@huynle/hub 0.8.7 → 0.9.1

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 (213) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +75 -16
  3. package/package.json +5 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/wrapper.ts +2 -2
  6. package/packages/cli/dist/hub-cli.js +1413 -523
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/bootstrap/PHASE3-CONTRACT.md +112 -0
  9. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +164 -21
  10. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +24 -7
  11. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +116 -9
  12. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +8 -2
  13. package/packages/cli/src/bootstrap/contracts/actions.ts +8 -6
  14. package/packages/cli/src/bootstrap/contracts/discovery.ts +4 -3
  15. package/packages/cli/src/bootstrap/contracts/exec.ts +4 -2
  16. package/packages/cli/src/bootstrap/contracts/paths.ts +2 -1
  17. package/packages/cli/src/bootstrap/contracts/platform.ts +5 -5
  18. package/packages/cli/src/bootstrap/discovery/discover.ts +7 -2
  19. package/packages/cli/src/bootstrap/discovery/host.ts +2 -0
  20. package/packages/cli/src/bootstrap/preview/plan.ts +5 -1
  21. package/packages/cli/src/bootstrap/profiles/desired.ts +16 -1
  22. package/packages/cli/src/bootstrap/profiles/history.ts +2 -1
  23. package/packages/cli/src/bootstrap/profiles/upgrade.ts +2 -2
  24. package/packages/cli/src/bootstrap/release/assets.ts +4 -1
  25. package/packages/cli/src/cli.ts +23 -10
  26. package/packages/cli/src/http.ts +1 -0
  27. package/packages/cli/src/launches.ts +5 -1
  28. package/packages/cli/src/mcp.ts +28 -8
  29. package/packages/cli/src/messaging.ts +14 -0
  30. package/packages/cli/src/node.ts +94 -12
  31. package/packages/cli/src/setup/commands.ts +27 -7
  32. package/packages/cli/src/setup/runtime.ts +31 -4
  33. package/packages/cli/src/team-intake-mcp.ts +26 -2
  34. package/packages/codex/package.json +1 -1
  35. package/packages/codex/src/wrapper.ts +2 -2
  36. package/packages/node/package.json +1 -1
  37. package/packages/node/src/config.ts +49 -17
  38. package/packages/node/src/git.ts +16 -2
  39. package/packages/node/src/harnesses.ts +54 -9
  40. package/packages/node/src/host.ts +61 -15
  41. package/packages/node/src/opencode-service.ts +111 -0
  42. package/packages/node/src/opencode-warm.ts +11 -5
  43. package/packages/node/src/run.ts +50 -5
  44. package/packages/preset/package.json +1 -1
  45. package/packages/protocol/package.json +1 -1
  46. package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
  47. package/packages/protocol/src/artifacts.ts +43 -0
  48. package/packages/protocol/src/credentials.ts +38 -0
  49. package/packages/protocol/src/index.ts +59 -6
  50. package/packages/protocol/src/launch.ts +46 -6
  51. package/packages/protocol/src/markdown.ts +753 -0
  52. package/packages/protocol/src/media-allowlist.ts +240 -0
  53. package/packages/protocol/src/media.ts +120 -0
  54. package/packages/protocol/src/preview-bundle.ts +221 -0
  55. package/packages/protocol/src/voice-recording.ts +8 -0
  56. package/packages/runtime-openshell/package.json +1 -1
  57. package/packages/runtime-sdk/package.json +1 -1
  58. package/packages/sdk/package.json +1 -1
  59. package/packages/sdk/src/client.ts +17 -3
  60. package/packages/sdk/src/errors.ts +1 -0
  61. package/packages/sdk/src/team-intake.ts +8 -0
  62. package/packages/sdk/src/types.ts +16 -2
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/src/central/agent/delivery.ts +35 -6
  65. package/src/central/agent/errors.ts +2 -0
  66. package/src/central/agent/events.ts +11 -2
  67. package/src/central/agent/launch-engine.ts +65 -4
  68. package/src/central/agent/launch-routes.ts +8 -0
  69. package/src/central/agent/launch-spec.ts +22 -3
  70. package/src/central/agent/launch-store.ts +2 -2
  71. package/src/central/agent/message-routes.ts +49 -11
  72. package/src/central/agent/messages.ts +17 -4
  73. package/src/central/agent/not-after.ts +28 -0
  74. package/src/central/agent/placement.ts +36 -4
  75. package/src/central/agent/routes.ts +3 -1
  76. package/src/central/agent/webhook-routes.ts +21 -9
  77. package/src/central/agent/webhooks.ts +27 -9
  78. package/src/central/artifacts/host.ts +3 -1
  79. package/src/central/artifacts/maintenance.ts +2 -1
  80. package/src/central/artifacts/preview-bundle.ts +81 -0
  81. package/src/central/artifacts/previews.ts +94 -0
  82. package/src/central/artifacts/routes.ts +12 -1
  83. package/src/central/artifacts/store.ts +198 -8
  84. package/src/central/config.ts +34 -0
  85. package/src/central/fetch-budget.ts +59 -0
  86. package/src/central/fetcher.ts +372 -0
  87. package/src/central/http.ts +277 -22
  88. package/src/central/install/CONTRACT.md +4 -4
  89. package/src/central/install/install.ps1 +37 -5
  90. package/src/central/install/render.ts +13 -8
  91. package/src/central/media-remote.ts +248 -0
  92. package/src/central/media-timeline.ts +317 -0
  93. package/src/central/media-unfurl.ts +119 -0
  94. package/src/central/profiles/content.ts +3 -35
  95. package/src/central/recording/routes.ts +2 -2
  96. package/src/central/recording/schema.ts +3 -1
  97. package/src/central/registry.ts +449 -21
  98. package/src/central/spoke-ws.ts +17 -3
  99. package/src/central/store.ts +21 -0
  100. package/src/central/teams/intake-routes.ts +2 -1
  101. package/src/central/teams/intake.ts +200 -15
  102. package/src/central/unfurl-parse.ts +313 -0
  103. package/src/index.ts +68 -8
  104. package/src/speakable.ts +33 -1
  105. package/src/spoke/conversation.ts +7 -4
  106. package/src/spoke/deliver.ts +19 -0
  107. package/src/spoke/media-bytes.ts +171 -0
  108. package/src/spoke/media-policy.ts +394 -0
  109. package/src/spoke/media.ts +189 -0
  110. package/src/spoke/own-server.ts +30 -0
  111. package/src/spoke/preview-bundle.ts +538 -0
  112. package/src/spoke/spoke.ts +387 -17
  113. package/src/spoke/timeline.ts +59 -5
  114. package/src/spoke/voice-front.ts +335 -0
  115. package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
  116. package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
  117. package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
  118. package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
  119. package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
  120. package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
  121. package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
  122. package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
  123. package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
  124. package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
  125. package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
  126. package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
  127. package/web/dist/assets/ArtifactPage-BKISN-Qj.js +1 -0
  128. package/web/dist/assets/ArtifactPane-CKOiAUNH.js +1 -0
  129. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +6 -0
  130. package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
  131. package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
  132. package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
  133. package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
  134. package/web/dist/assets/Diagram-azw2B_QH.js +1 -0
  135. package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
  136. package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
  137. package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
  138. package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
  139. package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
  140. package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
  141. package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
  142. package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
  143. package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
  144. package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
  145. package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
  146. package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
  147. package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
  148. package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
  149. package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
  150. package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
  151. package/web/dist/assets/Library-CcjZXsfF.js +2 -0
  152. package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
  153. package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
  154. package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
  155. package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
  156. package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
  157. package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
  158. package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
  159. package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
  160. package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
  161. package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
  162. package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
  163. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +1 -0
  164. package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
  165. package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
  166. package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
  167. package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
  168. package/web/dist/assets/TeamsPage-CDyjrK-I.js +1 -0
  169. package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
  170. package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
  171. package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
  172. package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
  173. package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
  174. package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
  175. package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
  176. package/web/dist/assets/Watchtower-DYwoUWME.js +2 -0
  177. package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
  178. package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
  179. package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
  180. package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
  181. package/web/dist/assets/index-C3ci5UtY.css +1 -0
  182. package/web/dist/assets/index-LNMZCqgE.js +36 -0
  183. package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
  184. package/web/dist/assets/live-ycZ_aQZO.js +5 -0
  185. package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
  186. package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
  187. package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
  188. package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-BIwbgRoX.js} +1 -1
  189. package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-C1a7eYGQ.js} +1 -1
  190. package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-DHhPy61Q.js} +1 -1
  191. package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
  192. package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
  193. package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
  194. package/web/dist/assets/source-BWlvaODa.js +1 -0
  195. package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
  196. package/web/dist/assets/ui-CFB4wQfF.js +3 -0
  197. package/web/dist/index.html +8 -6
  198. package/web/src/voice/turn/LICENSE +28 -0
  199. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
  200. package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
  201. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
  202. package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
  203. package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
  204. package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
  205. package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
  206. package/web/dist/assets/hub-CrbItiwP.js +0 -13
  207. package/web/dist/assets/index-82Ce0IAf.css +0 -1
  208. package/web/dist/assets/index-D9SP3_0z.js +0 -22
  209. package/web/dist/assets/live-BNJgy0vg.js +0 -5
  210. package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
  211. package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
  212. package/web/dist/assets/source-Wbq35qGp.js +0 -1
  213. package/web/dist/assets/ui-j5FFk90B.js +0 -3
@@ -14,7 +14,7 @@ import { readSpokeCredentials } from "../../node/src/config.ts"
14
14
  import { hostname } from "node:os"
15
15
  import { serveMcp } from "./mcp"
16
16
  import { intentLine, writeIntent } from "./intents"
17
- import { ASK_EXPIRES_DEFAULT_S, askOutcome, declinedText, durationArg, inboxText, mutedText, repliedText, sentText, textArg } from "./messaging"
17
+ import { ASK_EXPIRES_DEFAULT_S, askOutcome, declinedText, durationArg, inboxText, mutedText, notAfterArg, repliedText, sentText, textArg } from "./messaging"
18
18
  import { watch } from "./watch"
19
19
  import { DECISION_FLAGS, decisionOptions, decisionsText, openedText, stateArg } from "./decisions"
20
20
  import { GRANT_FLAGS, GRANT_REQUEST_FLAGS, grantError, grantRequestArg, grantSpecArg, grantsText, grantText, requestedText, userOnly } from "./grants"
@@ -81,10 +81,13 @@ Identity
81
81
  hub role release <role[.project]> [--session <addr>]
82
82
 
83
83
  Messaging (text "-" reads it from stdin)
84
- hub msg <addr> "text" [--all] [--no-wake] [--urgent] tell: fire and forget
85
- hub ask <addr> "text" [--wait [secs]] [--expires 2h] [--all] [--no-wake] [--urgent]
84
+ hub msg <addr> "text" [--all] [--no-wake] [--urgent] [--not-after <ms|ISO time>] [--key K]
85
+ tell: fire and forget; --not-after: never delivered after that time
86
+ hub ask <addr> "text" [--wait [secs]] [--expires 2h] [--not-after <ms|ISO time>] [--all] [--no-wake] [--urgent] [--key K]
86
87
  tracked until answered, declined or expired; --wait alone waits until
87
- it expires; exit 0 answered, 1 declined or no answer yet, 8 expired
88
+ it expires; exit 0 answered, 1 declined or no answer yet, 8 expired;
89
+ --key (Idempotency-Key): a retry with the same key and text answers
90
+ the first send again and sends nothing (24 h, per credential)
88
91
  hub reply <msgID> "text" [--no-wake] goes to the real sender
89
92
  hub decline <msgID> --why "reason"
90
93
  hub ask @me "question" [--option id=label[:safe]]... [--recommend id [--why "…"]] [--urgency low|normal|high]
@@ -119,9 +122,10 @@ Launching (users; agents and app tokens with sessions:launch under a grant with
119
122
  [--name <@name>] [--role R]... [--harness H] [--agent A] [--model provider/model]
120
123
  [--runtime host|openshell [--profile P]] [--machine M] [--node-label k=v]... [--prefer]
121
124
  [--label k=v]... [--prompt "text" | --prompt -] [--end idle|asked|never] [--timeout 4h]
122
- [--cleanup keep|remove] [--key K]
125
+ [--cleanup keep|remove] [--key K] [--not-after <ms|ISO time>]
123
126
  start a session; the same --key again answers the same launch;
124
- --label tags it for apps, --node-label picks nodes (--prefer: not required)
127
+ --label tags it for apps, --node-label picks nodes (--prefer: not required);
128
+ --not-after: still waiting for a machine then, it never starts (expired)
125
129
  hub launches list [--state S[,S]] [--label k=v]... your launches (agents: the ones they started)
126
130
  hub launches show <launchID> · hub launches stop <launchID> (stop: exit 8 when it already ended)
127
131
 
@@ -278,8 +282,17 @@ const whyOf = (flags: Parsed["flags"]) => {
278
282
  return why !== undefined ? { why } : {}
279
283
  }
280
284
 
281
- /** --no-wake and --urgent as SDK send options. */
282
- const sendFlags = (flags: Parsed["flags"]) => ({ ...(flags["no-wake"] === true ? { wake: false } : {}), ...(flags.urgent === true ? { urgent: true } : {}) })
285
+ /** --no-wake, --urgent, --not-after and --key as SDK send options. */
286
+ const sendFlags = (flags: Parsed["flags"]) => {
287
+ const notAfter = str(flags, "not-after")
288
+ const key = str(flags, "key")
289
+ return {
290
+ ...(flags["no-wake"] === true ? { wake: false } : {}),
291
+ ...(flags.urgent === true ? { urgent: true } : {}),
292
+ ...(notAfter === undefined ? {} : { notAfter: notAfterArg(notAfter, "--not-after") }),
293
+ ...(key !== undefined ? { idempotencyKey: key } : {}),
294
+ }
295
+ }
283
296
 
284
297
  const nodeIo = (io: Io): NodeIo => ({
285
298
  env: io.env,
@@ -399,7 +412,7 @@ const COMMANDS: Record<string, Command> = {
399
412
  },
400
413
  msg: {
401
414
  args: ["<addr>", "<text>"],
402
- flags: { all: "bool", "no-wake": "bool", urgent: "bool" },
415
+ flags: { all: "bool", "no-wake": "bool", urgent: "bool", "not-after": "string", key: "string" },
403
416
  async handle(context) {
404
417
  const { flags, args } = context.parsed
405
418
  const text = await textArg(args[1]!, context.io.stdin)
@@ -410,7 +423,7 @@ const COMMANDS: Record<string, Command> = {
410
423
  },
411
424
  ask: {
412
425
  args: ["<addr>", "<text>"],
413
- flags: { all: "bool", "no-wake": "bool", urgent: "bool", expires: "string", wait: "optional", ...DECISION_FLAGS },
426
+ flags: { all: "bool", "no-wake": "bool", urgent: "bool", "not-after": "string", key: "string", expires: "string", wait: "optional", ...DECISION_FLAGS },
414
427
  async handle(context) {
415
428
  const { flags, args } = context.parsed
416
429
  const decision = decisionOptions(flags, args[0]!)
@@ -33,6 +33,7 @@ const EXIT_OF: Record<string, number> = {
33
33
  offline: EXIT.unavailable,
34
34
  busy: EXIT.unavailable,
35
35
  gone: EXIT.gone,
36
+ expired: EXIT.gone,
36
37
  unauthorized: EXIT.unauthorized,
37
38
  token_expired: EXIT.unauthorized,
38
39
  }
@@ -5,6 +5,7 @@ import path from "node:path"
5
5
  import type { LaunchSpec, LaunchStep, LaunchView } from "../../sdk/src/index.ts"
6
6
  import { CLEANUP_POLICIES, LAUNCH_END_WHEN, RUNTIME_KINDS, WORKSPACE_MODES } from "../../protocol/src/launch.ts"
7
7
  import { str, UsageError, type FlagKind, type Parsed } from "./args"
8
+ import { notAfterArg } from "./messaging"
8
9
 
9
10
  export const LAUNCH_FLAGS: Readonly<Record<string, FlagKind>> = {
10
11
  key: "string",
@@ -28,6 +29,7 @@ export const LAUNCH_FLAGS: Readonly<Record<string, FlagKind>> = {
28
29
  end: "string",
29
30
  timeout: "string",
30
31
  cleanup: "string",
32
+ "not-after": "string",
31
33
  }
32
34
 
33
35
  /** `a, b or c` */
@@ -93,10 +95,12 @@ export async function launchSpecArg(flags: Parsed["flags"], context: LaunchArgCo
93
95
  const runtime = kind === undefined ? undefined : { kind, ...(profile !== undefined ? { profile } : {}) }
94
96
  const placement = defined({ labels: nodeLabels, machine, mode: flags.prefer === true ? ("prefer" as const) : undefined })
95
97
  const end = defined({ when, timeout: str(flags, "timeout"), cleanup })
98
+ const notAfterText = str(flags, "not-after")
99
+ const notAfter = notAfterText === undefined ? undefined : notAfterArg(notAfterText, "--not-after")
96
100
  return {
97
101
  ...defined({ key: str(flags, "key"), name, roles, harness: str(flags, "harness"), agent: str(flags, "agent"), model: str(flags, "model") }),
98
102
  workspace,
99
- ...defined({ runtime, placement, prompt, labels, end }),
103
+ ...defined({ runtime, placement, prompt, labels, end, notAfter }),
100
104
  }
101
105
  }
102
106
 
@@ -18,7 +18,7 @@ import { intentLine, writeIntent } from "./intents"
18
18
  import type { Credentials } from "./credentials"
19
19
  import { logText, oneLine, sessionsText, showText, statusText, whoamiText, whoText } from "./format"
20
20
  import { CliError, withHub } from "./http"
21
- import { askOutcome, declinedText, deliveryText, durationArg, mutedText, repliedText, sentText } from "./messaging"
21
+ import { askOutcome, declinedText, deliveryText, durationArg, mutedText, notAfterArg, repliedText, sentText } from "./messaging"
22
22
  import { DECISION_STATES, decisionsText, decisionText, openedText } from "./decisions"
23
23
  import { CLEANUP_POLICIES, LAUNCH_END_WHEN, RUNTIME_KINDS, WORKSPACE_MODES } from "../../protocol/src/launch.ts"
24
24
  import { launchedText, launchesText, launchText, stoppedLaunchText } from "./launches"
@@ -103,11 +103,27 @@ const optionalExpires = (args: Args): number | undefined => {
103
103
  }
104
104
  throw new ArgError(`"expires" must be seconds or a duration like 30s, 5m, 2h, 1d`)
105
105
  }
106
- /** The wake and urgent arguments as SDK send options. */
106
+ /** notAfter (H2): epoch milliseconds, or an ISO 8601 date-time converted to them. */
107
+ const optionalNotAfter = (args: Args): number | undefined => {
108
+ const value = args.notAfter
109
+ if (value === undefined || value === null) return undefined
110
+ if (typeof value === "number" && Number.isSafeInteger(value)) return value
111
+ if (typeof value === "string") {
112
+ try {
113
+ return notAfterArg(value, "\"notAfter\"")
114
+ } catch (cause) {
115
+ if (cause instanceof UsageError) throw new ArgError(cause.message)
116
+ throw cause
117
+ }
118
+ }
119
+ throw new ArgError(`"notAfter" must be epoch milliseconds or an ISO 8601 date-time like 2027-01-15T12:00:00Z`)
120
+ }
121
+ /** The wake, urgent and notAfter arguments as SDK send options. */
107
122
  const sendOptions = (args: Args) => {
108
123
  const wake = optionalBool(args, "wake")
109
124
  const urgent = optionalBool(args, "urgent")
110
- return { ...(wake !== undefined ? { wake } : {}), ...(urgent !== undefined ? { urgent } : {}) }
125
+ const notAfter = optionalNotAfter(args)
126
+ return { ...(wake !== undefined ? { wake } : {}), ...(urgent !== undefined ? { urgent } : {}), ...(notAfter !== undefined ? { notAfter } : {}) }
111
127
  }
112
128
 
113
129
  /** The why argument as SDK act options. */
@@ -178,6 +194,7 @@ function launchSpec(args: Args): LaunchSpec {
178
194
  prompt: optional(args, "prompt"),
179
195
  labels: optionalLabels(args, "labels"),
180
196
  end: definedFields({ when: optionalEnum(args, "end", LAUNCH_END_WHEN), timeout: optional(args, "timeout"), cleanup: optionalEnum(args, "cleanup", CLEANUP_POLICIES) }),
197
+ notAfter: optionalNotAfter(args),
181
198
  }),
182
199
  }
183
200
  }
@@ -220,6 +237,7 @@ const STRING = { type: "string" } as const
220
237
  const SEND = {
221
238
  wake: { type: "boolean", description: "false: admit the message without starting a turn." },
222
239
  urgent: { type: "boolean", description: "Steer into the recipient's running turn." },
240
+ notAfter: { type: ["number", "string"], description: "Never deliver after this time: epoch milliseconds or an ISO 8601 date-time (at most 30 days ahead). Already passed: nothing is sent (expired)." },
223
241
  all: { type: "boolean", description: "Send to every session the address names (each holder of a role)." },
224
242
  } as const
225
243
  const TO = { type: "string", description: "The recipient: @name, @@role, @@role#team (a role of one of your teams), @name.project, ses_…, or @me (the user)." } as const
@@ -388,7 +406,7 @@ const webhookText = (w: { id: string; url: string; events: readonly string[]; la
388
406
  const webhookTools: readonly Tool[] = [
389
407
  {
390
408
  name: "webhook_create",
391
- description: "Create a durable signed webhook for canonical compact events. The signing secret is returned only here. Needs events:subscribe plus every selected family's scope.",
409
+ description: "Create a durable signed webhook for canonical compact events (e.g. launch.*, session.*, message.changed, team.intake). The signing secret is returned only here. Needs events:subscribe plus every selected family's scope; events [\"team.intake\"] alone needs only teams:submit (an app gets only its own submissions).",
392
410
  properties: { url: STRING, events: { type: "array", items: STRING }, label: STRING }, required: ["url"], readOnly: false,
393
411
  run: async (hub, args, signal) => {
394
412
  const events = optionalStrings(args, "events")
@@ -809,13 +827,13 @@ const TOOLS: readonly Tool[] = [
809
827
  {
810
828
  name: "msg",
811
829
  description: "Tell a session something (fire and forget). Sent as this server's credential, not as an OpenCode session.",
812
- properties: { to: TO, text: { ...STRING, description: "The message." }, ...SEND },
830
+ properties: { to: TO, text: { ...STRING, description: "The message." }, ...SEND, idempotencyKey: IDEMPOTENCY },
813
831
  required: ["to", "text"],
814
832
  readOnly: false,
815
833
  run: async (hub, args, signal) => {
816
834
  const to = need(args, "to")
817
835
  const text = need(args, "text")
818
- const options = { ...sendOptions(args), signal }
836
+ const options = { ...sendOptions(args), ...idemOption(args), signal }
819
837
  if (optionalBool(args, "all")) return sentAllText(await hub.msg(to, text, { ...options, all: true }))
820
838
  return sentText(await hub.msg(to, text, options))
821
839
  },
@@ -829,6 +847,7 @@ const TOOLS: readonly Tool[] = [
829
847
  wait: { type: "number", minimum: 0, maximum: WAIT_POLL_MAX_S, description: `Seconds to wait for the answer (0–${WAIT_POLL_MAX_S}).` },
830
848
  expires: { type: ["number", "string"], description: "When the ask expires: seconds, or 30s, 5m, 2h, 1d (1 minute to 7 days; default 24 h)." },
831
849
  ...SEND,
850
+ idempotencyKey: IDEMPOTENCY,
832
851
  },
833
852
  required: ["to", "text"],
834
853
  readOnly: false,
@@ -837,7 +856,7 @@ const TOOLS: readonly Tool[] = [
837
856
  const text = need(args, "text")
838
857
  const wait = optionalWait(args)
839
858
  const expires = optionalExpires(args)
840
- const options = { ...sendOptions(args), ...(expires !== undefined ? { expires } : {}), signal }
859
+ const options = { ...sendOptions(args), ...(expires !== undefined ? { expires } : {}), ...idemOption(args), signal }
841
860
  if (optionalBool(args, "all")) {
842
861
  if (wait !== undefined) throw new ArgError(`"wait" cannot be combined with "all"`)
843
862
  return sentAllText(await hub.ask(to, text, { ...options, all: true }))
@@ -999,7 +1018,7 @@ const TOOLS: readonly Tool[] = [
999
1018
  {
1000
1019
  name: "launch",
1001
1020
  description:
1002
- "Start a new session on a node (a hub node with the repo under its workspace roots). The same key again answers the same launch. Needs an app token with scope sessions:launch and a grant with verb launch covering the repo.",
1021
+ "Start a new session on a node (a hub node with the repo under its workspace roots). The same key again answers the same launch. With notAfter, a launch still waiting for a machine at that time is never started (it ends expired). Needs an app token with scope sessions:launch and a grant with verb launch covering the repo.",
1003
1022
  properties: {
1004
1023
  repo: { ...STRING, description: "Absolute path of the repository on the node (or where a clone goes)." },
1005
1024
  mode: { type: "string", enum: [...WORKSPACE_MODES], description: "worktree (default: <repo>/.worktrees/<branch>, needs branch), checkout, or clone (needs remote)." },
@@ -1022,6 +1041,7 @@ const TOOLS: readonly Tool[] = [
1022
1041
  end: { type: "string", enum: [...LAUNCH_END_WHEN], description: "When it ends: idle (default; idle with nothing open for 30 s), asked (with a question open: needs_user), never." },
1023
1042
  timeout: { ...STRING, description: "Longest run, e.g. 4h or 90m." },
1024
1043
  cleanup: { type: "string", enum: [...CLEANUP_POLICIES], description: "keep (default) or remove the workspace when it ends." },
1044
+ notAfter: { type: ["number", "string"], description: "Do not start after this time: epoch milliseconds or an ISO 8601 date-time (at most 30 days ahead). Still waiting for a machine then, the launch ends expired; once placed it runs (timeout bounds it). Already passed: nothing is launched (expired)." },
1025
1045
  },
1026
1046
  required: ["repo"],
1027
1047
  readOnly: false,
@@ -57,6 +57,20 @@ export function durationArg(value: string, flag: string): number {
57
57
  return Number(match[1]) * UNIT_S[match[2]!]!
58
58
  }
59
59
 
60
+ /** An ISO 8601 date-time with at least hours and minutes; no zone means local time. */
61
+ const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})?$/i
62
+
63
+ /**
64
+ * A notAfter (H2) as epoch milliseconds, from epoch milliseconds (digits) or an ISO 8601 date-time like
65
+ * 2027-01-15T12:00:00Z; the hub checks the range (future, at most 30 days ahead). Throws UsageError(`what` …).
66
+ */
67
+ export function notAfterArg(value: string, what: string): number {
68
+ const text = value.trim()
69
+ const ms = /^\d+$/.test(text) ? Number(text) : ISO_DATE_TIME.test(text) ? Date.parse(text) : Number.NaN
70
+ if (!Number.isSafeInteger(ms)) throw new UsageError(`${what} takes epoch milliseconds or an ISO 8601 date-time like 2027-01-15T12:00:00Z (got "${value}")`)
71
+ return ms
72
+ }
73
+
60
74
  const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z")
61
75
 
62
76
  /** What an ask came to, its text, and the exit code: answered 0, declined 1, open after a wait 1, expired 8. */
@@ -5,12 +5,17 @@
5
5
  // entry), writes the file, and prints the launchctl / systemctl commands that load it. Nothing here starts a process:
6
6
  // loading, unloading and inspecting the service are always left to the user (printed, never run). --dry-run
7
7
  // prints the file instead of writing it. The same machinery (ServiceSpec) installs the harness spokes' services
8
- // (spoke-services.ts: `hub claude-code install`, `hub codex-spoke install`).
9
- import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"
8
+ // (spoke-services.ts: `hub claude-code install`, `hub codex-spoke install`). On Windows (PHASE3-CONTRACT.md) the node
9
+ // is a per-user logon task: install writes its Task XML (UTF-16LE) under the hub state dir's tasks/ and prints the
10
+ // schtasks commands that register and start it; the spokes have no task yet.
11
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
10
12
  import path from "node:path"
11
13
  import { nodeConfigPath, readNodeConfig, readSpokeCredentials, runNode } from "../../node/src/index.ts"
12
- import { renderServiceUnit } from "./bootstrap/adapters/node-services/render.ts"
13
- import { isAbsolutePath, programLayout, type ServiceID, type ServiceManagerID } from "./bootstrap/contracts/index.ts"
14
+ import { renderServiceUnit, taskNameFor } from "./bootstrap/adapters/node-services/render.ts"
15
+ import { isAbsolutePath, programLayout, toNativePath, type ProgramLayout, type ServiceID, type ServiceManagerID } from "./bootstrap/contracts/index.ts"
16
+ import { normalizeAbs } from "./bootstrap/discovery/util.ts"
17
+ import { resolveHubStateDir } from "../../../src/migrate.ts"
18
+ import { defaultWinAcl } from "../../../src/win-acl.ts"
14
19
  import type { Env } from "./credentials"
15
20
  import { CliError, EXIT } from "./http"
16
21
 
@@ -57,16 +62,20 @@ export interface NodeIo {
57
62
  readonly platform: NodeJS.Platform
58
63
  readonly uid: number
59
64
  readonly program: Program
65
+ /** Windows: the current user's SID (the logon task's principal); default `whoami /user` (win-acl.ts). */
66
+ readonly userSid?: () => string
60
67
  }
61
68
 
62
- export type ServiceKind = "launchd" | "systemd"
69
+ export type ServiceKind = "launchd" | "systemd" | "task-scheduler"
63
70
 
64
71
  export interface NodeService {
65
72
  readonly kind: ServiceKind
66
73
  /** Where the unit file goes. */
67
74
  readonly file: string
68
- /** Its content, as install writes it. */
75
+ /** Its content, as install writes it (as text). */
69
76
  readonly content: string
77
+ /** Its bytes when they are not the UTF-8 of `content` (a Task XML is UTF-16LE). */
78
+ readonly bytes?: Uint8Array
70
79
  /** Directories the service needs (its log). */
71
80
  readonly dirs: readonly string[]
72
81
  /** Commands that load (or reload) it, for the user to run. */
@@ -150,16 +159,61 @@ function systemd(spec: ServiceSpec, io: NodeIo, extra?: ExtraEnvironment): NodeS
150
159
  }
151
160
  }
152
161
 
162
+ /** The node as a Windows logon task: the Task XML in <hub state>/tasks and the schtasks commands (printed, not run). */
163
+ function taskScheduler(spec: ServiceSpec, io: NodeIo, extra?: ExtraEnvironment): NodeService {
164
+ if (spec.service !== "node")
165
+ throw new CliError(EXIT.unavailable, `${spec.command} install does not support Windows yet: hubctl installs no user service for it there`, spec.elsewhere)
166
+ const home = normalizeAbs(io.home)
167
+ if (!home) throw new CliError(EXIT.usage, `cannot write the ${spec.command} task: the home directory ${io.home} is not a drive path`)
168
+ const state = normalizeAbs(resolveHubStateDir(io.env, io.home).dir)
169
+ if (!state) throw new CliError(EXIT.usage, `cannot write the ${spec.command} task: the hub state directory is not a drive path`)
170
+ const executable = normalizeAbs(io.program.execPath)
171
+ const found = programLayout(io.program)
172
+ const layout: ProgramLayout = found.kind === "standalone" && executable ? { kind: "standalone", executable } : found
173
+ const { environment, skipped } = carried(spec, io, extra)
174
+ let name: string
175
+ try {
176
+ name = taskNameFor(spec.service, io.env.USERNAME ?? "", io.env)
177
+ } catch (error) {
178
+ throw new CliError(EXIT.usage, `cannot write the ${spec.command} task: ${(error as Error).message}`)
179
+ }
180
+ const r = renderServiceUnit({ service: spec.service, manager: "task-scheduler", layout, home, writtenBy: spec.command, task: { name, userSid: (io.userSid ?? (() => defaultWinAcl().sid()))() } })
181
+ if (!r.ok) throw new CliError(EXIT.usage, `cannot write the ${spec.command} task: ${r.issues.map((i) => `${i.path ? `${i.path}: ` : ""}${i.message}`).join("; ")}`)
182
+ const file = toNativePath(`${state}/tasks/${r.value.path.rel}`, "win32")
183
+ const stopNode = `Get-CimInstance Win32_Process -Filter "Name='hubctl.exe'" | Where-Object CommandLine -like '* node run' | Invoke-CimMethod -MethodName Terminate # PowerShell: /End stops only the hidden console host`
184
+ return {
185
+ kind: "task-scheduler",
186
+ file,
187
+ content: r.value.text,
188
+ bytes: r.value.bytes,
189
+ // The task carries no environment: these settings of this shell are not seen by it.
190
+ skipped: [...Object.keys(environment), ...skipped].sort(),
191
+ dirs: [],
192
+ load: [`schtasks /Create /TN ${name} /XML "${file}" /F`, `schtasks /Run /TN ${name}`],
193
+ unload: [`schtasks /End /TN ${name}`, stopNode, `schtasks /Delete /TN ${name} /F`],
194
+ check: [`schtasks /Query /TN ${name} /V /FO LIST`],
195
+ }
196
+ }
197
+
153
198
  /** `spec`'s service for this platform; unsupported (exit 7) elsewhere. */
154
199
  export function serviceFor(spec: ServiceSpec, io: NodeIo, extra?: ExtraEnvironment): NodeService {
155
200
  if (io.platform === "darwin") return launchd(spec, io, extra)
156
201
  if (io.platform === "linux") return systemd(spec, io, extra)
202
+ if (io.platform === "win32") return taskScheduler(spec, io, extra)
157
203
  throw new CliError(EXIT.unavailable, `${spec.command} install supports macOS (launchd) and Linux (systemd), not ${io.platform}`, spec.elsewhere)
158
204
  }
159
205
 
160
206
  /** The node's service for this platform; unsupported (exit 7) elsewhere. */
161
207
  export const nodeService = (io: NodeIo): NodeService => serviceFor(NODE_SERVICE, io)
162
208
 
209
+ const readBytes = (file: string): Buffer | undefined => {
210
+ try {
211
+ return readFileSync(file)
212
+ } catch {
213
+ return undefined
214
+ }
215
+ }
216
+
163
217
  const readText = (file: string): string | undefined => {
164
218
  try {
165
219
  return readFileSync(file, "utf8")
@@ -194,14 +248,15 @@ export function installNode(io: NodeIo, options: { readonly dryRun: boolean }):
194
248
  /** Writes `spec`'s service file (or prints it, dry run) and prints the commands that load it; `warnings` go to stderr. */
195
249
  export function installService(spec: ServiceSpec, io: NodeIo, options: { readonly dryRun: boolean }, given: readonly string[], extra?: ExtraEnvironment): NodeCommandOutput {
196
250
  const service = serviceFor(spec, io, extra)
197
- const warnings = [...given, ...service.skipped.map((key) => `${key} is not an absolute path, so the service does not carry it`)]
251
+ const why = service.kind === "task-scheduler" ? "is set in this shell, but a logon task has no environment of its own, so the node does not see it" : "is not an absolute path, so the service does not carry it"
252
+ const warnings = [...given, ...service.skipped.map((key) => `${key} ${why}`)]
198
253
  const load = commands(`Load it ${NOT_RUN}:`, service.load)
199
254
  if (options.dryRun) return { stdout: `would write ${service.file}:\n\n${service.content}\n${load}`, warnings }
200
- const current = readText(service.file)
255
+ const current = service.bytes ? readBytes(service.file) : readText(service.file)
201
256
  for (const dir of service.dirs) mkdirSync(dir, { recursive: true })
202
- if (current === service.content) return { stdout: `${service.file} is up to date\n${load}`, warnings }
257
+ if (service.bytes ? current !== undefined && Buffer.from(service.bytes).equals(current as Buffer) : current === service.content) return { stdout: `${service.file} is up to date\n${load}`, warnings }
203
258
  mkdirSync(path.dirname(service.file), { recursive: true })
204
- writeFileSync(service.file, service.content, { mode: 0o644 })
259
+ writeFileSync(service.file, service.bytes ?? service.content, { mode: 0o644 })
205
260
  return { stdout: `${current === undefined ? "wrote" : "updated"} ${service.file}\n${load}`, warnings }
206
261
  }
207
262
 
@@ -225,6 +280,10 @@ export type ServiceState = { readonly kind: ServiceKind; readonly file: string;
225
280
  export function serviceState(spec: ServiceSpec, io: NodeIo, extra?: ExtraEnvironment): ServiceState {
226
281
  try {
227
282
  const s = serviceFor(spec, io, extra)
283
+ if (s.bytes) {
284
+ const bytes = readBytes(s.file)
285
+ return { kind: s.kind, file: s.file, installed: bytes !== undefined, current: bytes !== undefined && Buffer.from(s.bytes).equals(bytes), check: s.check }
286
+ }
228
287
  const text = readText(s.file)
229
288
  return { kind: s.kind, file: s.file, installed: text !== undefined, current: text === s.content, check: s.check }
230
289
  } catch {
@@ -273,7 +332,30 @@ export function nodeStatusText(status: NodeStatus): string {
273
332
  return lines.join("\n")
274
333
  }
275
334
 
335
+ /** node.log is rotated to node.log.1 once it grows past this. */
336
+ export const NODE_LOG_MAX_BYTES = 1024 * 1024
337
+
338
+ /**
339
+ * The node's log: stderr everywhere; on win32 also <hub state dir>/node.log, because the logon task runs hubctl
340
+ * headless (conhost --headless) and its stderr goes nowhere. Writing the file never throws.
341
+ */
342
+ export function nodeLogger(o: { readonly platform: string; readonly stateDir: string; stderr(s: string): void; readonly now?: () => Date }): (line: string) => void {
343
+ const file = path.join(o.stateDir, "node.log")
344
+ return (line) => {
345
+ o.stderr(`[hub node] ${line}\n`)
346
+ if (o.platform !== "win32") return
347
+ try {
348
+ mkdirSync(o.stateDir, { recursive: true })
349
+ try {
350
+ if (statSync(file).size > NODE_LOG_MAX_BYTES) renameSync(file, `${file}.1`)
351
+ } catch {}
352
+ appendFileSync(file, `${(o.now ?? (() => new Date()))().toISOString()} ${line}\n`)
353
+ } catch {}
354
+ }
355
+ }
356
+
276
357
  /** `hub node run`: the daemon until `signal` aborts; runNode's exit code. */
277
- export function runNodeCommand(io: { readonly env: Env; readonly home: string; readonly signal?: AbortSignal; stderr(s: string): void }): Promise<number> {
278
- return runNode({ env: io.env, home: io.home, signal: io.signal ?? new AbortController().signal, log: (line) => io.stderr(`[hub node] ${line}\n`) })
358
+ export function runNodeCommand(io: { readonly env: Env; readonly home: string; readonly signal?: AbortSignal; readonly platform?: string; stderr(s: string): void }): Promise<number> {
359
+ const log = nodeLogger({ platform: io.platform ?? process.platform, stateDir: resolveHubStateDir(io.env, io.home).dir, stderr: (s) => io.stderr(s) })
360
+ return runNode({ env: io.env, home: io.home, signal: io.signal ?? new AbortController().signal, log })
279
361
  }
@@ -42,7 +42,7 @@ import { OpencodePlanError } from "../bootstrap/adapters/opencode/plan.ts"
42
42
  import { parseJsonc } from "../bootstrap/transaction/json-merge.ts"
43
43
  import { renderRoleVars } from "../bootstrap/ansible/render.ts"
44
44
  import { createNodeProbe } from "../bootstrap/discovery/node-probe.ts"
45
- import { normalizeAbs } from "../bootstrap/discovery/util.ts"
45
+ import { normalizeAbs, underDir } from "../bootstrap/discovery/util.ts"
46
46
  import { credentialStoreFor, defaultMachineName, machineEnroll, machineStatus, renderMachineResult, sharingOffWarning } from "../bootstrap/enrollment/index.ts"
47
47
  import { previewSetup, renderPreview, revalidateApply } from "../bootstrap/preview/index.ts"
48
48
  import { defaultProfile } from "../bootstrap/profiles/catalog.ts"
@@ -284,25 +284,45 @@ function discovered(io: SetupIo): DiscoverySnapshot {
284
284
  return r.value
285
285
  }
286
286
 
287
- /** A PathRef for an absolute path under a mapped root (the verification launch's workspace). */
288
- function refFor(abs: string, snapshot: DiscoverySnapshot): PathRef | undefined {
287
+ /** A PathRef for an absolute path under a mapped root (the verification launch's workspace; Windows paths in any case). */
288
+ export function refFor(abs: string, snapshot: DiscoverySnapshot): PathRef | undefined {
289
289
  const ctx = executionContextOf(snapshot)
290
290
  for (const id of ["home"] as const) {
291
291
  const root = snapshot.roots.find((r) => r.id === id)
292
292
  const base = root ? rootPathIn(root, ctx) : undefined
293
- if (base && abs.startsWith(`${base}/`)) return { root: id, rel: abs.slice(base.length + 1) }
293
+ if (base && underDir(abs, base)) return { root: id, rel: abs.slice(base.length + (base.endsWith("/") ? 0 : 1)) }
294
294
  }
295
295
  return undefined
296
296
  }
297
297
 
298
+ /**
299
+ * A --workspace-root as a canonical absolute path. POSIX: resolved against the working directory, as before. Windows
300
+ * (PHASE3-CONTRACT.md): a native ("C:\\x"), canonical ("C:/x") or lower-case drive path, "~/…" or "~\\…" under
301
+ * the home, or a path relative to the working directory; UNC and device paths are refused (undefined).
302
+ */
303
+ export function workspaceRootOf(raw: string, where: { readonly platform: string; readonly cwd: string; readonly home: string }): string | undefined {
304
+ if (where.platform !== "win32") return path.resolve(where.cwd, raw)
305
+ if (/^[\\/]{2}/.test(raw)) return undefined
306
+ const home = normalizeAbs(where.home)
307
+ const tilde = /^~(?:[\\/](.*))?$/.exec(raw)
308
+ if (tilde) return home ? normalizeAbs(path.win32.join(home, tilde[1] ?? "")) : undefined
309
+ const resolved = path.win32.resolve(path.win32.normalize(where.cwd), raw)
310
+ return /^[\\/]{2}/.test(resolved) ? undefined : normalizeAbs(resolved)
311
+ }
312
+
298
313
  function choicesOf(io: SetupIo, flags: Parsed["flags"], profile: Profile, snapshot: DiscoverySnapshot): SetupChoices {
299
314
  const enrollment = str(flags, "enrollment")
300
315
  if (enrollment !== undefined && !(ENROLLMENT_MODES as readonly string[]).includes(enrollment)) throw new Refusal("usage", `--enrollment is ${ENROLLMENT_MODES.join(" or ")}`)
301
- let roots = (flags["workspace-root"] as string[] | undefined)?.map((r) => path.resolve(io.cwd ?? process.cwd(), r))
316
+ const where = { platform: snapshot.host.platform, cwd: io.cwd ?? process.cwd(), home: io.home }
317
+ let roots = (flags["workspace-root"] as string[] | undefined)?.map((r) => {
318
+ const root = workspaceRootOf(r, where)
319
+ if (root === undefined) throw new Refusal("usage", `--workspace-root ${r} is not a local absolute path (a drive path such as C:\\Users\\you\\projects, or ~/projects)`)
320
+ return root
321
+ })
302
322
  if (!roots?.length && profile.services.includes("node")) {
303
- // Without --workspace-root, the workspace roots node.json already lists.
323
+ // Without --workspace-root, the workspace roots node.json already lists (canonical on Windows, as the plan holds them).
304
324
  const existing = readNodeConfig(nodeConfigPath(io.env as Record<string, string | undefined>, io.home)).config.workspaceRoots
305
- if (existing.length > 0) roots = [...existing]
325
+ if (existing.length > 0) roots = where.platform === "win32" ? existing.map((r) => workspaceRootOf(r, where) ?? r) : [...existing]
306
326
  }
307
327
  const launchHarness = str(flags, "verify-launch")
308
328
  let launch: SetupChoices["launch"]
@@ -32,6 +32,7 @@ import {
32
32
  type ProgramLayout,
33
33
  type Result,
34
34
  type RootMapping,
35
+ type ServiceID,
35
36
  type ServiceManagerID,
36
37
  type VerifiedManifest,
37
38
  } from "../bootstrap/contracts/index.ts"
@@ -49,10 +50,13 @@ import {
49
50
  type ManagerRunner,
50
51
  type NodeServiceControl,
51
52
  type NodeServicesPlan,
53
+ taskNameFor,
54
+ type TaskIdentity,
52
55
  } from "../bootstrap/adapters/node-services/index.ts"
53
56
  import { createOpencodeHarness, type Download, type ProcessRunner } from "../bootstrap/adapters/opencode/index.ts"
54
57
  import type { ReadinessGate } from "../bootstrap/adapters/readiness.ts"
55
- import { discover } from "../bootstrap/discovery/discover.ts"
58
+ import { discover, systemRootOf } from "../bootstrap/discovery/discover.ts"
59
+ import { defaultWinAcl } from "../../../../src/win-acl.ts"
56
60
  import { pathDirs, which } from "../bootstrap/discovery/executables.ts"
57
61
  import type { DiscoveryProbe } from "../bootstrap/discovery/probe.ts"
58
62
  import { machineEnroll, type EnrollPrompt } from "../bootstrap/enrollment/enroll.ts"
@@ -89,6 +93,14 @@ export interface RuntimePorts {
89
93
  readonly hubctlBytes?: () => Uint8Array | undefined
90
94
  readonly fs?: TxFs
91
95
  readonly uid?: number
96
+ /** Windows: the current user's SID (the logon task's principal); default `whoami /user` (win-acl.ts). */
97
+ readonly userSid?: () => string
98
+ }
99
+
100
+ /** The Windows programs the task-scheduler control runs: absolute, under %SystemRoot%\System32 (canonical form). */
101
+ export function windowsServicePrograms(systemRoot: string): { readonly schtasks: string; readonly powershell: string; readonly taskkill: string } {
102
+ const dir = `${systemRoot.replace(/\/+$/, "")}/System32`
103
+ return { schtasks: `${dir}/schtasks.exe`, powershell: `${dir}/WindowsPowerShell/v1.0/powershell.exe`, taskkill: `${dir}/taskkill.exe` }
92
104
  }
93
105
 
94
106
  export interface RuntimeDeps {
@@ -324,6 +336,7 @@ export function createSetupRuntime(deps: RuntimeDeps): SetupRuntime {
324
336
  ...(choices.activate ? { activate: true } : {}),
325
337
  environment: deps.env,
326
338
  ...(choices.launch ? { launch: choices.launch } : {}),
339
+ ...(input.snapshot.managers.service === "task-scheduler" && input.profile.services.length > 0 ? { task: taskIdentity() } : {}),
327
340
  })
328
341
  if (!planned.ok) return planned
329
342
  nodePlan = planned.value
@@ -343,14 +356,28 @@ export function createSetupRuntime(deps: RuntimeDeps): SetupRuntime {
343
356
  return { sources: { adapters: registry, contributors, ...(choices.enrollment ? { enrollment: choices.enrollment } : {}) }, node: () => nodePlan, history: records }
344
357
  }
345
358
 
359
+ /** The node's logon task for this Windows user: hub-node-<USERNAME> (a hub-test-… name only under test) and the SID. */
360
+ function taskIdentity(): TaskIdentity {
361
+ return { name: taskNameFor("node", deps.env.USERNAME ?? "", deps.env), userSid: (ports.userSid ?? (() => defaultWinAcl().sid()))() }
362
+ }
363
+
346
364
  function control(snap: DiscoverySnapshot): NodeServiceControl | undefined {
347
365
  if (snap.managers.service === "none") return undefined
366
+ const tasks = snap.managers.service === "task-scheduler"
348
367
  let run = ports.serviceRunner
349
368
  if (!run) {
350
- const systemctl = which(deps.probe, [...pathDirs(deps.probe), "/usr/bin", "/bin"], "systemctl") ?? "/usr/bin/systemctl"
351
- run = spawnManagerRunner({ programs: { launchctl: "/bin/launchctl", systemctl }, env: deps.env })
369
+ if (tasks) run = spawnManagerRunner({ programs: windowsServicePrograms(systemRootOf(deps.probe)), env: deps.env })
370
+ else {
371
+ const systemctl = which(deps.probe, [...pathDirs(deps.probe), "/usr/bin", "/bin"], "systemctl") ?? "/usr/bin/systemctl"
372
+ run = spawnManagerRunner({ programs: { launchctl: "/bin/launchctl", systemctl }, env: deps.env })
373
+ }
352
374
  }
353
- return createServiceControl({ run, uid: ports.uid ?? deps.probe.uid, unitPath: unitPathResolver(snap.roots, executionContextOf(snap)) })
375
+ return createServiceControl({
376
+ run,
377
+ uid: ports.uid ?? deps.probe.uid,
378
+ unitPath: unitPathResolver(snap.roots, executionContextOf(snap)),
379
+ ...(tasks ? { taskName: (service: ServiceID) => taskNameFor(service, deps.env.USERNAME ?? "", deps.env) } : {}),
380
+ })
354
381
  }
355
382
 
356
383
  function hubctlSource(): HubctlSource | undefined {
@@ -5,6 +5,8 @@
5
5
  // sessions submit under team policy and, in the team's entry role, report status.
6
6
  import { sanitizeText } from "../../sdk/src/envelope.ts"
7
7
  import { reportIntakeStatus, TEAM_INTAKE_STATUS_INPUTS, type TeamIntake, type TeamIntakeApi, type TeamIntakePriority, type TeamIntakeStatus, type TeamIntakeStatusInput } from "../../sdk/src/team-intake.ts"
8
+ import { UsageError } from "./args"
9
+ import { notAfterArg } from "./messaging"
8
10
  import { ToolArgError } from "./tool-args"
9
11
 
10
12
  type Args = Record<string, unknown>
@@ -60,6 +62,22 @@ function dueOf(args: Args): number | undefined {
60
62
  return ms
61
63
  }
62
64
 
65
+ /** `notAfter` (H4) as epoch milliseconds or an ISO 8601 date-time; the hub checks the range. */
66
+ function notAfterOf(args: Args): number | undefined {
67
+ const value = args.notAfter
68
+ if (value === undefined || value === null || value === "") return undefined
69
+ if (typeof value === "number" && Number.isSafeInteger(value)) return value
70
+ if (typeof value === "string") {
71
+ try {
72
+ return notAfterArg(value, `"notAfter"`)
73
+ } catch (cause) {
74
+ if (cause instanceof UsageError) throw new ToolArgError(cause.message)
75
+ throw cause
76
+ }
77
+ }
78
+ throw new ToolArgError(`"notAfter" must be epoch milliseconds or an ISO 8601 date-time like 2027-01-15T12:00:00Z`)
79
+ }
80
+
63
81
  const line = (i: TeamIntake) =>
64
82
  `${i.id} ${i.status}${i.progress ? `/${i.progress}` : ""} ${i.priority} r${i.revision} ${sanitizeText(i.title).replace(/\s+/g, " ")}`
65
83
 
@@ -71,6 +89,7 @@ function intakeText(i: TeamIntake): string {
71
89
  `status: ${i.status}${i.progress ? ` (${i.progress})` : ""} priority: ${i.priority} from: ${from}`,
72
90
  ...(i.assignmentID ? [`assignment: ${i.assignmentID} in workspace ${i.workspaceID ?? "?"}${i.sessionID ? `, delivered to ${i.sessionID}` : ""}`] : []),
73
91
  ...(i.due !== undefined ? [`due: ${new Date(i.due).toISOString()}`] : []),
92
+ ...(i.notAfter !== undefined ? [`not after: ${new Date(i.notAfter).toISOString()}`] : []),
74
93
  ...(i.labels?.length ? [`labels: ${i.labels.join(", ")}`] : []),
75
94
  ...(i.criteria?.length ? ["done means:", ...i.criteria.map((c) => `- ${sanitizeText(c)}`)] : []),
76
95
  ...(i.ref ? [`ref: ${sanitizeText(i.ref)}`] : []),
@@ -83,7 +102,7 @@ export const teamIntakeTools: readonly TeamIntakeTool[] = [
83
102
  {
84
103
  name: "team_submit",
85
104
  description:
86
- "Give work to a team: its entry role (e.g. the project manager) receives the task with the team's context and owns it as an assignment in the team's workspace; while the role is vacant it waits queued. Pass `key` to make retries idempotent. App tokens need scope teams:submit; sessions submit under the team's communication policy.",
105
+ "Give work to a team: its entry role (e.g. the project manager) receives the task with the team's context and owns it as an assignment in the team's workspace; while the role is vacant it waits queued (with `notAfter`, only until then: still queued at that time, it is cancelled with reason not_after instead of delivered late). Pass `key` to make retries idempotent. App tokens need scope teams:submit; sessions submit under the team's communication policy.",
87
106
  properties: {
88
107
  team: TEAM,
89
108
  title: { ...STRING, description: "One line, at most 120 characters." },
@@ -91,6 +110,10 @@ export const teamIntakeTools: readonly TeamIntakeTool[] = [
91
110
  priority: { type: "string", enum: PRIORITIES, description: "Default normal." },
92
111
  labels: { type: "array", items: STRING, description: "Slugs (lowercase letters, digits, dashes)." },
93
112
  due: { ...STRING, description: "When it is due: an ISO date/time." },
113
+ notAfter: {
114
+ type: ["number", "string"],
115
+ description: "Never deliver after this time: epoch milliseconds or an ISO 8601 date-time (at most 30 days ahead). Already passed: nothing is submitted (expired). Still queued then: cancelled (reason not_after).",
116
+ },
94
117
  criteria: { type: "array", items: STRING, description: "What done means (at most 8 lines)." },
95
118
  ref: { ...STRING, description: "An external reference, e.g. a Brain task ID." },
96
119
  key: { ...STRING, description: "Idempotency-Key: the same key and body return the original submission." },
@@ -103,12 +126,13 @@ export const teamIntakeTools: readonly TeamIntakeTool[] = [
103
126
  const labels = strings(args, "labels")
104
127
  const criteria = strings(args, "criteria")
105
128
  const due = dueOf(args)
129
+ const notAfter = notAfterOf(args)
106
130
  const brief = optional(args, "brief")
107
131
  const ref = optional(args, "ref")
108
132
  const key = optional(args, "key")
109
133
  const { intake, created } = await hub.teams.submit(
110
134
  need(args, "team"),
111
- { title: need(args, "title"), ...(brief ? { brief } : {}), ...(priority ? { priority } : {}), ...(labels ? { labels } : {}), ...(due !== undefined ? { due } : {}), ...(criteria ? { criteria } : {}), ...(ref ? { ref } : {}) },
135
+ { title: need(args, "title"), ...(brief ? { brief } : {}), ...(priority ? { priority } : {}), ...(labels ? { labels } : {}), ...(due !== undefined ? { due } : {}), ...(notAfter !== undefined ? { notAfter } : {}), ...(criteria ? { criteria } : {}), ...(ref ? { ref } : {}) },
112
136
  { signal, ...(key ? { idempotencyKey: key } : {}) },
113
137
  )
114
138
  return `${created ? "Submitted" : "Already submitted (same key)"}: ${intake.id} is ${intake.status}${intake.status === "queued" ? " (waiting for the entry role)" : ""}\n${intakeText(intake)}`