@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.
- package/CHANGELOG.md +152 -0
- package/README.md +75 -16
- package/package.json +5 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/wrapper.ts +2 -2
- package/packages/cli/dist/hub-cli.js +1413 -523
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/PHASE3-CONTRACT.md +112 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +164 -21
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +24 -7
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +116 -9
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +8 -2
- package/packages/cli/src/bootstrap/contracts/actions.ts +8 -6
- package/packages/cli/src/bootstrap/contracts/discovery.ts +4 -3
- package/packages/cli/src/bootstrap/contracts/exec.ts +4 -2
- package/packages/cli/src/bootstrap/contracts/paths.ts +2 -1
- package/packages/cli/src/bootstrap/contracts/platform.ts +5 -5
- package/packages/cli/src/bootstrap/discovery/discover.ts +7 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +2 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +5 -1
- package/packages/cli/src/bootstrap/profiles/desired.ts +16 -1
- package/packages/cli/src/bootstrap/profiles/history.ts +2 -1
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +2 -2
- package/packages/cli/src/bootstrap/release/assets.ts +4 -1
- package/packages/cli/src/cli.ts +23 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +5 -1
- package/packages/cli/src/mcp.ts +28 -8
- package/packages/cli/src/messaging.ts +14 -0
- package/packages/cli/src/node.ts +94 -12
- package/packages/cli/src/setup/commands.ts +27 -7
- package/packages/cli/src/setup/runtime.ts +31 -4
- package/packages/cli/src/team-intake-mcp.ts +26 -2
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/wrapper.ts +2 -2
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +49 -17
- package/packages/node/src/git.ts +16 -2
- package/packages/node/src/harnesses.ts +54 -9
- package/packages/node/src/host.ts +61 -15
- package/packages/node/src/opencode-service.ts +111 -0
- package/packages/node/src/opencode-warm.ts +11 -5
- package/packages/node/src/run.ts +50 -5
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
- package/packages/protocol/src/artifacts.ts +43 -0
- package/packages/protocol/src/credentials.ts +38 -0
- package/packages/protocol/src/index.ts +59 -6
- package/packages/protocol/src/launch.ts +46 -6
- package/packages/protocol/src/markdown.ts +753 -0
- package/packages/protocol/src/media-allowlist.ts +240 -0
- package/packages/protocol/src/media.ts +120 -0
- package/packages/protocol/src/preview-bundle.ts +221 -0
- package/packages/protocol/src/voice-recording.ts +8 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +17 -3
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/team-intake.ts +8 -0
- package/packages/sdk/src/types.ts +16 -2
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/delivery.ts +35 -6
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/events.ts +11 -2
- package/src/central/agent/launch-engine.ts +65 -4
- package/src/central/agent/launch-routes.ts +8 -0
- package/src/central/agent/launch-spec.ts +22 -3
- package/src/central/agent/launch-store.ts +2 -2
- package/src/central/agent/message-routes.ts +49 -11
- package/src/central/agent/messages.ts +17 -4
- package/src/central/agent/not-after.ts +28 -0
- package/src/central/agent/placement.ts +36 -4
- package/src/central/agent/routes.ts +3 -1
- package/src/central/agent/webhook-routes.ts +21 -9
- package/src/central/agent/webhooks.ts +27 -9
- package/src/central/artifacts/host.ts +3 -1
- package/src/central/artifacts/maintenance.ts +2 -1
- package/src/central/artifacts/preview-bundle.ts +81 -0
- package/src/central/artifacts/previews.ts +94 -0
- package/src/central/artifacts/routes.ts +12 -1
- package/src/central/artifacts/store.ts +198 -8
- package/src/central/config.ts +34 -0
- package/src/central/fetch-budget.ts +59 -0
- package/src/central/fetcher.ts +372 -0
- package/src/central/http.ts +277 -22
- package/src/central/install/CONTRACT.md +4 -4
- package/src/central/install/install.ps1 +37 -5
- package/src/central/install/render.ts +13 -8
- package/src/central/media-remote.ts +248 -0
- package/src/central/media-timeline.ts +317 -0
- package/src/central/media-unfurl.ts +119 -0
- package/src/central/profiles/content.ts +3 -35
- package/src/central/recording/routes.ts +2 -2
- package/src/central/recording/schema.ts +3 -1
- package/src/central/registry.ts +449 -21
- package/src/central/spoke-ws.ts +17 -3
- package/src/central/store.ts +21 -0
- package/src/central/teams/intake-routes.ts +2 -1
- package/src/central/teams/intake.ts +200 -15
- package/src/central/unfurl-parse.ts +313 -0
- package/src/index.ts +68 -8
- package/src/speakable.ts +33 -1
- package/src/spoke/conversation.ts +7 -4
- package/src/spoke/deliver.ts +19 -0
- package/src/spoke/media-bytes.ts +171 -0
- package/src/spoke/media-policy.ts +394 -0
- package/src/spoke/media.ts +189 -0
- package/src/spoke/own-server.ts +30 -0
- package/src/spoke/preview-bundle.ts +538 -0
- package/src/spoke/spoke.ts +387 -17
- package/src/spoke/timeline.ts +59 -5
- package/src/spoke/voice-front.ts +335 -0
- package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
- package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
- package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
- package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
- package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
- package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
- package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
- package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
- package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
- package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
- package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
- package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
- package/web/dist/assets/ArtifactPage-BKISN-Qj.js +1 -0
- package/web/dist/assets/ArtifactPane-CKOiAUNH.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +6 -0
- package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
- package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
- package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
- package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
- package/web/dist/assets/Diagram-azw2B_QH.js +1 -0
- package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
- package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
- package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
- package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
- package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
- package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
- package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
- package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
- package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
- package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
- package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
- package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
- package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
- package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
- package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
- package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
- package/web/dist/assets/Library-CcjZXsfF.js +2 -0
- package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
- package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
- package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
- package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
- package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
- package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
- package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
- package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
- package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
- package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
- package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +1 -0
- package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
- package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
- package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
- package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
- package/web/dist/assets/TeamsPage-CDyjrK-I.js +1 -0
- package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
- package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
- package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
- package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
- package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
- package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
- package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
- package/web/dist/assets/Watchtower-DYwoUWME.js +2 -0
- package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
- package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
- package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
- package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
- package/web/dist/assets/index-C3ci5UtY.css +1 -0
- package/web/dist/assets/index-LNMZCqgE.js +36 -0
- package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
- package/web/dist/assets/live-ycZ_aQZO.js +5 -0
- package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
- package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
- package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
- package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-BIwbgRoX.js} +1 -1
- package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-C1a7eYGQ.js} +1 -1
- package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-DHhPy61Q.js} +1 -1
- package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
- package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
- package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
- package/web/dist/assets/source-BWlvaODa.js +1 -0
- package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
- package/web/dist/assets/ui-CFB4wQfF.js +3 -0
- package/web/dist/index.html +8 -6
- package/web/src/voice/turn/LICENSE +28 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
- package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
- package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
- package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
- package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
- package/web/dist/assets/hub-CrbItiwP.js +0 -13
- package/web/dist/assets/index-82Ce0IAf.css +0 -1
- package/web/dist/assets/index-D9SP3_0z.js +0 -22
- package/web/dist/assets/live-BNJgy0vg.js +0 -5
- package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
- package/web/dist/assets/source-Wbq35qGp.js +0 -1
- package/web/dist/assets/ui-j5FFk90B.js +0 -3
package/packages/cli/src/cli.ts
CHANGED
|
@@ -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]
|
|
85
|
-
|
|
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 --
|
|
282
|
-
const sendFlags = (flags: Parsed["flags"]) =>
|
|
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]!)
|
package/packages/cli/src/http.ts
CHANGED
|
@@ -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
|
|
package/packages/cli/src/mcp.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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. */
|
package/packages/cli/src/node.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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({
|
|
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)}`
|