@rubytech/create-sitedesk-code 0.1.515 → 0.1.516

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 (200) hide show
  1. package/dist/__tests__/launchd-bootstrap.test.js +164 -18
  2. package/dist/index.js +15 -14
  3. package/dist/launchd-bootstrap.js +86 -17
  4. package/package.json +1 -1
  5. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +53 -4
  6. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +6 -0
  7. package/payload/platform/plugins/docs/references/admin-ui.md +25 -2
  8. package/payload/platform/plugins/docs/references/deployment.md +4 -0
  9. package/payload/platform/plugins/email/PLUGIN.md +1 -1
  10. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-metadata-calendar.test.d.ts +2 -0
  11. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-metadata-calendar.test.d.ts.map +1 -0
  12. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-metadata-calendar.test.js +94 -0
  13. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-metadata-calendar.test.js.map +1 -0
  14. package/payload/platform/plugins/email/mcp/dist/index.js +1 -1
  15. package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
  16. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +21 -1
  17. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  18. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +49 -8
  19. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  20. package/payload/platform/plugins/email/references/email-reference.md +4 -0
  21. package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client-retry.test.js +143 -2
  22. package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client-retry.test.js.map +1 -1
  23. package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client.test.js +33 -3
  24. package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client.test.js.map +1 -1
  25. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-search-paging.test.js +142 -0
  26. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-search-paging.test.js.map +1 -1
  27. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.d.ts +8 -0
  28. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.d.ts.map +1 -1
  29. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js +40 -0
  30. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js.map +1 -1
  31. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.d.ts.map +1 -1
  32. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.js +54 -3
  33. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.js.map +1 -1
  34. package/payload/platform/plugins/outlook/references/graph-surfaces.md +9 -3
  35. package/payload/server/public/activity.html +5 -5
  36. package/payload/server/public/agents.html +4 -4
  37. package/payload/server/public/assets/{AdminLoginScreens-Cs1jSnOF.js → AdminLoginScreens-B5qKb3YK.js} +1 -1
  38. package/payload/server/public/assets/AdminLoginScreens-B5qKb3YK.js.br +0 -0
  39. package/payload/server/public/assets/AdminLoginScreens-B5qKb3YK.js.gz +0 -0
  40. package/payload/server/public/assets/{AdminShell-DkrURZnF.js → AdminShell-BgJEq2zJ.js} +1 -1
  41. package/payload/server/public/assets/AdminShell-BgJEq2zJ.js.br +0 -0
  42. package/payload/server/public/assets/AdminShell-BgJEq2zJ.js.gz +0 -0
  43. package/payload/server/public/assets/{activity-BgZISMuE.js → activity-DmnVUDYO.js} +1 -1
  44. package/payload/server/public/assets/activity-DmnVUDYO.js.br +0 -0
  45. package/payload/server/public/assets/activity-DmnVUDYO.js.gz +0 -0
  46. package/payload/server/public/assets/{admin-DjFysc3-.js → admin-Dpge4OG9.js} +1 -1
  47. package/payload/server/public/assets/admin-Dpge4OG9.js.br +0 -0
  48. package/payload/server/public/assets/admin-Dpge4OG9.js.gz +0 -0
  49. package/payload/server/public/assets/{agents-Bx-MSZW6.js → agents-Bmq9X7P3.js} +1 -1
  50. package/payload/server/public/assets/agents-Bmq9X7P3.js.br +0 -0
  51. package/payload/server/public/assets/agents-Bmq9X7P3.js.gz +0 -0
  52. package/payload/server/public/assets/{browser-1I7syhHu.js → browser-B1G-W80B.js} +1 -1
  53. package/payload/server/public/assets/browser-B1G-W80B.js.br +0 -0
  54. package/payload/server/public/assets/browser-B1G-W80B.js.gz +0 -0
  55. package/payload/server/public/assets/{calendar-D_04JIo1.js → calendar-CHgrOFIN.js} +1 -1
  56. package/payload/server/public/assets/calendar-CHgrOFIN.js.br +0 -0
  57. package/payload/server/public/assets/calendar-CHgrOFIN.js.gz +0 -0
  58. package/payload/server/public/assets/chat-CIwmmtwP.js +1 -0
  59. package/payload/server/public/assets/chat-CIwmmtwP.js.br +0 -0
  60. package/payload/server/public/assets/chat-CIwmmtwP.js.gz +0 -0
  61. package/payload/server/public/assets/chevron-left-EQjfaQ-n.js +1 -0
  62. package/payload/server/public/assets/chevron-left-EQjfaQ-n.js.br +0 -0
  63. package/payload/server/public/assets/chevron-right-Ct8jZUGP.js +1 -0
  64. package/payload/server/public/assets/chevron-right-Ct8jZUGP.js.br +0 -0
  65. package/payload/server/public/assets/clock-Cxz5ybck.js +1 -0
  66. package/payload/server/public/assets/clock-Cxz5ybck.js.br +0 -0
  67. package/payload/server/public/assets/clock-Cxz5ybck.js.gz +0 -0
  68. package/payload/server/public/assets/data-BQVXKVsJ.js +1 -0
  69. package/payload/server/public/assets/data-BQVXKVsJ.js.br +1 -0
  70. package/payload/server/public/assets/data-BQVXKVsJ.js.gz +0 -0
  71. package/payload/server/public/assets/{file-text-DkvKqHMx.js → file-text-BVqqaDun.js} +1 -1
  72. package/payload/server/public/assets/file-text-BVqqaDun.js.br +0 -0
  73. package/payload/server/public/assets/file-text-BVqqaDun.js.gz +0 -0
  74. package/payload/server/public/assets/{graph-CCZhrwZX.js → graph-CEcME0lp.js} +1 -1
  75. package/payload/server/public/assets/graph-CEcME0lp.js.br +0 -0
  76. package/payload/server/public/assets/graph-CEcME0lp.js.gz +0 -0
  77. package/payload/server/public/assets/{graph-labels-BJ7puQkX.js → graph-labels-Zl32_AOH.js} +1 -1
  78. package/payload/server/public/assets/graph-labels-Zl32_AOH.js.br +0 -0
  79. package/payload/server/public/assets/graph-labels-Zl32_AOH.js.gz +0 -0
  80. package/payload/server/public/assets/{maximize-2-Xdj804U1.js → maximize-2-rkkMyAhf.js} +1 -1
  81. package/payload/server/public/assets/maximize-2-rkkMyAhf.js.br +0 -0
  82. package/payload/server/public/assets/maximize-2-rkkMyAhf.js.gz +0 -0
  83. package/payload/server/public/assets/{operator-BkfNpBs7.js → operator-DlL023ch.js} +1 -1
  84. package/payload/server/public/assets/operator-DlL023ch.js.br +0 -0
  85. package/payload/server/public/assets/operator-DlL023ch.js.gz +0 -0
  86. package/payload/server/public/assets/{page-D5n8ShE7.js → page-BAmYYqxp.js} +1 -1
  87. package/payload/server/public/assets/page-BAmYYqxp.js.br +0 -0
  88. package/payload/server/public/assets/page-BAmYYqxp.js.gz +0 -0
  89. package/payload/server/public/assets/{page-BoYc9c4a.js → page-k0BhBkA2.js} +1 -1
  90. package/payload/server/public/assets/page-k0BhBkA2.js.br +0 -0
  91. package/payload/server/public/assets/page-k0BhBkA2.js.gz +0 -0
  92. package/payload/server/public/assets/{public-CX5XSrxD.js → public-Cqgfmo1D.js} +1 -1
  93. package/payload/server/public/assets/public-Cqgfmo1D.js.br +0 -0
  94. package/payload/server/public/assets/public-Cqgfmo1D.js.gz +0 -0
  95. package/payload/server/public/assets/{rotate-ccw-dh_OhL23.js → rotate-ccw-O3RmqoTy.js} +1 -1
  96. package/payload/server/public/assets/rotate-ccw-O3RmqoTy.js.br +0 -0
  97. package/payload/server/public/assets/rotate-ccw-O3RmqoTy.js.gz +0 -0
  98. package/payload/server/public/assets/{routines-bb7dxcrr.js → routines-DMYObMUy.js} +1 -1
  99. package/payload/server/public/assets/routines-DMYObMUy.js.br +0 -0
  100. package/payload/server/public/assets/routines-DMYObMUy.js.gz +0 -0
  101. package/payload/server/public/assets/{skills-TeMwxXmJ.js → skills-DON1BKu5.js} +1 -1
  102. package/payload/server/public/assets/skills-DON1BKu5.js.br +0 -0
  103. package/payload/server/public/assets/skills-DON1BKu5.js.gz +0 -0
  104. package/payload/server/public/assets/{tasks-BqBBJlkg.js → tasks-C2PhUSrf.js} +1 -1
  105. package/payload/server/public/assets/tasks-C2PhUSrf.js.br +0 -0
  106. package/payload/server/public/assets/tasks-C2PhUSrf.js.gz +0 -0
  107. package/payload/server/public/assets/{time-entry-format-Cy_T-wiD.js → time-entry-format-4zgmuQI0.js} +1 -1
  108. package/payload/server/public/assets/time-entry-format-4zgmuQI0.js.br +0 -0
  109. package/payload/server/public/assets/time-entry-format-4zgmuQI0.js.gz +0 -0
  110. package/payload/server/public/assets/{triangle-alert-D-bSbPnX.js → triangle-alert-By5D7wuI.js} +1 -1
  111. package/payload/server/public/assets/triangle-alert-By5D7wuI.js.br +0 -0
  112. package/payload/server/public/assets/triangle-alert-By5D7wuI.js.gz +0 -0
  113. package/payload/server/public/assets/{useCopyFeedback-L7L55nFl.js → useCopyFeedback-Dx0vtm9s.js} +1 -1
  114. package/payload/server/public/assets/useCopyFeedback-Dx0vtm9s.js.br +0 -0
  115. package/payload/server/public/assets/useCopyFeedback-Dx0vtm9s.js.gz +0 -0
  116. package/payload/server/public/assets/{useSubAccountSwitcher-BYl2c7xA.css → useSubAccountSwitcher-UQQDcj0B.css} +1 -1
  117. package/payload/server/public/assets/useSubAccountSwitcher-UQQDcj0B.css.br +0 -0
  118. package/payload/server/public/assets/useSubAccountSwitcher-UQQDcj0B.css.gz +0 -0
  119. package/payload/server/public/assets/{useVoiceRecorder-MuZ7lpet.js → useVoiceRecorder-BgwOCCwj.js} +1 -1
  120. package/payload/server/public/assets/useVoiceRecorder-BgwOCCwj.js.br +0 -0
  121. package/payload/server/public/assets/useVoiceRecorder-BgwOCCwj.js.gz +0 -0
  122. package/payload/server/public/assets/{wrench-DUG1SZQB.js → wrench-CHfMwLza.js} +1 -1
  123. package/payload/server/public/assets/wrench-CHfMwLza.js.br +0 -0
  124. package/payload/server/public/assets/wrench-CHfMwLza.js.gz +0 -0
  125. package/payload/server/public/browser.html +4 -4
  126. package/payload/server/public/calendar.html +7 -7
  127. package/payload/server/public/chat.html +13 -13
  128. package/payload/server/public/data.html +11 -11
  129. package/payload/server/public/graph.html +9 -9
  130. package/payload/server/public/index.html +14 -14
  131. package/payload/server/public/operator.html +14 -14
  132. package/payload/server/public/public.html +13 -13
  133. package/payload/server/public/routines.html +6 -6
  134. package/payload/server/public/skills.html +5 -5
  135. package/payload/server/public/tasks.html +6 -6
  136. package/payload/server/server.js +38 -4
  137. package/payload/server/public/assets/AdminLoginScreens-Cs1jSnOF.js.br +0 -0
  138. package/payload/server/public/assets/AdminLoginScreens-Cs1jSnOF.js.gz +0 -0
  139. package/payload/server/public/assets/AdminShell-DkrURZnF.js.br +0 -0
  140. package/payload/server/public/assets/AdminShell-DkrURZnF.js.gz +0 -0
  141. package/payload/server/public/assets/activity-BgZISMuE.js.br +0 -0
  142. package/payload/server/public/assets/activity-BgZISMuE.js.gz +0 -0
  143. package/payload/server/public/assets/admin-DjFysc3-.js.br +0 -0
  144. package/payload/server/public/assets/admin-DjFysc3-.js.gz +0 -0
  145. package/payload/server/public/assets/agents-Bx-MSZW6.js.br +0 -0
  146. package/payload/server/public/assets/agents-Bx-MSZW6.js.gz +0 -0
  147. package/payload/server/public/assets/browser-1I7syhHu.js.br +0 -0
  148. package/payload/server/public/assets/browser-1I7syhHu.js.gz +0 -0
  149. package/payload/server/public/assets/calendar-D_04JIo1.js.br +0 -0
  150. package/payload/server/public/assets/calendar-D_04JIo1.js.gz +0 -0
  151. package/payload/server/public/assets/chat-BFH3FM5a.js +0 -1
  152. package/payload/server/public/assets/chat-BFH3FM5a.js.br +0 -0
  153. package/payload/server/public/assets/chat-BFH3FM5a.js.gz +0 -0
  154. package/payload/server/public/assets/chevron-left-Ux9-qLkk.js +0 -1
  155. package/payload/server/public/assets/chevron-right-BwIuCfyG.js +0 -1
  156. package/payload/server/public/assets/chevron-right-BwIuCfyG.js.br +0 -0
  157. package/payload/server/public/assets/clock-DhJdZRhC.js +0 -1
  158. package/payload/server/public/assets/clock-DhJdZRhC.js.br +0 -0
  159. package/payload/server/public/assets/data-ChY_uSIk.js +0 -1
  160. package/payload/server/public/assets/data-ChY_uSIk.js.br +0 -0
  161. package/payload/server/public/assets/data-ChY_uSIk.js.gz +0 -0
  162. package/payload/server/public/assets/file-text-DkvKqHMx.js.br +0 -0
  163. package/payload/server/public/assets/file-text-DkvKqHMx.js.gz +0 -0
  164. package/payload/server/public/assets/graph-CCZhrwZX.js.br +0 -0
  165. package/payload/server/public/assets/graph-CCZhrwZX.js.gz +0 -0
  166. package/payload/server/public/assets/graph-labels-BJ7puQkX.js.br +0 -0
  167. package/payload/server/public/assets/graph-labels-BJ7puQkX.js.gz +0 -0
  168. package/payload/server/public/assets/maximize-2-Xdj804U1.js.br +0 -0
  169. package/payload/server/public/assets/maximize-2-Xdj804U1.js.gz +0 -0
  170. package/payload/server/public/assets/operator-BkfNpBs7.js.br +0 -0
  171. package/payload/server/public/assets/operator-BkfNpBs7.js.gz +0 -0
  172. package/payload/server/public/assets/page-BoYc9c4a.js.br +0 -0
  173. package/payload/server/public/assets/page-BoYc9c4a.js.gz +0 -0
  174. package/payload/server/public/assets/page-D5n8ShE7.js.br +0 -0
  175. package/payload/server/public/assets/page-D5n8ShE7.js.gz +0 -0
  176. package/payload/server/public/assets/public-CX5XSrxD.js.br +0 -0
  177. package/payload/server/public/assets/public-CX5XSrxD.js.gz +0 -0
  178. package/payload/server/public/assets/rotate-ccw-dh_OhL23.js.br +0 -0
  179. package/payload/server/public/assets/rotate-ccw-dh_OhL23.js.gz +0 -0
  180. package/payload/server/public/assets/routines-bb7dxcrr.js.br +0 -0
  181. package/payload/server/public/assets/routines-bb7dxcrr.js.gz +0 -0
  182. package/payload/server/public/assets/skills-TeMwxXmJ.js.br +0 -0
  183. package/payload/server/public/assets/skills-TeMwxXmJ.js.gz +0 -0
  184. package/payload/server/public/assets/tasks-BqBBJlkg.js.br +0 -0
  185. package/payload/server/public/assets/tasks-BqBBJlkg.js.gz +0 -0
  186. package/payload/server/public/assets/time-entry-format-Cy_T-wiD.js.br +0 -3
  187. package/payload/server/public/assets/time-entry-format-Cy_T-wiD.js.gz +0 -0
  188. package/payload/server/public/assets/triangle-alert-D-bSbPnX.js.br +0 -1
  189. package/payload/server/public/assets/triangle-alert-D-bSbPnX.js.gz +0 -0
  190. package/payload/server/public/assets/useCopyFeedback-L7L55nFl.js.br +0 -0
  191. package/payload/server/public/assets/useCopyFeedback-L7L55nFl.js.gz +0 -0
  192. package/payload/server/public/assets/useSubAccountSwitcher-BYl2c7xA.css.br +0 -0
  193. package/payload/server/public/assets/useSubAccountSwitcher-BYl2c7xA.css.gz +0 -0
  194. package/payload/server/public/assets/useVoiceRecorder-MuZ7lpet.js.br +0 -0
  195. package/payload/server/public/assets/useVoiceRecorder-MuZ7lpet.js.gz +0 -0
  196. package/payload/server/public/assets/wrench-DUG1SZQB.js.br +0 -0
  197. package/payload/server/public/assets/wrench-DUG1SZQB.js.gz +0 -0
  198. /package/payload/server/public/assets/{useSubAccountSwitcher-DH0P6QMV.js → useSubAccountSwitcher-CRWoQXuU.js} +0 -0
  199. /package/payload/server/public/assets/{useSubAccountSwitcher-DH0P6QMV.js.br → useSubAccountSwitcher-CRWoQXuU.js.br} +0 -0
  200. /package/payload/server/public/assets/{useSubAccountSwitcher-DH0P6QMV.js.gz → useSubAccountSwitcher-CRWoQXuU.js.gz} +0 -0
@@ -1,10 +1,12 @@
1
- // acceptance grid for launchd-bootstrap.ts (Task 1401).
1
+ // acceptance grid for launchd-bootstrap.ts (Task 1401, reshaped by Task 2069).
2
2
  //
3
- // Locks the darwin session-manager bootstrap contract: bootout (idempotent,
4
- // rc 3 "No such process" ignored) then bootstrap, with a bounded retry on
5
- // exit 5 ("Input/output error" a known transient/racey launchctl failure on
6
- // re-install). Pure logic the launchctl runner and the retry sleep are both
7
- // injected, so no real launchctl or wall-clock delay runs here.
3
+ // Locks the darwin LaunchAgent bootstrap contract: bootout (idempotent, rc 3
4
+ // "No such process" ignored), then poll `launchctl print` until the label
5
+ // leaves the domain, then bootstrap, with a bounded retry on exit 5
6
+ // ("Input/output error"returned for as long as the previous instance is
7
+ // still terminating). Pure logic — the launchctl runner, the sleep and the
8
+ // clock are all injected, so no real launchctl and no wall-clock delay runs
9
+ // here.
8
10
  //
9
11
  // Compiles to dist/__tests__/launchd-bootstrap.test.js so
10
12
  // `node --test dist/__tests__/*.test.js` picks it up after build.
@@ -13,33 +15,74 @@ import assert from "node:assert/strict";
13
15
  import { bootstrapLaunchAgent } from "../launchd-bootstrap.js";
14
16
  const OPTS = { gui: "gui/502", label: "com.rubytech.host-claude-session-manager", plistPath: "/tmp/sm.plist" };
15
17
  const noSleep = () => { };
16
- test("first bootstrap succeeds one bootout, one bootstrap, attempts=1", () => {
18
+ /** A fake clock the injected sleepFn advances, so the wait-budget arithmetic
19
+ * runs with no wall-clock cost. */
20
+ function fakeClock() {
21
+ let t = 0;
22
+ return { now: () => t, sleep: (ms) => { t += ms; } };
23
+ }
24
+ test("waits for the label to leave the domain before bootstrapping", () => {
25
+ const clock = fakeClock();
17
26
  const calls = [];
27
+ let prints = 0;
18
28
  const run = (args) => {
19
29
  calls.push(args);
20
- return args[0] === "bootstrap" ? { status: 0, stderr: "" } : { status: 3, stderr: "No such process" };
30
+ if (args[0] === "print") {
31
+ prints += 1;
32
+ // The old instance lingers for 3 polls, then leaves the domain.
33
+ return prints <= 3 ? { status: 0, stderr: "" } : { status: 113, stderr: "" };
34
+ }
35
+ if (args[0] === "bootstrap")
36
+ return { status: 0, stderr: "" };
37
+ return { status: 0, stderr: "" };
21
38
  };
22
- const r = bootstrapLaunchAgent(run, { ...OPTS, sleepFn: noSleep });
39
+ const r = bootstrapLaunchAgent(run, { ...OPTS, sleepFn: clock.sleep, nowFn: clock.now });
23
40
  assert.equal(r.ok, true);
24
41
  assert.equal(r.attempts, 1);
42
+ assert.equal(prints, 4); // 3 polls with the label present, 1 showing it gone
43
+ assert.equal(r.totalWaitedMs, 1500); // 3 sleeps of 500ms before the gone poll
44
+ const bootstrapIdx = calls.findIndex((c) => c[0] === "bootstrap");
45
+ assert.ok(bootstrapIdx > 3, `bootstrap issued at index ${bootstrapIdx}, before the label was gone`);
46
+ });
47
+ test("fresh install — bootout, one absence poll, bootstrap, attempts=1", () => {
48
+ const clock = fakeClock();
49
+ const calls = [];
50
+ const run = (args) => {
51
+ calls.push(args);
52
+ if (args[0] === "print")
53
+ return { status: 113, stderr: "" };
54
+ if (args[0] === "bootstrap")
55
+ return { status: 0, stderr: "" };
56
+ return { status: 3, stderr: "No such process" };
57
+ };
58
+ const r = bootstrapLaunchAgent(run, { ...OPTS, sleepFn: clock.sleep, nowFn: clock.now });
59
+ assert.equal(r.ok, true);
60
+ assert.equal(r.attempts, 1);
61
+ assert.equal(r.totalWaitedMs, 0); // gone on the first poll — a fresh install never waits
25
62
  assert.deepEqual(calls[0], ["bootout", "gui/502/com.rubytech.host-claude-session-manager"]);
26
- assert.deepEqual(calls[1], ["bootstrap", "gui/502", "/tmp/sm.plist"]);
27
- assert.equal(calls.length, 2);
63
+ assert.deepEqual(calls[1], ["print", "gui/502/com.rubytech.host-claude-session-manager"]);
64
+ assert.deepEqual(calls[2], ["bootstrap", "gui/502", "/tmp/sm.plist"]);
65
+ assert.equal(calls.length, 3);
28
66
  });
29
67
  test("exit 5 twice then 0 — retries and reports the winning attempt", () => {
68
+ const clock = fakeClock();
30
69
  let bootstraps = 0;
31
70
  const sleeps = [];
32
71
  const run = (args) => {
72
+ if (args[0] === "print")
73
+ return { status: 113, stderr: "" };
33
74
  if (args[0] !== "bootstrap")
34
75
  return { status: 3, stderr: "No such process" };
35
76
  bootstraps += 1;
36
77
  return bootstraps < 3 ? { status: 5, stderr: "Bootstrap failed: 5: Input/output error" } : { status: 0, stderr: "" };
37
78
  };
38
- const r = bootstrapLaunchAgent(run, { ...OPTS, sleepFn: (ms) => { sleeps.push(ms); } });
79
+ const r = bootstrapLaunchAgent(run, {
80
+ ...OPTS, sleepFn: (ms) => { sleeps.push(ms); clock.sleep(ms); }, nowFn: clock.now,
81
+ });
39
82
  assert.equal(r.ok, true);
40
83
  assert.equal(r.attempts, 3);
41
84
  assert.equal(bootstraps, 3);
42
- assert.equal(sleeps.length, 2); // slept before each of the two retries
85
+ assert.equal(sleeps.filter((ms) => ms === 500).length, 2); // slept before each of the two retries
43
86
  });
44
87
  test("exit 5 on every attempt — fails after the attempt cap, not before", () => {
45
88
  let bootstraps = 0;
@@ -69,17 +112,120 @@ test("non-5 non-zero bootstrap aborts immediately — no retry", () => {
69
112
  assert.equal(r.bootstrapStatus, 9);
70
113
  assert.equal(bootstraps, 1);
71
114
  });
72
- test("emits one structured log line per attempt with bootout and bootstrap rc", () => {
115
+ test("label never leaves the domain bootstraps anyway at the budget, wait is reported", () => {
116
+ const clock = fakeClock();
117
+ const lines = [];
118
+ const run = (args) => {
119
+ if (args[0] === "print")
120
+ return { status: 0, stderr: "" }; // always present
121
+ if (args[0] === "bootstrap")
122
+ return { status: 5, stderr: "Bootstrap failed: 5: Input/output error" };
123
+ return { status: 0, stderr: "" };
124
+ };
125
+ const r = bootstrapLaunchAgent(run, {
126
+ ...OPTS, maxAttempts: 1, sleepFn: clock.sleep, nowFn: clock.now, logger: (l) => lines.push(l),
127
+ });
128
+ assert.equal(r.ok, false);
129
+ assert.equal(r.lastPresent, true);
130
+ assert.equal(r.totalWaitedMs, 60_000);
131
+ assert.ok(lines.some((l) => /op=wait-timeout waitedMs=60000 polls=\d+ present=true probeFailures=0 action=bootstrap-anyway/.test(l)));
132
+ });
133
+ test("the wait budget is shared across attempts, not spent per attempt", () => {
134
+ const clock = fakeClock();
135
+ const run = (args) => {
136
+ if (args[0] === "print")
137
+ return { status: 0, stderr: "" };
138
+ if (args[0] === "bootstrap")
139
+ return { status: 5, stderr: "eio" };
140
+ return { status: 0, stderr: "" };
141
+ };
142
+ const r = bootstrapLaunchAgent(run, { ...OPTS, maxAttempts: 3, sleepFn: clock.sleep, nowFn: clock.now });
143
+ assert.equal(r.attempts, 3);
144
+ // 60s of waiting in total across three attempts, not 180s.
145
+ assert.equal(r.totalWaitedMs, 60_000);
146
+ });
147
+ test("each exit-5 retry re-enters the wait rather than firing after retryDelayMs", () => {
148
+ const clock = fakeClock();
149
+ const seq = [];
150
+ let prints = 0;
151
+ const run = (args) => {
152
+ seq.push(args[0]);
153
+ if (args[0] === "print") {
154
+ prints += 1;
155
+ // present for one poll per attempt, then gone
156
+ return prints % 2 === 1 ? { status: 0, stderr: "" } : { status: 113, stderr: "" };
157
+ }
158
+ if (args[0] === "bootstrap")
159
+ return { status: 5, stderr: "eio" };
160
+ return { status: 0, stderr: "" };
161
+ };
162
+ bootstrapLaunchAgent(run, { ...OPTS, maxAttempts: 2, sleepFn: clock.sleep, nowFn: clock.now });
163
+ assert.deepEqual(seq, [
164
+ "bootout", "print", "print", "bootstrap",
165
+ "bootout", "print", "print", "bootstrap",
166
+ ]);
167
+ });
168
+ test("a probe that cannot run is not read as an absent label", () => {
169
+ const clock = fakeClock();
170
+ const lines = [];
171
+ const run = (args) => {
172
+ // status null = launchctl could not be spawned, or was killed by the
173
+ // spawnSync timeout. That is not evidence the label is gone.
174
+ if (args[0] === "print")
175
+ return { status: null, stderr: "" };
176
+ if (args[0] === "bootstrap")
177
+ return { status: 5, stderr: "eio" };
178
+ return { status: 0, stderr: "" };
179
+ };
180
+ const r = bootstrapLaunchAgent(run, {
181
+ ...OPTS, maxAttempts: 1, sleepFn: clock.sleep, nowFn: clock.now, logger: (l) => lines.push(l),
182
+ });
183
+ assert.equal(r.lastPresent, true); // never confirmed gone
184
+ assert.equal(r.totalWaitedMs, 60_000); // the full wait was served, not skipped
185
+ assert.ok(r.probeFailures > 0);
186
+ assert.ok(lines.some((l) => /op=wait-poll present=true probeRc=null/.test(l)));
187
+ });
188
+ test("the wait terminates on the poll cap even if the clock never advances", () => {
189
+ // A wall clock that steps backwards (an NTP correction) would leave the
190
+ // elapsed-time test permanently unsatisfied. Termination must not depend on
191
+ // time moving forwards.
192
+ const frozenNow = () => 0;
193
+ let polls = 0;
194
+ const run = (args) => {
195
+ if (args[0] === "print") {
196
+ polls += 1;
197
+ return { status: 0, stderr: "" };
198
+ }
199
+ if (args[0] === "bootstrap")
200
+ return { status: 5, stderr: "eio" };
201
+ return { status: 0, stderr: "" };
202
+ };
203
+ const r = bootstrapLaunchAgent(run, {
204
+ ...OPTS, maxAttempts: 1, sleepFn: noSleep, nowFn: frozenNow,
205
+ });
206
+ assert.equal(r.ok, false);
207
+ assert.equal(r.totalWaitedMs, 0); // the frozen clock measures no elapsed time
208
+ assert.equal(polls, 121); // ceil(60000/500) + 1 — the cap, not an infinite loop
209
+ });
210
+ test("emits one structured line per lifecycle step, correlated by agent label", () => {
211
+ const clock = fakeClock();
73
212
  let bootstraps = 0;
74
213
  const lines = [];
75
214
  const run = (args) => {
215
+ if (args[0] === "print")
216
+ return { status: 113, stderr: "" };
76
217
  if (args[0] !== "bootstrap")
77
218
  return { status: 3, stderr: "No such process" };
78
219
  bootstraps += 1;
79
220
  return bootstraps < 2 ? { status: 5, stderr: "eio" } : { status: 0, stderr: "" };
80
221
  };
81
- bootstrapLaunchAgent(run, { ...OPTS, sleepFn: noSleep, logger: (l) => lines.push(l) });
82
- assert.equal(lines.length, 2);
83
- assert.match(lines[0], /agent=com\.rubytech\.host-claude-session-manager bootout=3 bootstrap=5 attempt=1/);
84
- assert.match(lines[1], /bootout=3 bootstrap=0 attempt=2/);
222
+ bootstrapLaunchAgent(run, { ...OPTS, sleepFn: clock.sleep, nowFn: clock.now, logger: (l) => lines.push(l) });
223
+ const ops = lines.map((l) => l.match(/op=([a-z-]+)/)[1]);
224
+ assert.deepEqual(ops, [
225
+ "bootout", "wait-poll", "gone", "bootstrap",
226
+ "bootout", "wait-poll", "gone", "bootstrap", "result",
227
+ ]);
228
+ assert.ok(lines.every((l) => l.startsWith("[launchd] agent=com.rubytech.host-claude-session-manager ")));
229
+ assert.match(lines[3], /op=bootstrap rc=5 attempt=1 waitedMs=0/);
230
+ assert.match(lines[8], /op=result ok=true attempts=2 totalWaitedMs=0 probeFailures=0 rc=0/);
85
231
  });
package/dist/index.js CHANGED
@@ -1361,8 +1361,9 @@ function resetNeo4jAuth(port = DEFAULT_NEO4J_PORT, dataDir = "/var/lib/neo4j", p
1361
1361
  const label = `${launchdLabel()}-neo4j`;
1362
1362
  console.log(" Resetting dedicated Neo4j auth (launchd)...");
1363
1363
  spawnSync("launchctl", ["bootout", `${gui()}/${label}`], { stdio: "pipe" });
1364
- // launchctl bootout returns once the job leaves the domain, but neo4j does a
1365
- // graceful multi-second store flush on SIGTERM — so the JVM can still hold
1364
+ // launchctl bootout returns as soon as launchd accepts the request (Task
1365
+ // 2069), and neo4j does a graceful multi-second store flush on SIGTERM — so
1366
+ // the JVM can still hold
1366
1367
  // the port (and the store) after bootout returns. Wait for the bolt port to
1367
1368
  // free before clearing the auth db and re-seeding: set-initial-password only
1368
1369
  // takes effect against a stopped store, and rm must not race a live flush.
@@ -1386,7 +1387,7 @@ function resetNeo4jAuth(port = DEFAULT_NEO4J_PORT, dataDir = "/var/lib/neo4j", p
1386
1387
  const plistPath = join(launchAgentsDir(), `${label}.plist`);
1387
1388
  const boot = bootstrapLaunchAgent((a) => { const r = spawnSync("launchctl", a, { stdio: "pipe", encoding: "utf-8", timeout: 15_000 }); return { status: r.status, stderr: (r.stderr ?? "").toString() }; }, { gui: gui(), label, plistPath, logger: (l) => { console.log(` ${l}`); logFile(` ${l}`); } });
1388
1389
  if (!boot.ok) {
1389
- throw new Error(`launchctl bootstrap ${label} failed (exit ${boot.bootstrapStatus}) after ${boot.attempts} attempt(s) during dedicated darwin auth reset: ${boot.stderr}`);
1390
+ throw new Error(`launchctl bootstrap ${label} failed (exit ${boot.bootstrapStatus}) after ${boot.attempts} attempt(s), waited ${boot.totalWaitedMs}ms, stillPresent=${boot.lastPresent}, probeFailures=${boot.probeFailures} during dedicated darwin auth reset: ${boot.stderr}`);
1390
1391
  }
1391
1392
  logFile(` [neo4j-darwin-auth-reset] dedicated datadir=${dataDir} conf=${p.confDir} label=${label}`);
1392
1393
  }
@@ -1804,7 +1805,7 @@ function setupDedicatedNeo4jDarwin() {
1804
1805
  logFile(` ${plistPath} written (${plist.length} bytes)`);
1805
1806
  const boot = bootstrapLaunchAgent((a) => { const r = spawnSync("launchctl", a, { stdio: "pipe", encoding: "utf-8", timeout: 15_000 }); return { status: r.status, stderr: (r.stderr ?? "").toString() }; }, { gui: gui(), label, plistPath, logger: (l) => { console.log(` ${l}`); logFile(` ${l}`); } });
1806
1807
  if (!boot.ok) {
1807
- throw new Error(`launchctl bootstrap ${label} failed (exit ${boot.bootstrapStatus}) after ${boot.attempts} attempt(s): ${boot.stderr}`);
1808
+ throw new Error(`launchctl bootstrap ${label} failed (exit ${boot.bootstrapStatus}) after ${boot.attempts} attempt(s), waited ${boot.totalWaitedMs}ms, stillPresent=${boot.lastPresent}, probeFailures=${boot.probeFailures}: ${boot.stderr}`);
1808
1809
  }
1809
1810
  // Health-check the brand's bolt port — loud failure, no silent fallback to the
1810
1811
  // shared instance (matches the Linux dedicated bind check).
@@ -3891,12 +3892,12 @@ function installServiceDarwin() {
3891
3892
  });
3892
3893
  if (smBoot.ok) {
3893
3894
  console.log(` [launchd] bootstrap ${gui()}/${smLabel} ok`);
3894
- logFile(` [create-maxy] launchd-plist=${smLabel} loaded=true attempts=${smBoot.attempts}`);
3895
+ logFile(` [create-maxy] launchd-plist=${smLabel} loaded=true attempts=${smBoot.attempts} waitedMs=${smBoot.totalWaitedMs}`);
3895
3896
  }
3896
3897
  else {
3897
- console.error(` [launchd] bootstrap ${smLabel} returned ${smBoot.bootstrapStatus} after ${smBoot.attempts} attempt(s): ${smBoot.stderr}`);
3898
- logFile(` [create-maxy] launchd-plist=${smLabel} loaded=false exit=${smBoot.bootstrapStatus} attempts=${smBoot.attempts}`);
3899
- throw new Error(`launchctl bootstrap ${smLabel} failed (exit ${smBoot.bootstrapStatus}) after ${smBoot.attempts} attempt(s): ${smBoot.stderr}`);
3898
+ console.error(` [launchd] bootstrap ${smLabel} returned ${smBoot.bootstrapStatus} after ${smBoot.attempts} attempt(s), waited ${smBoot.totalWaitedMs}ms, stillPresent=${smBoot.lastPresent}, probeFailures=${smBoot.probeFailures}: ${smBoot.stderr}`);
3899
+ logFile(` [create-maxy] launchd-plist=${smLabel} loaded=false exit=${smBoot.bootstrapStatus} attempts=${smBoot.attempts} waitedMs=${smBoot.totalWaitedMs} stillPresent=${smBoot.lastPresent} probeFailures=${smBoot.probeFailures}`);
3900
+ throw new Error(`launchctl bootstrap ${smLabel} failed (exit ${smBoot.bootstrapStatus}) after ${smBoot.attempts} attempt(s), waited ${smBoot.totalWaitedMs}ms, stillPresent=${smBoot.lastPresent}, probeFailures=${smBoot.probeFailures}: ${smBoot.stderr}`);
3900
3901
  }
3901
3902
  // Health-probe the manager (/healthz) so a silent boot failure surfaces at
3902
3903
  // install time, not at first session spawn as `manager-unreachable`.
@@ -3947,11 +3948,11 @@ function installServiceDarwin() {
3947
3948
  logger: (line) => { console.log(` ${line}`); logFile(` ${line}`); },
3948
3949
  });
3949
3950
  if (hbBoot.ok) {
3950
- logFile(` [create-maxy] launchd-plist=${hbLabel} loaded=true attempts=${hbBoot.attempts}`);
3951
+ logFile(` [create-maxy] launchd-plist=${hbLabel} loaded=true attempts=${hbBoot.attempts} waitedMs=${hbBoot.totalWaitedMs}`);
3951
3952
  }
3952
3953
  else {
3953
- console.error(` WARNING: heartbeat LaunchAgent ${hbLabel} bootstrap returned ${hbBoot.bootstrapStatus} after ${hbBoot.attempts} attempt(s): ${hbBoot.stderr} — scheduling ticks will not fire until a re-install succeeds.`);
3954
- logFile(` [create-maxy] launchd-plist=${hbLabel} loaded=false exit=${hbBoot.bootstrapStatus} attempts=${hbBoot.attempts}`);
3954
+ console.error(` WARNING: heartbeat LaunchAgent ${hbLabel} bootstrap returned ${hbBoot.bootstrapStatus} after ${hbBoot.attempts} attempt(s), waited ${hbBoot.totalWaitedMs}ms, stillPresent=${hbBoot.lastPresent}, probeFailures=${hbBoot.probeFailures}: ${hbBoot.stderr} — scheduling ticks will not fire until a re-install succeeds.`);
3955
+ logFile(` [create-maxy] launchd-plist=${hbLabel} loaded=false exit=${hbBoot.bootstrapStatus} attempts=${hbBoot.attempts} waitedMs=${hbBoot.totalWaitedMs} stillPresent=${hbBoot.lastPresent} probeFailures=${hbBoot.probeFailures}`);
3955
3956
  }
3956
3957
  // Task 1706 — the log rotator. On Linux a */15 entry in the per-brand cron
3957
3958
  // block ticks logs-rotate.sh (installCrons/buildBrandCronBlock); darwin has no
@@ -3987,11 +3988,11 @@ function installServiceDarwin() {
3987
3988
  logger: (line) => { console.log(` ${line}`); logFile(` ${line}`); },
3988
3989
  });
3989
3990
  if (lrBoot.ok) {
3990
- logFile(` [create-maxy] launchd-plist=${lrLabel} loaded=true attempts=${lrBoot.attempts}`);
3991
+ logFile(` [create-maxy] launchd-plist=${lrLabel} loaded=true attempts=${lrBoot.attempts} waitedMs=${lrBoot.totalWaitedMs}`);
3991
3992
  }
3992
3993
  else {
3993
- console.error(` WARNING: logs-rotate LaunchAgent ${lrLabel} bootstrap returned ${lrBoot.bootstrapStatus} after ${lrBoot.attempts} attempt(s): ${lrBoot.stderr} — brand logs will not be rotated until a re-install succeeds.`);
3994
- logFile(` [create-maxy] launchd-plist=${lrLabel} loaded=false exit=${lrBoot.bootstrapStatus} attempts=${lrBoot.attempts}`);
3994
+ console.error(` WARNING: logs-rotate LaunchAgent ${lrLabel} bootstrap returned ${lrBoot.bootstrapStatus} after ${lrBoot.attempts} attempt(s), waited ${lrBoot.totalWaitedMs}ms, stillPresent=${lrBoot.lastPresent}, probeFailures=${lrBoot.probeFailures}: ${lrBoot.stderr} — brand logs will not be rotated until a re-install succeeds.`);
3995
+ logFile(` [create-maxy] launchd-plist=${lrLabel} loaded=false exit=${lrBoot.bootstrapStatus} attempts=${lrBoot.attempts} waitedMs=${lrBoot.totalWaitedMs} stillPresent=${lrBoot.lastPresent} probeFailures=${lrBoot.probeFailures}`);
3995
3996
  }
3996
3997
  // Wait for the server to come up.
3997
3998
  console.log(" Waiting for web server...");
@@ -1,19 +1,34 @@
1
- // Task 1401 — darwin session-manager LaunchAgent bootstrap, made idempotent and
1
+ // Task 1401 / Task 2069 — darwin LaunchAgent bootstrap, made idempotent and
2
2
  // EIO-tolerant.
3
3
  //
4
- // `launchctl bootstrap` intermittently returns exit 5 ("Bootstrap failed: 5:
5
- // Input/output error") on a re-install even when the target label is not
6
- // loaded (a `bootout` of it returns 3, "No such process"). It is a known
7
- // transient/racey launchd failure: bootstrapping the same plist from a clean
8
- // state succeeds. The earlier phase-11 code issued a single bare `bootstrap`
9
- // and treated any non-zero exit as fatal, so one EIO aborted the whole install.
4
+ // `launchctl bootstrap` returns exit 5 ("Bootstrap failed: 5: Input/output
5
+ // error") when the label's previous instance has not finished terminating.
6
+ // 1401 read this as a transient racey failure and retried three times 500 ms
7
+ // apart; 2069 measured it instead. `launchctl bootout` returns as soon as
8
+ // launchd accepts the request, but the job's process keeps running — a
9
+ // dedicated Neo4j's JVM took 14.2 s to reach "Stopped." on the Muvin iMac while
10
+ // all three retries fired inside 1.043 s and every one got exit 5. The failure
11
+ // is deterministic for the length of that shutdown, not random.
10
12
  //
11
- // This helper bootout-then-bootstraps with a bounded retry on exit 5. Both the
12
- // launchctl runner and the retry sleep are injected, so the logic is unit-tested
13
- // with no real launchctl and no wall-clock delay. Kept synchronous to match the
14
- // spawnSync-based installer body (installService is a synchronous `void`).
15
- /** `launchctl bootstrap` exit code for "Input/output error" the transient,
16
- * racey failure this retry loop exists to absorb. */
13
+ // So this helper boots the label out, polls `launchctl print <gui>/<label>`
14
+ // until the label leaves the domain, and only then bootstraps. `print` returns
15
+ // 0 for the whole time the old process lives and non-zero once it exits
16
+ // (measured: rc 113), which makes it an exit-code probe rather than prose
17
+ // parsing. The exit-5 retry is kept for the residual case, and each retry
18
+ // re-enters the wait.
19
+ //
20
+ // What `print`'s exit code does NOT report is liveness: a job that loads and
21
+ // exits immediately still returns rc 0, with "state = not running" appearing
22
+ // only in stdout. Detecting a dead-on-arrival agent therefore needs a different
23
+ // primitive and is not attempted here.
24
+ //
25
+ // The launchctl runner, the sleep, and the clock are all injected, so the logic
26
+ // is unit-tested with no real launchctl and no wall-clock delay. Kept
27
+ // synchronous to match the spawnSync-based installer body (installService is a
28
+ // synchronous `void`).
29
+ /** `launchctl bootstrap` exit code for "Input/output error" — returned for as
30
+ * long as the label's previous instance is still terminating. The wait above
31
+ * is what removes that window; the retry covers what is left. */
17
32
  const BOOTSTRAP_EIO = 5;
18
33
  /** Blocking sleep with no imports, safe inside the synchronous installer. */
19
34
  function blockingSleep(ms) {
@@ -24,20 +39,69 @@ function blockingSleep(ms) {
24
39
  export function bootstrapLaunchAgent(run, o) {
25
40
  const maxAttempts = Math.max(1, o.maxAttempts ?? 3);
26
41
  const retryDelayMs = o.retryDelayMs ?? 500;
42
+ const waitBudgetMs = o.waitBudgetMs ?? 60_000;
43
+ const pollIntervalMs = Math.max(1, o.pollIntervalMs ?? 500);
44
+ const now = o.nowFn ?? Date.now;
27
45
  const sleepFn = o.sleepFn ?? blockingSleep;
28
46
  const log = o.logger ?? (() => { });
47
+ const target = `${o.gui}/${o.label}`;
48
+ const tag = `[launchd] agent=${o.label}`;
49
+ // The wall clock is not monotonic — an NTP step backwards during the wait
50
+ // would leave the elapsed-time test below permanently unsatisfied. This poll
51
+ // allowance bounds the loop independently of the clock, so termination never
52
+ // depends on time moving forwards.
53
+ let pollsRemaining = Math.ceil(waitBudgetMs / pollIntervalMs) + 1;
54
+ let totalWaitedMs = 0;
55
+ let lastPresent = false;
56
+ let probeFailures = 0;
29
57
  let attempt = 0;
30
58
  let last = { status: null, stderr: "" };
31
59
  for (attempt = 1; attempt <= maxAttempts; attempt++) {
32
60
  // Idempotent: bootout any prior instance so the following bootstrap starts
33
61
  // clean. rc 3 ("No such process") is the expected fresh case; it and any
34
62
  // other bootout rc are ignored — bootout is cleanup, not the verified op.
35
- const bootout = run(["bootout", `${o.gui}/${o.label}`]);
63
+ const bootout = run(["bootout", target]);
64
+ log(`${tag} op=bootout rc=${bootout.status ?? "null"} attempt=${attempt}`);
65
+ // bootout returns as soon as launchd accepts the request, but the job's
66
+ // process can keep running for seconds (Neo4j's JVM: 14.2 s measured on the
67
+ // Muvin iMac). `launchctl print` returns 0 for that whole window and
68
+ // non-zero once the process is gone, so it is the absence probe.
69
+ // Bootstrapping before that is what returns EIO.
70
+ const waitStart = now();
71
+ let present = true;
72
+ let polls = 0;
73
+ for (;;) {
74
+ const p = run(["print", target]);
75
+ polls += 1;
76
+ pollsRemaining -= 1;
77
+ // Three outcomes, not two. rc 0 = still in the domain. A non-zero rc
78
+ // (113) = gone. A null status means the probe could not run at all —
79
+ // that is not evidence of absence, so the wait continues rather than
80
+ // treating an unrunnable probe as a clear domain.
81
+ const gone = p.status !== null && p.status !== 0;
82
+ if (p.status === null)
83
+ probeFailures += 1;
84
+ present = !gone;
85
+ const elapsedMs = now() - waitStart;
86
+ log(`${tag} op=wait-poll present=${present} probeRc=${p.status ?? "null"} elapsedMs=${elapsedMs}`);
87
+ if (gone) {
88
+ log(`${tag} op=gone goneMs=${elapsedMs} polls=${polls} attempt=${attempt}`);
89
+ break;
90
+ }
91
+ if (totalWaitedMs + elapsedMs >= waitBudgetMs || pollsRemaining <= 0) {
92
+ log(`${tag} op=wait-timeout waitedMs=${totalWaitedMs + elapsedMs} polls=${polls} present=true probeFailures=${probeFailures} action=bootstrap-anyway`);
93
+ break;
94
+ }
95
+ sleepFn(pollIntervalMs);
96
+ }
97
+ totalWaitedMs += Math.max(0, now() - waitStart);
98
+ lastPresent = present;
36
99
  const bootstrap = run(["bootstrap", o.gui, o.plistPath]);
37
100
  last = { status: bootstrap.status, stderr: (bootstrap.stderr ?? "").trim() };
38
- log(`[launchd] agent=${o.label} bootout=${bootout.status ?? "null"} bootstrap=${bootstrap.status ?? "null"} attempt=${attempt}`);
101
+ log(`${tag} op=bootstrap rc=${bootstrap.status ?? "null"} attempt=${attempt} waitedMs=${totalWaitedMs}`);
39
102
  if (bootstrap.status === 0) {
40
- return { ok: true, attempts: attempt, bootstrapStatus: 0, stderr: last.stderr };
103
+ log(`${tag} op=result ok=true attempts=${attempt} totalWaitedMs=${totalWaitedMs} probeFailures=${probeFailures} rc=0`);
104
+ return { ok: true, attempts: attempt, bootstrapStatus: 0, stderr: last.stderr, totalWaitedMs, lastPresent, probeFailures };
41
105
  }
42
106
  // Only exit 5 (EIO) is retried, and only while attempts remain. Every other
43
107
  // non-zero status is a real failure that aborts now rather than burning the
@@ -48,10 +112,15 @@ export function bootstrapLaunchAgent(run, o) {
48
112
  }
49
113
  break;
50
114
  }
115
+ const attempts = Math.min(attempt, maxAttempts);
116
+ log(`${tag} op=result ok=false attempts=${attempts} totalWaitedMs=${totalWaitedMs} probeFailures=${probeFailures} rc=${last.status ?? "null"}`);
51
117
  return {
52
118
  ok: false,
53
- attempts: Math.min(attempt, maxAttempts),
119
+ attempts,
54
120
  bootstrapStatus: last.status,
55
121
  stderr: last.stderr,
122
+ totalWaitedMs,
123
+ lastPresent,
124
+ probeFailures,
56
125
  };
57
126
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-sitedesk-code",
3
- "version": "0.1.515",
3
+ "version": "0.1.516",
4
4
  "description": "Install SiteDesk — automated back office for independent building contractors",
5
5
  "bin": {
6
6
  "create-sitedesk-code": "./dist/index.js"
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what SiteDesk ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:414b3a1a40f8b871635c0746cd30d4ea4203fd9baa8f4727c435c75c01f9bc2a
4
+ content-hash: sha256:f164b44ebf79eb2e637d1157465fb53eb4d226989b350d3dbcf2df4fc8afd6b2
5
5
  brand: sitedesk-code
6
6
  product-name: SiteDesk
7
7
  ---
@@ -793,7 +793,29 @@ cat ~/Library/LaunchAgents/com.rubytech.maxy-code.plist
793
793
 
794
794
  The plist points at the wrapper script the installer wrote and at log files under `$HOME/.maxy-code/logs/`. `launchctl bootstrap`'s exit code is recorded in the install log as `[create-maxy] launchd-plist=… loaded=true|false`.
795
795
 
796
- Each brand runs two LaunchAgents: the main service (`com.rubytech.maxy-code`) and a session manager (`com.rubytech.maxy-code-claude-session-manager`). On a re-install `launchctl bootstrap` of the session-manager agent can return a transient `exit 5: Input/output error` even when the agent was not already loaded; the installer boots the agent out and retries automatically. Each attempt is logged as `[launchd] agent=<label> bootout=<rc> bootstrap=<rc> attempt=<n>`, and only an EIO that persists across every attempt aborts the install.
796
+ Each brand runs the main service (`com.rubytech.maxy-code`) plus three supporting LaunchAgents: a session manager (`…-claude-session-manager`), a heartbeat (`…-heartbeat`) and a log rotator (`…-logs-rotate`). A brand with a dedicated Neo4j adds a fifth, `…-neo4j`.
797
+
798
+ On a re-install the installer boots an agent out before bootstrapping it back. `launchctl bootout` returns as soon as launchd accepts the request, but the job's process keeps running — Neo4j's JVM took 14.2 s to finish its store flush on one host — and `launchctl bootstrap` returns `exit 5: Input/output error` for that entire window. So after the bootout the installer polls `launchctl print gui/$UID/<label>` every 500 ms until the label leaves the domain, up to 60 s in total across all attempts, and only then bootstraps. If the label has still not left when the budget runs out, it bootstraps anyway and the resulting exit code decides.
799
+
800
+ This applies to the four supporting agents and the dedicated Neo4j agent. The main service (`com.rubytech.maxy-code`) bootstraps directly and does not wait.
801
+
802
+ Every step is one line in the install log, keyed by `agent=<label>`:
803
+
804
+ ```
805
+ [launchd] agent=<label> op=bootout rc=<n> attempt=<n>
806
+ [launchd] agent=<label> op=wait-poll present=<bool> probeRc=<n|null> elapsedMs=<n>
807
+ [launchd] agent=<label> op=gone goneMs=<n> polls=<n> attempt=<n>
808
+ [launchd] agent=<label> op=bootstrap rc=<n> attempt=<n> waitedMs=<n>
809
+ [launchd] agent=<label> op=result ok=<bool> attempts=<n> totalWaitedMs=<n> probeFailures=<n> rc=<n>
810
+ ```
811
+
812
+ Read one agent's whole lifeline with:
813
+
814
+ ```bash
815
+ grep '\[launchd\]' ~/.maxy-code/logs/install-*.log | grep 'agent=com.rubytech.maxy-code-neo4j'
816
+ ```
817
+
818
+ `op=gone goneMs=<n>` is how long the previous instance actually took to exit. `op=wait-timeout` means it never left within 60 s. `probeRc=null` on a `wait-poll` line means `launchctl print` could not run at all — the wait then continues rather than treating an unrunnable probe as a clear domain, and `probeFailures=<n>` on the result line counts how often that happened. Only an EIO that persists across every attempt aborts the install.
797
819
 
798
820
  ## 4. Open the admin UI
799
821
 
@@ -3134,6 +3156,22 @@ both `kind: 'specialist'` and differ only by origin.
3134
3156
  highest-capability agents on the box were the ones the operator could not see.
3135
3157
  These are the only agents that can be disabled, and the route enforces that
3136
3158
  rather than relying on the UI to withhold the control.
3159
+ **A file in the LIVE directory whose name also exists under
3160
+ `<accountDir>/plugins/*/agents/` is excluded from this set.** `agent-builder`
3161
+ copies the authored file into `specialists/agents/` because presence there is
3162
+ what makes an agent dispatchable, so the live directory says nothing about who
3163
+ wrote the agent and every activated user-created specialist used to render
3164
+ twice, the second time as Shipped. The account plugin tree is the authorship
3165
+ evidence and its row wins, which is also why `disable` refuses a slug present
3166
+ under `plugins/*/agents/`: quarantine exists to stop provisioning re-delivering
3167
+ a file, and provisioning never writes that tree. Both gates read filenames off
3168
+ disk, so a plugin file whose frontmatter does not parse still suppresses its
3169
+ live twin — it is authored either way, and `specialistsSkipped` is what reports
3170
+ the parse failure. The exclusion applies to the live copy only: a copy found in
3171
+ quarantine is operator state, keeps its Shipped row and its `disabled:true`,
3172
+ and is therefore still switchable back on. Each exclusion logs
3173
+ `op=list-shipped-dedup file=<name>` and the count rides `op=list` and the
3174
+ response as `deduped`.
3137
3175
 
3138
3176
  The former "Public" flyout toggle in the account menu was removed; this page is
3139
3177
  the sole agent surface.
@@ -3201,9 +3239,9 @@ explicit `?plugin=` parameter, not inferred from `--` in the slug.
3201
3239
 
3202
3240
  | Route | Behaviour |
3203
3241
  |-------|-----------|
3204
- | `GET /api/admin/agents` | Session-scoped. Lists the session account's public `agents/*/` dirs (never the `admin` agent), its user-created specialists from `plugins/*/agents/*.md`, and its shipped specialists, each row tagged `kind` and `origin` and carrying `risk`, `riskiestTool`, `unresolved` and `disabled`. Returns `{agents, accountId, skipped, specialistsSkipped, shippedSkipped}`. 401 when the session maps to no account. |
3242
+ | `GET /api/admin/agents` | Session-scoped. Lists the session account's public `agents/*/` dirs (never the `admin` agent), its user-created specialists from `plugins/*/agents/*.md`, and its shipped specialists, each row tagged `kind` and `origin` and carrying `risk`, `riskiestTool`, `unresolved` and `disabled`. A live-directory candidate whose filename also exists under `plugins/*/agents/` is dropped and counted, so an activated user-created agent is listed once. Returns `{agents, accountId, skipped, specialistsSkipped, shippedSkipped, deduped, riskSurfaceFailed}`. 401 when the session maps to no account. |
3205
3243
  | `GET /api/admin/agents/:slug` | Session-scoped. Without `?plugin=` or `?origin=`, returns a public agent's config fields + four owned docs + a `present` map (a missing or unreadable doc is `''`/`present.<role>=false`, never a 500). With `?plugin=`, that plugin's user-created specialist; with `?origin=shipped`, the shipped one. Both specialist shapes add `{risk, riskiestTool, unresolved, byTool, disabled}`, where `byTool` is one class per declared tool. Selection is explicit rather than inferred, because a premium file and a user-created specialist under a plugin of the same name produce the same slug. Unknown 404s, 401 as above. |
3206
- | `POST /api/admin/agents/:slug/disable?accountId=` | Shipped agents only, enforced here: a slug present in none of the three shipped directories 404s. Moves the file from `specialists/agents/` to `specialists/agents-disabled/` and records the basename in the 0600 store. Returns `{ok, moved}`; `moved:false` means the agent existed only as a bundled template, so nothing was there to move and the store entry is what stops the next provisioning run delivering it. The store is read strictly before anything moves, so an unreadable store 500s with the file untouched rather than rewriting the file whole from an empty set and dropping every other disabled agent. Returns `routingReconciled`, which is `false` when the AGENTS.md reconcile threw: the move and the store write already happened, so the request is not failed, and the caller is told rather than left assuming the prose is correct. Same `accountId` contract as delete. |
3244
+ | `POST /api/admin/agents/:slug/disable?accountId=` | Shipped agents only, enforced here: a slug present in none of the three shipped directories 404s, and so does a slug present under `plugins/*/agents/` (a user-created specialist, `reason=user-created-specialist`), because its activated copy in the live directory would otherwise pass the shipped test. Moves the file from `specialists/agents/` to `specialists/agents-disabled/` and records the basename in the 0600 store. Returns `{ok, moved}`; `moved:false` means the agent existed only as a bundled template, so nothing was there to move and the store entry is what stops the next provisioning run delivering it. The store is read strictly before anything moves, so an unreadable store 500s with the file untouched rather than rewriting the file whole from an empty set and dropping every other disabled agent. Returns `routingReconciled`, which is `false` when the AGENTS.md reconcile threw: the move and the store write already happened, so the request is not failed, and the caller is told rather than left assuming the prose is correct. Same `accountId` contract as delete. |
3207
3245
  | `POST /api/admin/agents/:slug/enable?accountId=` | Covers both of disable's outcomes. Returns `{ok, restored}`: a quarantined file moves back (`restored:true`); a bundled-only agent has nothing to move, so clearing the store entry is the whole job (`restored:false`), which is what stops provisioning withholding it. Treating that second case as "nothing to restore" made disable a one-way door. 404 only when neither directory nor store knows the agent. Carries the same `routingReconciled` field; the restored file is what puts the routing line back. |
3208
3246
  | `DELETE /api/admin/agents/:slug?accountId=` | Public agents only, on the named validated account. Removes the dir after `deleteAgentProjection`; refuses the `admin` slug (403) and a missing/unknown `accountId` (400) with no write. Loud-fail: a graph-cleanup throw aborts the file removal. |
3209
3247
  | `POST /api/admin/agents/:slug/project?accountId=` | Re-projects the named account's on-disk agent into the graph. Same `accountId` contract as delete. |
@@ -3231,6 +3269,13 @@ Agents page survived undiagnosed on a multi-account install.
3231
3269
  - `op=delete accountId=<id8> slug=<…> outcome=<ok|failed>` with
3232
3270
  `reason=<graph-cleanup-failed|rm-error>` on failure (files preserved on a
3233
3271
  graph-cleanup throw).
3272
+ - `op=list-shipped-dedup file=<name>` — one line per live-directory candidate
3273
+ dropped as an authored duplicate, with `deduped=<n>` on the `op=list` line and
3274
+ in the response. `shipped + shippedSkipped + deduped` equals the number of
3275
+ distinct `.md` names across the three source directories, so a listing that
3276
+ does not add up is visible without reproducing anything. The over-exclusion to
3277
+ watch for is a `deduped` line naming a file that is also a bundled or premium
3278
+ agent: the authored file then hides a genuinely shipped one.
3234
3279
  - `op=classify account=<id8> agent=<name> origin=<shipped|public|specialist>
3235
3280
  risk=<green|amber|red> riskiestTool=<tool> tools=<n> unresolved=<comma-list|none>
3236
3281
  disabled=<bool>` — one line per classified agent per listing, so it fires on
@@ -4795,6 +4840,10 @@ npx -y @rubytech/create-realagent-code # realagent brand
4795
4840
 
4796
4841
  **LaunchAgent.** The installer registers `SiteDesk` as a launchd LaunchAgent at `~/Library/LaunchAgents/com.rubytech.<brand-hostname>.plist` — for example `com.rubytech.maxy-code.plist`. Survives logout/login and reboot via `KeepAlive=true` and `RunAtLoad=true`. Two brands on the same Mac get two distinct plists (brand-hostname-keyed), so install order is independent. Use `launchctl print gui/$UID/com.rubytech.<brand-hostname>` for service state. The `[create-maxy] launchd-plist=<path> loaded=true` line in the install log confirms `launchctl bootstrap` accepted the plist; `loaded=false exit=<n>` is the failure signal (run `plutil -lint <path>` to diagnose).
4797
4842
 
4843
+ **Restart waits for the old instance.** This covers the session-manager, heartbeat, logs-rotate and dedicated-Neo4j agents — not the main service above, which bootstraps directly. A re-install boots the agent out first, but `launchctl bootout` returns as soon as launchd accepts the request: the job's process can keep running for seconds afterwards (a dedicated Neo4j's JVM took 14.2 s to finish its store flush), and `launchctl bootstrap` returns `exit 5: Input/output error` for that whole window. The installer therefore polls `launchctl print gui/$UID/<label>` every 500 ms until the label leaves the domain, up to 60 s total across all attempts, before bootstrapping; if it never leaves, the installer bootstraps anyway and the exit code decides. The lifecycle is one log line per step, keyed `agent=<label>`: `op=bootout`, `op=wait-poll present=<bool> probeRc=<n|null>`, `op=gone goneMs=<n> polls=<n>`, `op=bootstrap rc=<n> waitedMs=<n>`, `op=result ok=<bool> totalWaitedMs=<n> probeFailures=<n>`. `op=gone goneMs=<n>` is how long the previous instance actually took to exit; `op=wait-timeout` means it never left within 60 s; `probeFailures=<n>` counts probes that could not run at all, which makes the wait blind.
4844
+
4845
+ Note `launchctl print`'s exit code reports domain membership, not liveness — a job that loads and exits immediately still returns 0. Detecting a dead-on-arrival agent needs a different primitive and is not attempted; for the main service and the session manager the existing `/healthz` probe is what catches it.
4846
+
4798
4847
  **Cloudflare on darwin.** The installer brew-installs the `cloudflared` binary so it is on PATH, but does not invoke `cloudflared service install` or `cloudflared tunnel route dns` — public reach is opt-in. After install, the operator runs `cloudflared tunnel login` (browser-driven) followed by the existing tunnel-setup flow if they want a public address. Grep `[create-maxy] darwin-cloudflare-skip=true` in the install log to confirm the installer took the documented skip path.
4799
4848
 
4800
4849
  **Uninstall.** `npx -y @rubytech/create-maxy-code uninstall` (or the realagent equivalent) bootsout the LaunchAgent, removes the plist, and deletes `~/.<brand>/`. Homebrew-installed dependencies (curl, git, unzip, jq, poppler, ffmpeg, node@22, neo4j, cloudflared) remain — remove them with `brew uninstall` if you want a clean slate.
@@ -9,6 +9,12 @@ Invoked by the admin agent directly.
9
9
 
10
10
  This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
11
11
 
12
+ ## 2026-07-27 (0.1.516)
13
+
14
+ - Meeting invitations attached to an email are no longer invisible: a calendar part now counts as an attachment on every read path.
15
+ - The Agents page no longer lists a specialist you created twice, once as your own and again as shipped.
16
+ - Outlook errors now say what actually happened. A refusal on an item that was deleted reads as deletion rather than a missing permission, and a mailbox being moved gets its own message instead of a bare server error.
17
+
12
18
  ## 2026-07-27 (0.1.515)
13
19
 
14
20
  - Disabling an agent now takes effect straight away: it is dropped from the routing table, reported as disabled rather than missing, and the agents file is reconciled on both disable and enable.