@huynle/hub 0.8.6 → 0.9.0

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 (184) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +69 -12
  3. package/package.json +5 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +278 -35
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/bootstrap/adapters/opencode/README.md +4 -2
  8. package/packages/cli/src/bootstrap/release/harness-pins.json +22 -0
  9. package/packages/cli/src/bootstrap/release/harness-pins.pending.json +1 -20
  10. package/packages/cli/src/cli.ts +23 -10
  11. package/packages/cli/src/http.ts +1 -0
  12. package/packages/cli/src/launches.ts +5 -1
  13. package/packages/cli/src/mcp.ts +28 -8
  14. package/packages/cli/src/messaging.ts +14 -0
  15. package/packages/cli/src/team-intake-mcp.ts +26 -2
  16. package/packages/codex/package.json +1 -1
  17. package/packages/node/package.json +1 -1
  18. package/packages/preset/package.json +1 -1
  19. package/packages/protocol/package.json +1 -1
  20. package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
  21. package/packages/protocol/src/artifacts.ts +43 -0
  22. package/packages/protocol/src/credentials.ts +38 -0
  23. package/packages/protocol/src/index.ts +56 -5
  24. package/packages/protocol/src/launch.ts +7 -2
  25. package/packages/protocol/src/markdown.ts +753 -0
  26. package/packages/protocol/src/media-allowlist.ts +240 -0
  27. package/packages/protocol/src/media.ts +120 -0
  28. package/packages/protocol/src/preview-bundle.ts +221 -0
  29. package/packages/protocol/src/voice-recording.ts +8 -0
  30. package/packages/runtime-openshell/package.json +1 -1
  31. package/packages/runtime-sdk/package.json +1 -1
  32. package/packages/sdk/package.json +1 -1
  33. package/packages/sdk/src/client.ts +17 -3
  34. package/packages/sdk/src/errors.ts +1 -0
  35. package/packages/sdk/src/team-intake.ts +8 -0
  36. package/packages/sdk/src/types.ts +16 -2
  37. package/packages/spoke-sdk/package.json +1 -1
  38. package/src/central/agent/delivery.ts +35 -6
  39. package/src/central/agent/errors.ts +2 -0
  40. package/src/central/agent/events.ts +11 -2
  41. package/src/central/agent/launch-engine.ts +65 -4
  42. package/src/central/agent/launch-routes.ts +8 -0
  43. package/src/central/agent/launch-spec.ts +18 -1
  44. package/src/central/agent/launch-store.ts +2 -2
  45. package/src/central/agent/message-routes.ts +49 -11
  46. package/src/central/agent/messages.ts +17 -4
  47. package/src/central/agent/not-after.ts +28 -0
  48. package/src/central/agent/placement.ts +36 -4
  49. package/src/central/agent/routes.ts +3 -1
  50. package/src/central/agent/webhook-routes.ts +21 -9
  51. package/src/central/agent/webhooks.ts +27 -9
  52. package/src/central/artifacts/host.ts +3 -1
  53. package/src/central/artifacts/maintenance.ts +2 -1
  54. package/src/central/artifacts/preview-bundle.ts +81 -0
  55. package/src/central/artifacts/previews.ts +94 -0
  56. package/src/central/artifacts/routes.ts +12 -1
  57. package/src/central/artifacts/store.ts +198 -8
  58. package/src/central/config.ts +34 -0
  59. package/src/central/fetch-budget.ts +59 -0
  60. package/src/central/fetcher.ts +372 -0
  61. package/src/central/http.ts +277 -22
  62. package/src/central/media-remote.ts +248 -0
  63. package/src/central/media-timeline.ts +317 -0
  64. package/src/central/media-unfurl.ts +119 -0
  65. package/src/central/profiles/content.ts +3 -35
  66. package/src/central/recording/routes.ts +2 -2
  67. package/src/central/recording/schema.ts +3 -1
  68. package/src/central/registry.ts +449 -21
  69. package/src/central/spoke-ws.ts +17 -3
  70. package/src/central/store.ts +21 -0
  71. package/src/central/teams/intake-routes.ts +2 -1
  72. package/src/central/teams/intake.ts +200 -15
  73. package/src/central/unfurl-parse.ts +313 -0
  74. package/src/index.ts +68 -8
  75. package/src/speakable.ts +33 -1
  76. package/src/spoke/conversation.ts +7 -4
  77. package/src/spoke/deliver.ts +19 -0
  78. package/src/spoke/media-bytes.ts +171 -0
  79. package/src/spoke/media-policy.ts +394 -0
  80. package/src/spoke/media.ts +189 -0
  81. package/src/spoke/own-server.ts +30 -0
  82. package/src/spoke/preview-bundle.ts +538 -0
  83. package/src/spoke/spoke.ts +387 -17
  84. package/src/spoke/timeline.ts +59 -5
  85. package/src/spoke/voice-front.ts +335 -0
  86. package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
  87. package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
  88. package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
  89. package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
  90. package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
  91. package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
  92. package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
  93. package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
  94. package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
  95. package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
  96. package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
  97. package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
  98. package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
  99. package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
  100. package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
  101. package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
  102. package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
  103. package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
  104. package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
  105. package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
  106. package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
  107. package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
  108. package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
  109. package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
  110. package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
  111. package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
  112. package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
  113. package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
  114. package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
  115. package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
  116. package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
  117. package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
  118. package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
  119. package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
  120. package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
  121. package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
  122. package/web/dist/assets/Library-TwvrwqSk.js +2 -0
  123. package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
  124. package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
  125. package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
  126. package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
  127. package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
  128. package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
  129. package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
  130. package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
  131. package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
  132. package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
  133. package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
  134. package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
  135. package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
  136. package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
  137. package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
  138. package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
  139. package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
  140. package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
  141. package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
  142. package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
  143. package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
  144. package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
  145. package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
  146. package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
  147. package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
  148. package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
  149. package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
  150. package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
  151. package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
  152. package/web/dist/assets/index-C3ci5UtY.css +1 -0
  153. package/web/dist/assets/index-D-xG9nrc.js +36 -0
  154. package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
  155. package/web/dist/assets/live-7q72Lyj2.js +5 -0
  156. package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
  157. package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
  158. package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
  159. package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
  160. package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
  161. package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
  162. package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
  163. package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
  164. package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
  165. package/web/dist/assets/source-BWlvaODa.js +1 -0
  166. package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
  167. package/web/dist/assets/ui-CFB4wQfF.js +3 -0
  168. package/web/dist/index.html +8 -6
  169. package/web/src/voice/turn/LICENSE +28 -0
  170. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
  171. package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
  172. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
  173. package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
  174. package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
  175. package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
  176. package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
  177. package/web/dist/assets/hub-CrbItiwP.js +0 -13
  178. package/web/dist/assets/index-82Ce0IAf.css +0 -1
  179. package/web/dist/assets/index-D9SP3_0z.js +0 -22
  180. package/web/dist/assets/live-BNJgy0vg.js +0 -5
  181. package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
  182. package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
  183. package/web/dist/assets/source-Wbq35qGp.js +0 -1
  184. package/web/dist/assets/ui-j5FFk90B.js +0 -3
@@ -89,6 +89,28 @@
89
89
  "evidence-listed"
90
90
  ]
91
91
  }
92
+ },
93
+ {
94
+ "pin": {
95
+ "harness": "opencode",
96
+ "version": "2.2.0",
97
+ "target": "windows-x64",
98
+ "url": "https://github.com/anomalyco/opencode/releases/download/v2.2.0/opencode-windows-x64-baseline.zip",
99
+ "file": "opencode-windows-x64-baseline.zip",
100
+ "size": 118610005,
101
+ "sha256": "sha256:810fe13f6d85c6121e8ab0dd09933ababa2d52dccce9f3bc9ec70de5a9ae126e",
102
+ "evidence": [],
103
+ "minimumVersion": "2.0.0"
104
+ },
105
+ "review": {
106
+ "by": "huynle",
107
+ "on": "2026-11-06",
108
+ "checked": [
109
+ "upstream-release",
110
+ "digest-redownloaded",
111
+ "evidence-listed"
112
+ ]
113
+ }
92
114
  }
93
115
  ]
94
116
  }
@@ -1,24 +1,5 @@
1
1
  {
2
2
  "schema": "hub.harness-pin-pending",
3
3
  "version": 1,
4
- "pins": [
5
- {
6
- "pin": {
7
- "harness": "opencode",
8
- "version": "2.2.0",
9
- "target": "windows-x64",
10
- "url": "https://github.com/anomalyco/opencode/releases/download/v2.2.0/opencode-windows-x64-baseline.zip",
11
- "file": "opencode-windows-x64-baseline.zip",
12
- "size": 118610005,
13
- "sha256": "sha256:810fe13f6d85c6121e8ab0dd09933ababa2d52dccce9f3bc9ec70de5a9ae126e",
14
- "evidence": [],
15
- "minimumVersion": "2.0.0"
16
- },
17
- "prepared": {
18
- "by": "agent (Phase 2 release workstream; NOT a review)",
19
- "on": "2026-11-05",
20
- "note": "UNREVIEWED. The author downloaded the asset (curl -fsSL), measured 118610005 bytes and sha256 810fe13f…126e, which equal the release's SHA256SUMS line and the GitHub API assets[].digest for tag v2.2.0 (published 2026-10-27 by github-actions[bot] from dev). unzip -Zl lists exactly one member, opencode.exe (253303688 bytes, deflate, FAT host). The owner must redo every check of release/README.md themselves and move this entry into harness-pins.json with their own review record; until then only the dev release includes it."
21
- }
22
- }
23
- ]
4
+ "pins": []
24
5
  }
@@ -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,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)}`
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-codex",
3
- "version": "0.8.6",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "The Codex app-server spoke: attaches to Codex's app-server (its own over stdio, and the shared daemon's socket when present), and the `hub codex` wrapper (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-node",
3
- "version": "0.8.6",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "`hub node`: the per-machine daemon that prepares, collects and cleans up launch workspaces (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-preset",
3
- "version": "0.8.6",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "The preset spoke: drives a harness without a spoke of its own through a terminal pane (tmux), described by a data-only preset; official presets in presets/ (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-protocol",
3
- "version": "0.8.6",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "Hub ↔ spoke wire protocol (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",