@decidio/sdk 0.10.0 → 0.11.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.
package/CHANGELOG.md CHANGED
@@ -7,7 +7,55 @@ allowlist. Each entry names what changed, what a reader must do on upgrade, and
7
7
 
8
8
  ## Unreleased
9
9
 
10
- ## 0.10.0 — connect a coding assistant in one tap
10
+ ## 0.11.0 — connect once, on in every project; no surprises; the connector connects a computer
11
+
12
+ 0.10.0 was never published (its tag was held for this release), so 0.11.0 is the first release with `connect`. It carries
13
+ cycle connect-once's slice 1, 2a and 2b (founder 2026-10-29: one tag, after they merge).
14
+
15
+ - **One connect per computer.** A connection now covers every repository on the computer, and `connect` switches the hook
16
+ on in your own `~/.claude/settings.json`, which every project reads. A repository connected the 0.10.0 way keeps its own
17
+ connection (read first). A cloud session and `--team` still use the repository's `.claude/settings.json`.
18
+ - **The hook connects the computer itself.** A destructive command on a computer with no credential is refused with a link
19
+ and a short code (to Claude, and in a line to the person); once the workspace owner allows it, the next try finishes
20
+ connecting and asks for the approval in the same run. It never writes Claude Code's settings - in auto mode the assistant
21
+ may not, and a person put the hook there. Two hooks at once share one link. It connects only the Decidio your shell
22
+ names (or the default), never one a repository's `.env` names, and only over HTTPS; a project whose `.env` names an agent
23
+ with no token is refused by name rather than given the computer's connection. The run that connects tells the person which
24
+ workspace and who allowed it, even when the command then runs at once.
25
+ - **The computer-wide hook is the pinned `npx -y @decidio/sdk@0.11.0` command**, so it works in every project; a project
26
+ install still writes `$CLAUDE_PROJECT_DIR` unescaped, so Claude Code expands it. The settings file is written whole, and
27
+ through a symlink.
28
+ - **No surprises** (founder 2026-10-29: "the user is fully aware and not surprised"):
29
+ - `hook session`, a SessionStart hook: every session opens with one line - approvals are on here, what waits and for whom,
30
+ or that this computer isn't connected yet. Local only, never blocks;
31
+ - `connect` says what waits for approval and that everything else runs as before;
32
+ - a held command names who approves and where they see it;
33
+ - the plugin skill says what connecting does before doing it, and has the person run `! npx @decidio/sdk connect` when
34
+ auto mode refuses it.
35
+ - **The plugin** (0.5.0) carries the SessionStart hook, and a marketplace file ready to publish - not published (founder:
36
+ nothing public until the listing is ready).
37
+ - **The connector connects a computer** (slice 2a):
38
+ - `connect --ticket <ticket>` finishes the link the Decidio connector allowed (`connect_device` returns the ticket): the
39
+ poll carries this computer's own secret and the ticket, and only the credential is written;
40
+ - the hook names the connector's way first when a computer isn't connected (call `connect_device` with the code, run what
41
+ it returns), then the link and code; a link the connector allowed is kept while it waits for its ticket.
42
+ - **Pause and resume** (slice 2b): `hook prompt`, a UserPromptSubmit hook that `hook install`, `connect` and the plugin add
43
+ behind a shell pre-filter, acts on the person's own typed "decidio pause" / "decidio start" (exact words, `source: user`
44
+ only) and keeps the words from the model. While paused a gated command runs with a line and no decision - asked of Decidio only
45
+ in a session paused on this computer; the settings guard (now run over the whole line, inside `sh -c` too, for the pause and
46
+ the shared file's notice alike) and the self-decide refusal stay on; an agent running `hook prompt` itself is refused. The
47
+ session line says how to pause, or that the organisation keeps the guard on.
48
+ - **A shared file tells, then lets through** (slice 2a): `hook pretool --unconnected=tell`, which `connect` writes into a
49
+ shared file, refuses a computer with no credential once per session and command, then lets the same call pass with a line
50
+ to the person - never an `allow`, so Claude Code's own permission check still decides. No flag, a value it doesn't know,
51
+ or no session id holds, as before. `connect --team --hold-everyone` writes the holding hook instead.
52
+
53
+ On upgrade: nothing to do; 0.10.0 repository connections keep working. Proved by: cli-smoke.ts (378 -> 400; slice 1: one connect for
54
+ two repositories, the 0.10.0 file read first, the hook's own link - wait - collect-then-ask without touching settings, two
55
+ hooks sharing a link, a refused link, the approver in holds, the session line, SessionStart installed; slice 2a: the
56
+ connector's way, the ticket's usage, secret and ticket on the poll, tell once then a line, hold by default, --hold-everyone).
57
+
58
+ ## 0.10.0 — connect a coding assistant in one tap (not published; folded into 0.11.0)
11
59
 
12
60
  - **`npx @decidio/sdk connect`** (cycle connect). A coding assistant sets Decidio up for you: it runs `connect`, which prints a
13
61
  link and a short code (on a real terminal also a QR, and it opens the browser); the workspace owner opens the link signed in,
@@ -16,8 +64,8 @@ allowlist. Each entry names what changed, what a reader must do on upgrade, and
16
64
  ask-only credential - never a session - kept OUTSIDE the repository (the config dir, owner-only, keyed by the repository root
17
65
  and the Decidio), and the hooks switch on in `.claude/settings.local.json` (`--team`: the shared `.claude/settings.json`).
18
66
  No `.env`, nothing to paste, the word "agent" nowhere in the setup.
19
- - **In a cloud session the hooks go in `.claude/settings.json`.** A cloud session reads only the repository's own settings file,
20
- never `.claude/settings.local.json` (Claude Code's docs), so that is where they work. connect leaves it uncommitted and says to ask
67
+ - **In a cloud session the hooks go in `.claude/settings.json`.** A cloud session reads the repository's files, not your
68
+ computer's, and only the committed shared file reaches the next cloud machine, so that is where they go. connect leaves it uncommitted and says to ask
21
69
  the person before committing it (committed, every cloud session of the repository and every teammate's clone ask too). The
22
70
  credential is that cloud machine's: a fresh one refuses destructive commands until it connects again.
23
71
  - **The machine proves its key.** The start is signed with the machine's own key (the request-bound proof every signed
package/dist/cli.d.ts CHANGED
@@ -18,6 +18,8 @@ export declare function defaultAgentId(dir?: string): string;
18
18
  * distinct decision ids can never share a file (first-run review of 0.4.1: dots were STRIPPED, `a.b` and `ab`
19
19
  * collided). `~` is outside the allowed set, so the escape is unambiguous. Exported for the smokes. */
20
20
  export declare function receiptFileName(id: string): string;
21
+ /** Exactly what the person types to pause or resume the guard - nothing else is read. PURE. */
22
+ export declare function guardWords(prompt: unknown): "pause" | "start" | null;
21
23
  /** Scoped packs (GitHub App design §2): a pack's scope in words - the organisation's, a unit's (its type, and its name when
22
24
  * the server sent one: names that fail its person check are withheld there), or the reader's own. Null for an older server. */
23
25
  export declare function packScopeLabel(scope: any): string | null;
package/dist/cli.js CHANGED
@@ -76,9 +76,17 @@ if (!OFFLINE_CMD)
76
76
  }
77
77
  const DEFAULT_API = "https://decidio-api.onrender.com";
78
78
  let API = (process.env.DECIDIO_API_URL ?? DEFAULT_API).replace(/\/$/, "");
79
+ const COMPUTER = "*";
80
+ const EVERY_PROJECT = "every project";
81
+ let CONNECTED_APPROVER = null;
82
+ let CONNECTED_UNANSWERED = false;
83
+ let CONNECTED_APP_URL = null;
84
+ let CONNECTED_WORKSPACE = null;
85
+ let GUARD_PAUSABLE = true;
86
+ let connectNotice = null;
79
87
  let CREDS_FROM = SHELL_ENV.DECIDIO_API_TOKEN ? "shell" : process.env.DECIDIO_API_TOKEN ? ".env" : null;
80
88
  if (!OFFLINE_CMD && !["init", "quickstart", "login", "logout", "phone"].includes(ARGV_TAIL[0] ?? "") && !process.env.DECIDIO_API_TOKEN && !process.env.DECIDIO_AGENT_ID) {
81
- const c = loadConnection(projectRoot(), API);
89
+ const c = activeConnection(projectRoot(), API);
82
90
  if (c) {
83
91
  promoteConnection(c);
84
92
  CREDS_FROM = "connection";
@@ -2764,6 +2772,14 @@ function hookMatch(command, at, rules = [...HOOK_RULES, ...hookExtraRules()], de
2764
2772
  }
2765
2773
  return null;
2766
2774
  }
2775
+ function hookEditsGuard(command, at) {
2776
+ try {
2777
+ return !!hookMatch(command, at, HOOK_RULES.filter((r) => r.category === "decidio-guard"));
2778
+ }
2779
+ catch {
2780
+ return true;
2781
+ }
2782
+ }
2767
2783
  function hookSelfDecides(command, depth = 0) {
2768
2784
  const hit = hookSegments(command).some((s) => {
2769
2785
  const a = s.args.map((x) => x.replace(/^["']|["']$/g, ""));
@@ -3038,7 +3054,7 @@ function hookSubject(category, segs, cwdAbs, projectDir, at) {
3038
3054
  }
3039
3055
  return textOnly("text binds this category");
3040
3056
  }
3041
- const hookIsOurs = (h) => typeof h?.command === "string" && /\bhook (pretool|posttool)\b/.test(h.command) && /decidio|cli\.(js|ts)/.test(h.command);
3057
+ const hookIsOurs = (h) => typeof h?.command === "string" && /\bhook (pretool|posttool|session|prompt)\b/.test(h.command) && /decidio|cli\.(js|ts)/.test(h.command);
3042
3058
  const hookSorted = (v) => Array.isArray(v) ? v.map(hookSorted) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, hookSorted(v[k])])) : v;
3043
3059
  function hookOurHandlers(settings) {
3044
3060
  const out = [];
@@ -3398,8 +3414,91 @@ async function hookHold(cfg, decisionId, holdMs) {
3398
3414
  }
3399
3415
  return { status, lastError };
3400
3416
  }
3401
- async function cmdHookPretool() {
3402
- const out = (o) => { process.stdout.write(JSON.stringify(o) + "\n"); return 0; };
3417
+ const ORGANISATION_KEEPS_ON = "Your organisation keeps Decidio on for every session.";
3418
+ const GUARD_PAUSE_HOURS = 12;
3419
+ export function guardWords(prompt) {
3420
+ const m = typeof prompt === "string" ? /^\s*decidio\s+(pause|start)\s*[.!]?\s*$/i.exec(prompt) : null;
3421
+ return m ? m[1].toLowerCase() : null;
3422
+ }
3423
+ const guardSessionKey = (material, session) => idempotencyKey(material, { purpose: "guard-pause", host: "claude-code", session });
3424
+ async function cmdHookPrompt() {
3425
+ const block = (reason) => { process.stdout.write(JSON.stringify({ decision: "block", reason }) + "\n"); return 0; };
3426
+ let action = null;
3427
+ try {
3428
+ const read = await readHookStdin();
3429
+ if (!read.ok)
3430
+ return 0;
3431
+ const input = read.input;
3432
+ action = guardWords(input.prompt);
3433
+ if (!action)
3434
+ return 0;
3435
+ const not = action === "pause" ? "the guard stays on" : "nothing changed";
3436
+ if (input.source !== "user")
3437
+ return block(`Decidio: "decidio ${action}" works only on a prompt you type yourself - ${not}.`);
3438
+ const session = typeof input.session_id === "string" ? input.session_id : "";
3439
+ if (!session)
3440
+ return block(`Decidio: this session has no id to pause the guard for - ${not}.`);
3441
+ const cfg = hookConfig();
3442
+ if ("missing" in cfg)
3443
+ return block(`Decidio isn't connected on this computer, so there is no guard to ${action === "pause" ? "pause" : "resume"} here.`);
3444
+ const material = bindingMaterial(cfg);
3445
+ if (!material)
3446
+ return block(`Decidio: this computer's credential can't key the pause - ${not}. Ask the person to run npx @decidio/sdk doctor.`);
3447
+ assertSafeTransport(cfg.apiUrl, true);
3448
+ const r = await fetch(cfg.apiUrl.replace(/\/$/, "") + "/agent/guard", { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiToken}` },
3449
+ body: JSON.stringify({ session: guardSessionKey(material, session), action }), signal: AbortSignal.timeout(10_000) });
3450
+ const body = await r.json().catch(() => null);
3451
+ if (r.status === 403 && body?.code === "ORGANISATION_KEEPS_ON") {
3452
+ notePause(session, false);
3453
+ return block(ORGANISATION_KEEPS_ON);
3454
+ }
3455
+ if (r.ok && body?.state === "paused") {
3456
+ notePause(session, true);
3457
+ return block(`Decidio guard paused for this session: destructive commands run without a person's approval - each one is recorded in your workspace's audit log - until you type "decidio start" (or for ${GUARD_PAUSE_HOURS} hours). Decidio still asks before a command edits Claude Code's settings to switch its check off.`);
3458
+ }
3459
+ if (r.ok && body?.state === "on") {
3460
+ notePause(session, false);
3461
+ return block(body.resumed ? "Decidio guard is back on for this session." : "Decidio guard is on for this session (it wasn't paused).");
3462
+ }
3463
+ return block(`Decidio couldn't ${action} the guard just now (${r.status}) - ${action === "pause" ? "it stays on" : "if it was paused, type \"decidio start\" again"}.`);
3464
+ }
3465
+ catch (e) {
3466
+ if (!action)
3467
+ return 0;
3468
+ return block(`Decidio couldn't ${action} the guard just now (${String(e?.message ?? e).replace(/^\[decidio\] /, "").slice(0, 160)}) - ${action === "pause" ? "it stays on" : "if it was paused, type \"decidio start\" again"}.`);
3469
+ }
3470
+ }
3471
+ function hookForgesPrompt(command, depth = 0) {
3472
+ const hit = hookSegments(command).some((s) => {
3473
+ const a = s.args.map((x) => x.replace(/^["']|["']$/g, ""));
3474
+ const cli = (x) => /^@decidio\/sdk(@\S+)?$/i.test(x) || /^decidio(@\S+)?$/i.test(x) || /(^|[\\/])(decidio-guard|@decidio[\\/]sdk)[\\/](dist[\\/])?cli\.(js|ts)$/i.test(x);
3475
+ const runsCli = s.program === "decidio" || s.program === "decidio.js"
3476
+ || ((["npx", "pnpx", "bunx", "npm", "pnpm", "yarn", "bun", "node", "tsx", "uv", "uvx", "pipx"].includes(s.program) || /^python[0-9.]*$/.test(s.program)) && a.some(cli));
3477
+ const i = a.indexOf("hook");
3478
+ return runsCli && i >= 0 && a.slice(i + 1).some((x) => x.toLowerCase() === "prompt");
3479
+ });
3480
+ return hit || (depth < 2 && hookShellInner(command).some((c) => hookForgesPrompt(c, depth + 1)));
3481
+ }
3482
+ function cmdHookSession() {
3483
+ try {
3484
+ const cfg = hookConfig();
3485
+ const msg = "missing" in cfg
3486
+ ? "Decidio approvals are on here, but this computer isn't connected yet - the first destructive command shows a link to connect it (once, for every project on this computer)."
3487
+ : `Decidio guard is on for this session${CONNECTED_WORKSPACE ? ` (workspace ${CONNECTED_WORKSPACE})` : ""}: deleting files, force-pushing, dropping tables, infrastructure changes and publishing wait for ${CONNECTED_APPROVER ?? "a person"}'s OK; everything else runs as usual. `
3488
+ + (GUARD_PAUSABLE ? `Type "decidio pause" to pause it for this session, "decidio start" to resume.` : ORGANISATION_KEEPS_ON);
3489
+ process.stdout.write(JSON.stringify({ systemMessage: msg, hookSpecificOutput: { hookEventName: "SessionStart",
3490
+ additionalContext: "Decidio's hook asks a person before destructive shell commands in this project. When it refuses one, tell the user what its message says (who must approve, or the link that connects this computer) and follow it; never work around it." } }) + "\n");
3491
+ }
3492
+ catch { }
3493
+ return 0;
3494
+ }
3495
+ async function cmdHookPretool(opts = { unconnected: "hold" }) {
3496
+ const out = (o) => {
3497
+ if (connectNotice)
3498
+ o = { ...o, systemMessage: o?.systemMessage ? `${connectNotice} ${o.systemMessage}` : connectNotice };
3499
+ process.stdout.write(JSON.stringify(o) + "\n");
3500
+ return 0;
3501
+ };
3403
3502
  const deny = (reason, user) => out({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason }, ...(user ? { systemMessage: user } : {}) });
3404
3503
  const allow = (reason) => out({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", permissionDecisionReason: reason } });
3405
3504
  try {
@@ -3416,6 +3515,8 @@ async function cmdHookPretool() {
3416
3515
  const command = typeof input.tool_input?.command === "string" ? input.tool_input.command : "";
3417
3516
  if (!command.trim())
3418
3517
  return 0;
3518
+ if (hookForgesPrompt(command))
3519
+ return deny("Decidio: an agent can't pause or resume Decidio's guard - only the person does, by typing \"decidio pause\" or \"decidio start\" themselves. Nothing ran. Tell the user if they want it paused.");
3419
3520
  if (hookSelfDecides(command))
3420
3521
  return deny("Decidio: an agent can't decide an approval - `approvals approve|reject` is the person's, and the person decides on their phone, the web app, Slack or Teams. Nothing ran. Tell the user which decision is waiting; never try another way to decide it.", "Decidio: the agent tried to decide an approval - refused");
3421
3522
  const segs = hookSegments(command);
@@ -3435,7 +3536,8 @@ async function cmdHookPretool() {
3435
3536
  const canonical = command.replace(/\s+/g, " ").trim();
3436
3537
  const base = hookSubject(rule.category, segs, cwdAbs, projectDir, at);
3437
3538
  const subject = followed ? { fingerprint: `${base.fingerprint ?? ""}\nfile:${followed.rel}:${followed.sha}`, display: followed.rel } : base;
3438
- g = { category: rule.category, isFile: false, text: canonical, binding: canonical + "\n" + (subject.fingerprint ?? ""), subject, identity: { command: canonical, subject: subject.fingerprint ?? "" }, segs,
3539
+ const offSwitch = rule.category === "decidio-guard" || hookEditsGuard(command, at);
3540
+ g = { category: rule.category, isFile: false, text: canonical, binding: canonical + "\n" + (subject.fingerprint ?? ""), subject, identity: { command: canonical, subject: subject.fingerprint ?? "" }, segs, offSwitch,
3439
3541
  ...(followed ? { scriptLine: hookRedact(`${followed.rel}:${followed.line}: ${followed.text.replace(/\s+/g, " ").trim()}`).slice(0, 160) } : {}) };
3440
3542
  }
3441
3543
  else if (tool === "Write" || tool === "Edit" || tool === "MultiEdit") {
@@ -3443,7 +3545,7 @@ async function cmdHookPretool() {
3443
3545
  if (!fc)
3444
3546
  return 0;
3445
3547
  const what = fc.changes.join("; ");
3446
- g = { category: "decidio-guard", isFile: true, text: `${tool} ${fc.display} — ${what}`, binding: `${tool}\n${fc.abs}\n${fc.sha}`, subject: { fingerprint: fc.sha, display: `${fc.display}: ${what}` }, identity: { path: fc.abs, after: fc.sha }, segs: null };
3548
+ g = { category: "decidio-guard", isFile: true, text: `${tool} ${fc.display} — ${what}`, binding: `${tool}\n${fc.abs}\n${fc.sha}`, subject: { fingerprint: fc.sha, display: `${fc.display}: ${what}` }, identity: { path: fc.abs, after: fc.sha }, segs: null, offSwitch: true };
3447
3549
  }
3448
3550
  else
3449
3551
  return 0;
@@ -3451,7 +3553,21 @@ async function cmdHookPretool() {
3451
3553
  ? { noun: "edit", run: "make", ran: "Nothing was written.", anew: "If the user wants a different change to this file, that is a new request and may be asked." }
3452
3554
  : { noun: "command", run: "run", ran: "Nothing ran.", anew: "If the user changes what it would act on (a new commit, a different path), that is a new request and may be asked." };
3453
3555
  const short = hookShort(hookRedact(g.text.replace(/\s+/g, " ").trim()));
3454
- const cfg = hookConfig();
3556
+ let cfg = hookConfig();
3557
+ if ("missing" in cfg) {
3558
+ const got = await hookProvision();
3559
+ if (!got.ok) {
3560
+ if (opts.unconnected === "tell" && !g.offSwitch) {
3561
+ const told = toldOnce(typeof input.session_id === "string" ? input.session_id : "", g.binding);
3562
+ if (told === "passed")
3563
+ return out({ systemMessage: `Decidio: "${short}" ran without Decidio's approval - this computer isn't connected, and this repository lets a computer that isn't connected through after one notice.` });
3564
+ if (told === "noted")
3565
+ return deny(`Decidio: "${short}" is a ${g.category} ${w.noun} that needs a person's approval - ${got.reason} ${w.ran} This repository lets a computer that isn't connected run it after this one notice: if you can't connect, tell the user it would run WITHOUT Decidio's approval, and retry the same ${w.noun} only if they want that.`, got.user);
3566
+ }
3567
+ return deny(`Decidio: "${short}" is a ${g.category} ${w.noun} that needs a person's approval - ${got.reason} ${w.ran}`, got.user);
3568
+ }
3569
+ cfg = hookConfig();
3570
+ }
3455
3571
  if ("missing" in cfg)
3456
3572
  return deny(`Decidio: "${short}" is a ${g.category} ${w.noun} that needs a person's approval, but this project is not connected to Decidio yet. ${w.ran} Run \`npx @decidio/sdk connect\` in ${projectDir} and show the user the link it prints (they allow it on the web), then run \`npx @decidio/sdk connect --wait\`; once it says connected, retry the same ${w.noun}.`, `Decidio: this project is not connected - run npx @decidio/sdk connect`);
3457
3573
  const rel = relative(projectDir, cwdAbs);
@@ -3464,6 +3580,13 @@ async function cmdHookPretool() {
3464
3580
  catch (e) {
3465
3581
  return deny(`Decidio hook: this ${w.noun} cannot be bound to an approval (${e?.message ?? e}) - refusing to ${w.run} it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
3466
3582
  }
3583
+ if (material && session && !g.offSwitch) {
3584
+ const paused = existsSync(pauseNote(session)) ? await guardPausedNow(cfg, guardSessionKey(material, session), g.category) : "on";
3585
+ if (paused === "on")
3586
+ notePause(session, false);
3587
+ if (paused === "paused")
3588
+ return out({ systemMessage: `Decidio guard is paused for this session: "${short}" ran without a person's approval (recorded). Type "decidio start" to resume.` });
3589
+ }
3467
3590
  if (!material)
3468
3591
  return deny(`Decidio hook: this ${w.noun} cannot be bound to an approval (no key material (DECIDIO_API_TOKEN, or DECIDIO_AGENT_KEY + DECIDIO_AGENT_DID)) - refusing to ${w.run} it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
3469
3592
  const committed = { command: hookRedact(g.text).slice(0, 400), cwd: cwdRel.slice(0, 200), category: g.category, host: "claude-code", tool, subject: hookRedact(g.subject.display).slice(0, 200), ...(g.scriptLine ? { scriptLine: g.scriptLine } : {}) };
@@ -3508,7 +3631,7 @@ async function cmdHookPretool() {
3508
3631
  if (auth.decision === "proceed") {
3509
3632
  writeHookState(input.tool_use_id, { v: 1, decisionId: auth.decisionId, receiptId: auth.receiptId ?? null, tool, category: g.category, at: new Date().toISOString() });
3510
3633
  err(`decidio hook: "${short}" is authorized by policy (decision ${auth.decisionId}${auth.receiptId ? `, receipt ${auth.receiptId}` : ""}, sealed) - no output, so Claude Code's own permission check still decides; this covers this one execution only.`);
3511
- return 0;
3634
+ return connectNotice ? out({}) : 0;
3512
3635
  }
3513
3636
  const holdRaw = Number(process.env.DECIDIO_HOOK_HOLD_MS ?? "90000");
3514
3637
  const held = await hookHold(cfg, auth.decisionId, Number.isFinite(holdRaw) && holdRaw >= 0 ? Math.min(holdRaw, HOOK_HOLD_CAP_MS) : 90_000);
@@ -3516,7 +3639,7 @@ async function cmdHookPretool() {
3516
3639
  if (!st)
3517
3640
  return deny(`Decidio: "${short}" is routed for approval as decision ${auth.decisionId}, but its status could not be read (${held.lastError ?? "no answer"}). ${w.ran} When the gate is reachable, run \`npx @decidio/sdk hook wait ${auth.decisionId}\` and then retry EXACTLY the same ${w.noun}.`);
3518
3641
  if (st.state === "pending")
3519
- return deny(`Decidio: "${short}" needs a person's approval - decision ${auth.decisionId} is in their queue${where ? ` (${where})` : ""}. ${w.ran} To continue: run \`npx @decidio/sdk hook wait ${auth.decisionId}\` (it returns when they decide), then retry EXACTLY the same ${w.noun} - not a variant of it, and do not work around this gate.`, `Decidio: ${auth.decisionId} awaits approval${where ? ` - ${where}` : ""}`);
3642
+ return deny(`Decidio: "${short}" needs a person's approval - decision ${auth.decisionId} is in ${CONNECTED_APPROVER ? `${CONNECTED_APPROVER}'s` : "their"} queue${where ? ` (${where})` : ""}; they see it on their phone, Slack, Teams or the web. ${w.ran} Tell the user it is waiting for ${CONNECTED_APPROVER ?? "the approver"}.${CONNECTED_UNANSWERED ? ` If nobody has said yet whether another person approves this computer's requests, tell the user: the workspace owner answers "Should another person approve this?" once on the Decidio Agents page${CONNECTED_APP_URL ? ` (${CONNECTED_APP_URL}/connect/agents#coding-assistants)` : ""}. A request made before the answer keeps it: its owner can't approve that one - another approver can, or ask again in a new session.` : ""} To continue: run \`npx @decidio/sdk hook wait ${auth.decisionId}\` (it returns when they decide), then retry EXACTLY the same ${w.noun} - not a variant of it, and do not work around this gate.`, `Decidio: waiting for ${CONNECTED_APPROVER ?? "a person"}'s approval (${auth.decisionId})${where ? ` - ${where}` : ""}. ${w.ran} Claude continues once it is approved.`);
3520
3643
  if (st.state === "rejected" || st.state === "blocked")
3521
3644
  return deny(`Decidio: "${short}" was ${st.state} (decision ${auth.decisionId}${st.reason ? `: ${st.reason}` : ""}) for this exact change (${hookRedact(g.subject.display)}). Do not retry it or a variant of it; tell the user. ${g.subject.fingerprint ? w.anew : ""}`.trim(), `Decidio: ${auth.decisionId} ${st.state}`);
3522
3645
  const chk = await checkApproval(cfg, auth.decisionId, request, { status: st });
@@ -3675,10 +3798,10 @@ function cmdHookRules() {
3675
3798
  log(`A policy that auto-approves a request gets no output from the hook, so Claude Code's own permission check still decides; only a person's approval of the exact call answers "allow".`);
3676
3799
  return 0;
3677
3800
  }
3678
- function hookRunner() {
3801
+ function hookRunner(portable = false) {
3679
3802
  const q = (p) => `"${p.replace(/(["\\$`])/g, "\\$1")}"`;
3680
3803
  const entry = process.argv[1] ?? "";
3681
- if (!entry || /[\\/]_npx[\\/]/.test(entry))
3804
+ if (portable || !entry || /[\\/]_npx[\\/]/.test(entry))
3682
3805
  return `npx -y @decidio/sdk@${VERSION}`;
3683
3806
  let real = entry;
3684
3807
  try {
@@ -3687,12 +3810,13 @@ function hookRunner() {
3687
3810
  catch { }
3688
3811
  const localRoot = resolvePath("node_modules") + pathSep;
3689
3812
  if (real.startsWith(localRoot))
3690
- real = "$CLAUDE_PROJECT_DIR/node_modules/" + real.slice(localRoot.length).split(pathSep).join("/");
3813
+ return `node "$CLAUDE_PROJECT_DIR/node_modules/${real.slice(localRoot.length).split(pathSep).join("/").replace(/(["\\$`])/g, "\\$1")}"`;
3691
3814
  real = real.split(pathSep).join("/");
3692
3815
  if (/\.ts$/.test(real))
3693
3816
  return `npx tsx ${q(real)}`;
3694
3817
  return `node ${q(real)}`;
3695
3818
  }
3819
+ const hookPromptLine = (runner) => `cd "$CLAUDE_PROJECT_DIR" && IN=$(cat) && case "$IN" in *[Dd][Ee][Cc][Ii][Dd][Ii][Oo]*) printf '%s' "$IN" | ${runner} hook prompt;; esac`;
3696
3820
  const HOOK_FILE_TOOLS = ["Write", "Edit", "MultiEdit"];
3697
3821
  const HOOK_GUARD_PATHS = ["**/.claude/settings*.json", "~/.claude/settings*.json", "~/.claude/plugins/**"];
3698
3822
  async function cmdHookInstall(opts) {
@@ -3716,14 +3840,14 @@ async function cmdHookInstall(opts) {
3716
3840
  return 2;
3717
3841
  }
3718
3842
  const cliAt = opts.cli ? resolvePath(opts.cli).split(pathSep).join("/") : "";
3719
- const runner = opts.cli ? (/\.ts$/.test(opts.cli) ? `npx tsx "${cliAt}"` : `node "${cliAt}"`) : hookRunner();
3843
+ const runner = opts.cli ? (/\.ts$/.test(opts.cli) ? `npx tsx "${cliAt}"` : `node "${cliAt}"`) : hookRunner(opts.portable === true);
3720
3844
  const hooks = (settings.hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {});
3721
3845
  const place = (event, matcher, iff, command, extra) => {
3722
3846
  const list = Array.isArray(hooks[event]) ? hooks[event] : (hooks[event] = []);
3723
3847
  const want = { type: "command", ...(iff ? { if: iff } : {}), command, ...extra };
3724
3848
  let state = "added";
3725
3849
  for (const g of list) {
3726
- if (!g || typeof g !== "object" || g.matcher !== matcher || !Array.isArray(g.hooks))
3850
+ if (!g || typeof g !== "object" || (g.matcher ?? "") !== matcher || !Array.isArray(g.hooks))
3727
3851
  continue;
3728
3852
  g.hooks = g.hooks.flatMap((h) => {
3729
3853
  if (!hookIsOurs(h) || (typeof h.if === "string" ? h.if : undefined) !== iff)
@@ -3738,12 +3862,14 @@ async function cmdHookInstall(opts) {
3738
3862
  }
3739
3863
  hooks[event] = list.filter((g) => !(g && typeof g === "object" && Array.isArray(g.hooks) && g.hooks.length === 0));
3740
3864
  if (state === "added")
3741
- hooks[event].push({ matcher, hooks: [want] });
3865
+ hooks[event].push(matcher ? { matcher, hooks: [want] } : { hooks: [want] });
3742
3866
  return state;
3743
3867
  };
3744
- const preLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook pretool`, postLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook posttool`;
3868
+ const preLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook pretool${opts.unconnected === "tell" ? " --unconnected=tell" : ""}`, postLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook posttool`;
3745
3869
  const pre = place("PreToolUse", "Bash|PowerShell", undefined, preLine, { timeout: 150, onFailure: "block" });
3746
3870
  const post = place("PostToolUse", "Bash|PowerShell", undefined, postLine, { timeout: 30 });
3871
+ place("SessionStart", "startup|resume", undefined, `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook session`, { timeout: 15 });
3872
+ place("UserPromptSubmit", "", undefined, hookPromptLine(runner), { timeout: 15 });
3747
3873
  const guard = { added: 0, updated: 0, kept: 0 };
3748
3874
  for (const t of HOOK_FILE_TOOLS)
3749
3875
  for (const p of HOOK_GUARD_PATHS) {
@@ -3751,7 +3877,9 @@ async function cmdHookInstall(opts) {
3751
3877
  guard[place("PostToolUse", t, `${t}(${p})`, postLine, { timeout: 30 })]++;
3752
3878
  }
3753
3879
  mkdirSync(dirname(settingsPath), { recursive: true });
3754
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
3880
+ const target = realOrSame(settingsPath), tmpSettings = `${target}.decidio-${process.pid}.tmp`;
3881
+ writeFileSync(tmpSettings, JSON.stringify(settings, null, 2) + "\n", { mode: existsSync(target) ? statSync(target).mode & 0o777 : 0o644 });
3882
+ renameSync(tmpSettings, target);
3755
3883
  lastHookInstall = { settingsPath, pre, post };
3756
3884
  if (opts.quiet)
3757
3885
  return 0;
@@ -3795,7 +3923,7 @@ function projectRoot() {
3795
3923
  catch { }
3796
3924
  return realOrSame(SHELL_ENV.CLAUDE_PROJECT_DIR || process.cwd());
3797
3925
  }
3798
- function connectionKey(root) { return createHash("sha256").update(`${API}\n${process.platform === "win32" ? root.toLowerCase() : root}`, "utf8").digest("hex").slice(0, 20); }
3926
+ function connectionKey(root) { return createHash("sha256").update(`${API}\n${root === COMPUTER ? "computer" : process.platform === "win32" ? root.toLowerCase() : root}`, "utf8").digest("hex").slice(0, 20); }
3799
3927
  function connectionFile(root) { return joinPath(connectDir(), `${connectionKey(root)}.json`); }
3800
3928
  function pendingConnectFile(root) { return joinPath(connectDir(), `${connectionKey(root)}.pending.json`); }
3801
3929
  function readJsonQuiet(f) {
@@ -3806,6 +3934,8 @@ function readJsonQuiet(f) {
3806
3934
  return null;
3807
3935
  }
3808
3936
  }
3937
+ function activeConnection(root, api) { return loadConnection(root, api) ?? loadConnection(COMPUTER, api); }
3938
+ const connectScope = (root) => (connectionRecord(root, API) ? root : COMPUTER);
3809
3939
  function loadConnection(root, api) {
3810
3940
  const c = connectionRecord(root, api);
3811
3941
  return c && !c.disconnected && c.token ? c : null;
@@ -3814,7 +3944,15 @@ function connectionRecord(root, api) {
3814
3944
  const c = readJsonQuiet(connectionFile(root));
3815
3945
  return c && c.v === 1 && c.api === api && c.root === root && typeof c.token === "string" && typeof c.agentId === "string" && typeof c.key === "string" && typeof c.did === "string" ? c : null;
3816
3946
  }
3947
+ function printableName(v) {
3948
+ return typeof v === "string" ? v.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/g, "").trim().slice(0, 60) || null : null;
3949
+ }
3817
3950
  function promoteConnection(c) {
3951
+ CONNECTED_APPROVER = printableName(c.approver);
3952
+ CONNECTED_UNANSWERED = c.answered === false;
3953
+ CONNECTED_APP_URL = typeof c.appUrl === "string" && /^https?:\/\/[^\s"<>]+$/.test(c.appUrl) ? c.appUrl.replace(/\/+$/, "") : null;
3954
+ CONNECTED_WORKSPACE = printableName(c.workspaceName);
3955
+ GUARD_PAUSABLE = c.guardPausable !== false;
3818
3956
  process.env.DECIDIO_API_TOKEN = c.token;
3819
3957
  process.env.DECIDIO_AGENT_ID = c.agentId;
3820
3958
  process.env.DECIDIO_AGENT_DID = c.did;
@@ -3889,29 +4027,24 @@ function openInBrowser(url) {
3889
4027
  }
3890
4028
  const CONNECT_WAIT_S = () => { const n = Number(process.env.DECIDIO_CONNECT_WAIT_S ?? 90); return Number.isFinite(n) && n >= 1 && n <= 110 ? n : 90; };
3891
4029
  let lastHookInstall = null;
3892
- async function startConnect(root, host) {
4030
+ let connectError = "";
4031
+ async function startConnect(root, host, quiet = false) {
4032
+ const fail = (m) => { connectError = m; if (!quiet)
4033
+ err(`✘ ${m}`); return null; };
3893
4034
  const prior = connectionRecord(root, API);
3894
4035
  const kp = prior && !inCloudSession() ? { did: prior.did, privateKeyBase64: prior.key } : generateKeypair();
3895
4036
  const cloud = inCloudSession();
3896
- const project = projectLabel(root).trim() || "project", machine = machineLabel(cloud).trim() || "machine";
4037
+ const project = (root === COMPUTER ? EVERY_PROJECT : projectLabel(root)).trim() || "project", machine = machineLabel(cloud).trim() || "machine";
3897
4038
  const proof = signBoundProof(kp.privateKeyBase64, { agentId: "connect", did: kp.did, action: "connect.start", amount: 0, scope: [host, project, machine, cloud ? "cloud" : "local"].join("\n") });
3898
4039
  const r = await api("/connect/start", { method: "POST", body: JSON.stringify({ host, project, machine, did: kp.did, cloud, proof }) }, "", true);
3899
- if (r.status === 0) {
3900
- err(`✘ couldn't reach Decidio at ${API} - check your connection and run npx @decidio/sdk connect again.`);
3901
- return null;
3902
- }
3903
- if (r.status === 404 || r.status === 401) {
3904
- err(`✘ ${r.body?.message ?? "Connecting a coding assistant is available in the Decidio lab only."} (${API})`);
3905
- return null;
3906
- }
3907
- if (r.status === 429) {
3908
- err(`✘ ${r.body?.message ?? "Too many tries - wait a minute and try again."}`);
3909
- return null;
3910
- }
3911
- if (r.status !== 201 || typeof r.body?.pollSecret !== "string" || typeof r.body?.link !== "string") {
3912
- err(`✘ couldn't start a connection (${r.status}${r.body?.message ? `: ${r.body.message}` : ""}). Nothing was saved.`);
3913
- return null;
3914
- }
4040
+ if (r.status === 0)
4041
+ return fail(`couldn't reach Decidio at ${API} - check your connection and run npx @decidio/sdk connect again.`);
4042
+ if (r.status === 404 || r.status === 401)
4043
+ return fail(`${r.body?.message ?? "Connecting a coding assistant isn't switched on for this Decidio yet - it is on in the Decidio lab."} (${API})`);
4044
+ if (r.status === 429)
4045
+ return fail(`${r.body?.message ?? "Too many tries - wait a minute and try again."}`);
4046
+ if (r.status !== 201 || typeof r.body?.pollSecret !== "string" || typeof r.body?.link !== "string")
4047
+ return fail(`couldn't start a connection (${r.status}${r.body?.message ? `: ${r.body.message}` : ""}). Nothing was saved.`);
3915
4048
  const p = { v: 1, api: API, root, project, host, machine, cloud, did: kp.did, key: kp.privateKeyBase64, code: String(r.body.code), link: String(r.body.link),
3916
4049
  pollSecret: r.body.pollSecret, expiresAt: String(r.body.expiresAt), interval: Number(r.body.interval) || 2, startedAt: new Date().toISOString() };
3917
4050
  mkdirSync(connectDir(), { recursive: true });
@@ -3925,6 +4058,170 @@ function readPendingConnect(root) {
3925
4058
  const p = readJsonQuiet(pendingConnectFile(root));
3926
4059
  return p && p.v === 1 && p.api === API && p.root === root && typeof p.pollSecret === "string" ? p : null;
3927
4060
  }
4061
+ async function hookProvision() {
4062
+ const scope = COMPUTER;
4063
+ if (process.env.DECIDIO_AGENT_ID) {
4064
+ const named = printableName(process.env.DECIDIO_AGENT_ID) ?? "(unprintable)";
4065
+ return { ok: false, reason: `this project names the Decidio agent "${named}" (in its .env or the shell) but has no token for it. Ask the user to check it with npx @decidio/sdk doctor, or to remove DECIDIO_AGENT_ID to use this computer's connection.`, user: `Decidio: "${named}" has no token here` };
4066
+ }
4067
+ const own = (SHELL_ENV.DECIDIO_API_URL ?? DEFAULT_API).replace(/\/$/, "");
4068
+ if (API !== own) {
4069
+ const host = printableName(redactUrl(API)) ?? "(unprintable)";
4070
+ return { ok: false, reason: `this project's .env points Decidio at ${host}; the hook won't connect this computer to a server a repository names. If the user trusts it, they run npx @decidio/sdk connect themselves.`, user: `Decidio: not connecting to ${host} (named by this project)` };
4071
+ }
4072
+ try {
4073
+ assertSafeTransport(API, true);
4074
+ }
4075
+ catch (e) {
4076
+ return { ok: false, reason: `${String(e?.message ?? e).replace(/^\[decidio\] /, "")} - the connection's secret would cross in clear.`, user: "Decidio: refusing a plaintext connection" };
4077
+ }
4078
+ const linkWords = (p, fresh) => {
4079
+ const mins = minutesLeft(p.expiresAt) || 1;
4080
+ return { ok: false,
4081
+ reason: `this computer isn't connected to Decidio yet${fresh ? "" : " (its link is still waiting to be allowed)"}. If Decidio's connect_device tool is available to you (the Decidio connector), tell the user you are connecting this computer to their Decidio workspace, call connect_device with the code ${p.code}, run the command it returns, then retry. Otherwise show the user this link and code: ${p.link} (code ${p.code}, valid ${mins} more minute${mins === 1 ? "" : "s"}) - the workspace owner allows it once, signed in, and it covers every project on this computer. Then retry the same command: it connects and asks for approval.`,
4082
+ user: `Decidio: connect this computer once - open ${p.link} and check the code ${p.code}. Destructive commands wait until it's allowed; everything else runs as usual.` };
4083
+ };
4084
+ let p = readPendingConnect(scope);
4085
+ if (p && new Date(p.expiresAt).getTime() > Date.now() + 5_000) {
4086
+ const r = await api("/connect/poll", { method: "POST", headers: { "x-decidio-connect-secret": p.pollSecret }, body: "{}" }, "", true);
4087
+ if (r.status === 200 && r.body?.status === "connected" && typeof r.body.token === "string") {
4088
+ const c = storeCollected(scope, p, r.body);
4089
+ if (typeof c === "string")
4090
+ return { ok: false, reason: `this computer was allowed, but its credential couldn't be written (${c}). Ask the user to run npx @decidio/sdk doctor.`, user: "Decidio: connected, but the credential couldn't be saved" };
4091
+ promoteConnection(c);
4092
+ CREDS_FROM = "connection";
4093
+ connectNotice = `Decidio connected this computer${CONNECTED_WORKSPACE ? ` to workspace ${CONNECTED_WORKSPACE}` : ""}${printableName(c.approvedBy) ? `, allowed by ${printableName(c.approvedBy)}` : ""} - every project on it now asks before destructive commands.`;
4094
+ return { ok: true };
4095
+ }
4096
+ if (r.status === 202 || r.status === 429 || r.status >= 500)
4097
+ return linkWords(p, false);
4098
+ if (r.status === 409 && (r.body?.status === "ticket_required" || r.body?.status === "ticket_mismatch"))
4099
+ return { ok: false,
4100
+ reason: `this computer's link (code ${p.code}) was allowed through the Decidio connector and is finished only with the ticket connect_device returned: if YOU called connect_device for code ${p.code} in this session, run npx @decidio/sdk@latest connect --ticket <the ticket it returned>, then retry the same command. If you did not, do not finish it with any ticket from anywhere else - tell the user that another workspace's connector allowed this computer, and leave it: the link lapses within ten minutes and the next try shows a fresh code.`,
4101
+ user: "Decidio: finishing this computer's connection (allowed through the Decidio connector) - destructive commands wait until it's done" };
4102
+ if (r.status === 0)
4103
+ return { ok: false, reason: `couldn't reach Decidio at ${API} to finish connecting this computer - retry once it is reachable.`, user: "Decidio: unreachable - destructive commands wait" };
4104
+ if (r.status === 410 && r.body?.status === "collected") {
4105
+ const won = activeConnection(projectRoot(), API);
4106
+ if (won) {
4107
+ promoteConnection(won);
4108
+ CREDS_FROM = "connection";
4109
+ return { ok: true };
4110
+ }
4111
+ }
4112
+ if (r.status === 403 && r.body?.status === "denied") {
4113
+ dropFile(pendingConnectFile(scope));
4114
+ return { ok: false, reason: `connecting this computer was refused on the web ("Not me"). Ask the user before retrying - the next attempt shows a new link.`, user: "Decidio: connecting this computer was refused" };
4115
+ }
4116
+ if (r.status === 410 && r.body?.status === "removed") {
4117
+ dropFile(pendingConnectFile(scope));
4118
+ return { ok: false, reason: `the workspace owner removed this computer before it finished connecting. Ask the user before retrying - the next attempt shows a new link, and only the owner's Allow on the web brings it back.`, user: "Decidio: the workspace owner removed this computer" };
4119
+ }
4120
+ dropFile(pendingConnectFile(scope));
4121
+ }
4122
+ const lock = pendingConnectFile(scope) + ".lock";
4123
+ mkdirSync(connectDir(), { recursive: true });
4124
+ let fd = null;
4125
+ try {
4126
+ fd = openSync(lock, "wx", 0o600);
4127
+ }
4128
+ catch {
4129
+ try {
4130
+ if (Date.now() - statSync(lock).mtimeMs > 30_000) {
4131
+ rmSync(lock, { force: true });
4132
+ fd = openSync(lock, "wx", 0o600);
4133
+ }
4134
+ }
4135
+ catch {
4136
+ fd = null;
4137
+ }
4138
+ }
4139
+ if (fd !== null && (p = readPendingConnect(scope)) && new Date(p.expiresAt).getTime() > Date.now() + 5_000) {
4140
+ try {
4141
+ closeSync(fd);
4142
+ }
4143
+ catch { }
4144
+ dropFile(lock);
4145
+ return linkWords(p, false);
4146
+ }
4147
+ if (fd === null) {
4148
+ for (let i = 0; i < 20 && !(p = readPendingConnect(scope)); i++)
4149
+ await new Promise((res) => setTimeout(res, 250));
4150
+ if (p)
4151
+ return linkWords(p, false);
4152
+ return { ok: false, reason: "another Decidio check on this computer is connecting it right now - retry the same command in a few seconds.", user: "Decidio: connecting this computer" };
4153
+ }
4154
+ try {
4155
+ const started = await startConnect(scope, "claude-code", true);
4156
+ if (!started)
4157
+ return { ok: false, reason: `this computer isn't connected to Decidio, and a connection couldn't be started: ${connectError}`, user: "Decidio: this computer isn't connected" };
4158
+ return linkWords(started, true);
4159
+ }
4160
+ finally {
4161
+ try {
4162
+ closeSync(fd);
4163
+ }
4164
+ catch { }
4165
+ dropFile(lock);
4166
+ }
4167
+ }
4168
+ function toldOnce(session, binding) {
4169
+ if (!session)
4170
+ return "hold";
4171
+ const dir = joinPath(connectDir(), "told");
4172
+ const f = joinPath(dir, createHash("sha256").update(`${API}\n${session}\n${binding}`).digest("hex").slice(0, 40));
4173
+ try {
4174
+ if (existsSync(f))
4175
+ return "passed";
4176
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
4177
+ const week = Date.now() - 7 * 86_400_000;
4178
+ for (const old of readdirSync(dir)) {
4179
+ try {
4180
+ if (statSync(joinPath(dir, old)).mtimeMs < week)
4181
+ rmSync(joinPath(dir, old), { force: true });
4182
+ }
4183
+ catch { }
4184
+ }
4185
+ writeFileSync(f, "", { mode: 0o600, flag: "wx" });
4186
+ return "noted";
4187
+ }
4188
+ catch {
4189
+ return "hold";
4190
+ }
4191
+ }
4192
+ async function guardPausedNow(cfg, sessionKey, category) {
4193
+ try {
4194
+ assertSafeTransport(cfg.apiUrl, true);
4195
+ const r = await fetch(cfg.apiUrl.replace(/\/$/, "") + "/agent/guard/check", { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiToken}` },
4196
+ body: JSON.stringify({ session: sessionKey, category }), signal: AbortSignal.timeout(10_000) });
4197
+ const body = await r.json().catch(() => null);
4198
+ return r.ok && body?.paused === true ? "paused" : r.ok && body?.paused === false ? "on" : "unknown";
4199
+ }
4200
+ catch {
4201
+ return "unknown";
4202
+ }
4203
+ }
4204
+ function pauseNote(session) {
4205
+ return joinPath(connectDir(), "paused", createHash("sha256").update(`${API}\n${session}`).digest("hex").slice(0, 40));
4206
+ }
4207
+ function notePause(session, paused) {
4208
+ const f = pauseNote(session);
4209
+ try {
4210
+ if (!paused)
4211
+ return dropFile(f);
4212
+ const dir = dirname(f);
4213
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
4214
+ for (const old of readdirSync(dir)) {
4215
+ try {
4216
+ if (statSync(joinPath(dir, old)).mtimeMs < Date.now() - GUARD_PAUSE_HOURS * 3_600_000)
4217
+ rmSync(joinPath(dir, old), { force: true });
4218
+ }
4219
+ catch { }
4220
+ }
4221
+ writeFileSync(f, "", { mode: 0o600 });
4222
+ }
4223
+ catch { }
4224
+ }
3928
4225
  function dropFile(f) { try {
3929
4226
  rmSync(f, { force: true });
3930
4227
  }
@@ -3950,6 +4247,14 @@ async function cmdConnect(opts) {
3950
4247
  err(`✘ connect knows Claude Code today (--host claude-code); "${opts.host}" is not supported yet.`);
3951
4248
  return 2;
3952
4249
  }
4250
+ if (opts.ticket !== undefined && (opts.wait || opts.team || opts.force || opts.holdEveryone)) {
4251
+ err("✘ --ticket finishes a link the Decidio connector allowed and takes no other option.");
4252
+ return 2;
4253
+ }
4254
+ if (opts.holdEveryone && !opts.team && !inCloudSession()) {
4255
+ err("✘ --hold-everyone is for the shared file (--team, or a cloud session): it keeps refusing a computer that isn't connected.");
4256
+ return 2;
4257
+ }
3953
4258
  try {
3954
4259
  assertSafeTransport(API, true);
3955
4260
  }
@@ -3958,37 +4263,122 @@ async function cmdConnect(opts) {
3958
4263
  return 1;
3959
4264
  }
3960
4265
  const root = projectRoot();
4266
+ if (opts.ticket !== undefined)
4267
+ return connectWithTicket(root, opts.ticket);
3961
4268
  const tty = !!(process.stdin.isTTY && process.stdout.isTTY);
3962
4269
  if (CREDS_FROM === "shell" || CREDS_FROM === ".env") {
3963
4270
  log(`• this project already has Decidio credentials ${CREDS_FROM === "shell" ? "from your shell" : `in ${ENV_PATH}`} for "${process.env.DECIDIO_AGENT_ID ?? defaultAgentId()}" - the hook uses those, so connect leaves them alone.`);
3964
4271
  log(dim(` To use a connection instead, remove DECIDIO_API_TOKEN / DECIDIO_AGENT_ID from ${CREDS_FROM === "shell" ? "your shell" : ENV_PATH} and run npx @decidio/sdk connect again.`));
3965
4272
  return finishConnect(root, null, opts, false);
3966
4273
  }
3967
- const existing = loadConnection(root, API);
4274
+ const existing = activeConnection(root, API);
3968
4275
  if (existing && !opts.force && new Date(existing.expiresAt).getTime() > Date.now())
3969
4276
  return finishConnect(root, existing, opts, false);
4277
+ const scope = connectScope(root);
3970
4278
  if (opts.wait)
3971
- return waitConnect(root, opts, false);
3972
- let p = readPendingConnect(root);
4279
+ return waitConnect(root, scope, opts, false);
4280
+ let p = readPendingConnect(scope);
3973
4281
  if (!p || p.host !== host || new Date(p.expiresAt).getTime() <= Date.now() + 15_000)
3974
- p = await startConnect(root, host);
4282
+ p = await startConnect(scope, host);
3975
4283
  if (!p)
3976
4284
  return 1;
3977
4285
  const opened = !opts.noBrowser && localDesktop() && openInBrowser(p.link);
3978
4286
  showConnectLink(p, opened, tty);
3979
4287
  if (tty) {
3980
4288
  log("");
3981
- return waitConnect(root, opts, true);
4289
+ return waitConnect(root, scope, opts, true);
3982
4290
  }
3983
4291
  log("");
3984
4292
  log(`Next: allow it at the link above, then run ${bold("npx @decidio/sdk connect --wait")} - it finishes by itself (nothing else to type).`);
3985
4293
  log(dim("Assistants: show the person the link and the code above, then run npx @decidio/sdk connect --wait."));
3986
4294
  return 0;
3987
4295
  }
3988
- async function waitConnect(root, opts, interactive) {
3989
- const p = readPendingConnect(root);
4296
+ async function connectWithTicket(root, ticket) {
4297
+ if (!/^[A-Za-z0-9_-]{32,128}$/.test(ticket)) {
4298
+ err("✘ --ticket needs the ticket connect_device returned (letters, digits, - and _).");
4299
+ return 2;
4300
+ }
4301
+ const scope = connectScope(root);
4302
+ const p = readPendingConnect(scope);
4303
+ if (!p || new Date(p.expiresAt).getTime() <= Date.now()) {
4304
+ const c = activeConnection(root, API);
4305
+ if (c) {
4306
+ log(`✔ This computer is already connected to Decidio${printableName(c.workspaceName) ? ` - workspace ${printableName(c.workspaceName)}` : ""}. Retry the command.`);
4307
+ return 0;
4308
+ }
4309
+ err("✘ this computer has no link waiting to be finished - retry the command Decidio refused (it shows a fresh code), then call connect_device with that code.");
4310
+ return 1;
4311
+ }
4312
+ const r = await api("/connect/poll", { method: "POST", headers: { "x-decidio-connect-secret": p.pollSecret, "x-decidio-connect-ticket": ticket }, body: "{}" }, "", true);
4313
+ if (r.status === 200 && r.body?.status === "connected" && typeof r.body.token === "string") {
4314
+ const c = storeCollected(scope, p, r.body);
4315
+ if (typeof c === "string") {
4316
+ err(`✘ connected, but this computer's credential couldn't be written (${c}) - run npx @decidio/sdk doctor.`);
4317
+ return 1;
4318
+ }
4319
+ const ws = printableName(c.workspaceName), approver = printableName(c.approver);
4320
+ log(`✔ Connected this computer to Decidio${ws ? ` - workspace ${ws}` : ""}, through the Decidio connector.`);
4321
+ log(`✔ Retry the command Decidio refused: it now asks ${approver ?? "the approver"} before it runs.`);
4322
+ if (r.body.trust === "unconfirmed")
4323
+ log(dim(" Until the workspace owner confirms this computer is theirs (Decidio sent them a notification), every destructive command it asks about goes to a person."));
4324
+ else if (r.body.trust === "cloud")
4325
+ log(dim(" This cloud machine always asks a person for its destructive commands."));
4326
+ return 0;
4327
+ }
4328
+ if (r.status === 202) {
4329
+ err(`✘ this computer's link (code ${p.code}) hasn't been allowed yet - call connect_device with that code first, then run this again with the ticket it returns.`);
4330
+ return 3;
4331
+ }
4332
+ if (r.status === 409 && r.body?.status === "ticket_mismatch") {
4333
+ err(`✘ that ticket is for another link - call connect_device with this computer's code (${p.code}) and use the ticket it returns.`);
4334
+ return 3;
4335
+ }
4336
+ if (r.status === 403 && r.body?.status === "denied") {
4337
+ dropFile(pendingConnectFile(scope));
4338
+ err("✘ this computer's link was refused on the web (Not me). Nothing was saved.");
4339
+ return 3;
4340
+ }
4341
+ if (r.status === 410 && r.body?.status === "removed") {
4342
+ dropFile(pendingConnectFile(scope));
4343
+ err("✘ the workspace owner removed this computer before it finished connecting. Nothing was saved - ask them before trying again.");
4344
+ return 3;
4345
+ }
4346
+ if ((r.status === 404 || r.status === 410) && (r.body?.status === "expired" || r.body?.status === "collected")) {
4347
+ const won = r.body.status === "collected" ? activeConnection(root, API) : null;
4348
+ if (won) {
4349
+ log("✔ This computer is connected (another run finished it first). Retry the command.");
4350
+ return 0;
4351
+ }
4352
+ dropFile(pendingConnectFile(scope));
4353
+ err("✘ this computer's link expired before it was finished - retry the command Decidio refused for a fresh code, then call connect_device again.");
4354
+ return 3;
4355
+ }
4356
+ if (r.status === 0) {
4357
+ err(`✘ couldn't reach Decidio at ${API} - check the connection and run this again (the ticket stays good for ten minutes).`);
4358
+ return 1;
4359
+ }
4360
+ err(`✘ Decidio answered ${r.status}${r.body?.message ? `: ${String(r.body.message).slice(0, 200)}` : ""}`);
4361
+ return 1;
4362
+ }
4363
+ function storeCollected(scope, p, body) {
4364
+ const c = { v: 1, api: API, root: scope, project: p.project, host: p.host, machine: p.machine, cloud: p.cloud, agentId: String(body.agentId), did: p.did, key: p.key,
4365
+ token: body.token, jti: String(body.jti), expiresAt: String(body.expiresAt), workspaceId: String(body.workspaceId), workspaceName: body.workspace?.name ?? null,
4366
+ approver: body.approver?.name ?? null, approvedBy: body.approvedBy?.name ?? null, appUrl: typeof body.appUrl === "string" ? body.appUrl : null, connectedAt: new Date().toISOString(),
4367
+ guardPausable: body.guardPausable !== false, ...(body.answered === false ? { answered: false } : {}) };
4368
+ mkdirSync(connectDir(), { recursive: true });
4369
+ try {
4370
+ writeSecretFile(connectionFile(scope), JSON.stringify(c) + "\n");
4371
+ }
4372
+ catch (e) {
4373
+ return String(e?.message ?? e);
4374
+ }
4375
+ dropFile(pendingConnectFile(scope));
4376
+ return c;
4377
+ }
4378
+ async function waitConnect(root, scope, opts, interactive) {
4379
+ const p = readPendingConnect(scope);
3990
4380
  if (!p) {
3991
- const c = loadConnection(root, API);
4381
+ const c = activeConnection(root, API);
3992
4382
  if (c)
3993
4383
  return finishConnect(root, c, opts, false);
3994
4384
  err("✘ no connection is in progress for this project - start one: npx @decidio/sdk connect");
@@ -4004,29 +4394,41 @@ async function waitConnect(root, opts, interactive) {
4004
4394
  if (r.status === 200 && r.body?.status === "connected" && typeof r.body.token === "string") {
4005
4395
  if (interactive && TTY_OUT)
4006
4396
  process.stderr.write("\r\x1b[K");
4007
- const c = { v: 1, api: API, root, project: p.project, host: p.host, machine: p.machine, cloud: p.cloud, agentId: String(r.body.agentId), did: p.did, key: p.key,
4008
- token: r.body.token, jti: String(r.body.jti), expiresAt: String(r.body.expiresAt), workspaceId: String(r.body.workspaceId), workspaceName: r.body.workspace?.name ?? null,
4009
- approver: r.body.approver?.name ?? null, approvedBy: r.body.approvedBy?.name ?? null, appUrl: typeof r.body.appUrl === "string" ? r.body.appUrl : null, connectedAt: new Date().toISOString() };
4010
- mkdirSync(connectDir(), { recursive: true });
4011
- try {
4012
- writeSecretFile(connectionFile(root), JSON.stringify(c) + "\n");
4013
- }
4014
- catch (e) {
4015
- err(`✘ connected, but this machine's credentials could not be written (${e?.message ?? e}) - run npx @decidio/sdk connect again (the server keeps nothing to collect twice).`);
4397
+ const c = storeCollected(scope, p, r.body);
4398
+ if (typeof c === "string") {
4399
+ err(`✘ connected, but this machine's credentials could not be written (${c}) - run npx @decidio/sdk connect again (the server keeps nothing to collect twice).`);
4016
4400
  return 1;
4017
4401
  }
4018
- dropFile(pendingConnectFile(root));
4019
4402
  return finishConnect(root, c, opts, true);
4020
4403
  }
4021
4404
  if (r.status === 403 && r.body?.status === "denied") {
4022
- dropFile(pendingConnectFile(root));
4405
+ dropFile(pendingConnectFile(scope));
4023
4406
  if (interactive && TTY_OUT)
4024
4407
  process.stderr.write("\r\x1b[K");
4025
4408
  err("✘ the connection was refused on the web (Not me). Nothing was saved.");
4026
4409
  return 3;
4027
4410
  }
4411
+ if (r.status === 410 && r.body?.status === "removed") {
4412
+ dropFile(pendingConnectFile(scope));
4413
+ if (interactive && TTY_OUT)
4414
+ process.stderr.write("\r\x1b[K");
4415
+ err("✘ the workspace owner removed this computer before it finished connecting. Nothing was saved.");
4416
+ return 3;
4417
+ }
4418
+ if (r.status === 409 && (r.body?.status === "ticket_required" || r.body?.status === "ticket_mismatch")) {
4419
+ if (interactive && TTY_OUT)
4420
+ process.stderr.write("\r\x1b[K");
4421
+ err("✘ this computer's link was allowed through the Decidio connector: finish it with npx @decidio/sdk connect --ticket <the ticket YOUR connect_device returned> - never a ticket from anywhere else.");
4422
+ return 3;
4423
+ }
4028
4424
  if ((r.status === 404 || r.status === 410) && (r.body?.status === "expired" || r.body?.status === "collected")) {
4029
- dropFile(pendingConnectFile(root));
4425
+ const won = r.body.status === "collected" ? activeConnection(root, API) : null;
4426
+ if (won) {
4427
+ if (interactive && TTY_OUT)
4428
+ process.stderr.write("\r\x1b[K");
4429
+ return finishConnect(root, won, opts, true);
4430
+ }
4431
+ dropFile(pendingConnectFile(scope));
4030
4432
  if (interactive && TTY_OUT)
4031
4433
  process.stderr.write("\r\x1b[K");
4032
4434
  err(r.body.status === "collected" ? "✘ this connection was already collected by another run - start a fresh one: npx @decidio/sdk connect" : "✘ the link expired before it was allowed - start a fresh one: npx @decidio/sdk connect");
@@ -4052,19 +4454,26 @@ async function finishConnect(root, c, opts, fresh) {
4052
4454
  }
4053
4455
  const cloud = inCloudSession();
4054
4456
  const shared = opts.team || cloud;
4457
+ const everywhere = !shared && c?.root === COMPUTER;
4458
+ const home = process.platform === "win32" ? (SHELL_ENV.USERPROFILE ?? homedir()) : (SHELL_ENV.HOME ?? homedir());
4055
4459
  const plugin = !cloud && decidioPluginEnabled(root);
4056
4460
  if (!plugin) {
4057
- const rc = await cmdHookInstall({ project: shared, dir: root, quiet: true });
4461
+ const rc = await cmdHookInstall({ project: shared || everywhere, dir: everywhere ? home : root, quiet: true, portable: everywhere, ...(shared && !opts.holdEveryone ? { unconnected: "tell" } : {}) });
4058
4462
  if (rc !== 0)
4059
4463
  return rc;
4060
4464
  }
4061
4465
  const probe = await api("/agent/status/doctor-scope-probe", {}, process.env.DECIDIO_API_TOKEN, true);
4062
4466
  const verified = probe.status === 404;
4063
- const settings = lastHookInstall ? relative(root, lastHookInstall.settingsPath).split(pathSep).join("/") || lastHookInstall.settingsPath : ".claude/settings.json";
4467
+ const settings = everywhere ? "~/.claude/settings.json" : lastHookInstall ? relative(root, lastHookInstall.settingsPath).split(pathSep).join("/") || lastHookInstall.settingsPath : ".claude/settings.json";
4064
4468
  const hostName = connectHostLabel(c?.host ?? "claude-code");
4469
+ const approverName = printableName(c?.approver);
4065
4470
  if (c) {
4066
- log(`✔ ${fresh ? "Connected to Decidio" : "Already connected to Decidio"}${c.workspaceName ? ` - workspace ${c.workspaceName}` : ""}${fresh && c.approvedBy ? `, allowed by ${c.approvedBy}` : ""}`);
4067
- log(`✔ Approvals go to ${c.approver ?? "the approver you picked"} - on the phone app, Slack, Teams or the web`);
4471
+ const wsName = printableName(c.workspaceName), allowedBy = printableName(c.approvedBy);
4472
+ log(`✔ ${fresh ? "Connected to Decidio" : "Already connected to Decidio"}${wsName ? ` - workspace ${wsName}` : ""}${fresh && allowedBy ? `, allowed by ${allowedBy}` : ""}`);
4473
+ if (c.answered === false)
4474
+ log(`• One question is still open for this computer: "Should another person approve this?" - the workspace owner answers it once on the Decidio Agents page${c.appUrl ? ` (${c.appUrl.replace(/\/+$/, "")}/connect/agents#coding-assistants)` : ""}. Until then its owner can't approve its destructive commands - another approver can, or they wait.`);
4475
+ else
4476
+ log(`✔ Approvals go to ${approverName ?? "its approver"} - on the phone app, Slack, Teams or the web`);
4068
4477
  }
4069
4478
  if (!verified) {
4070
4479
  err(probe.status === 401 || probe.status === 403
@@ -4076,19 +4485,24 @@ async function finishConnect(root, c, opts, fresh) {
4076
4485
  const changed = !plugin && lastHookInstall && (lastHookInstall.pre !== "kept" || lastHookInstall.post !== "kept");
4077
4486
  log(plugin
4078
4487
  ? `✔ ${hostName} asks before destructive commands in this project (the Decidio plugin's hooks)`
4079
- : `✔ ${hostName} asks before destructive commands in this project (${settings})`);
4488
+ : `✔ ${hostName} asks before destructive commands in ${everywhere ? "every project on this computer" : "this project"} (${settings})`);
4489
+ log(dim(` What waits for a person's approval: deleting files (rm -rf), force-pushing, dropping tables, terraform and kubectl changes, publishing a package, curl | sh - and switching Decidio's own check off. Everything else runs as before.`));
4080
4490
  if (changed)
4081
4491
  log(dim(` Claude Code picks the settings change up by itself - if a destructive command is not asked about, open /hooks once (or restart it).`));
4082
4492
  if (c)
4083
- log(dim(` Credentials: ${connectionFile(root)} (outside the repository, owner-only) · until ${c.expiresAt.slice(0, 10)}${cloud ? " · this cloud machine only - a fresh one refuses destructive commands until it connects again" : ""}`));
4084
- if (!plugin && (shared || process.platform === "win32") && !sameDir(realOrSame(process.cwd()), root))
4493
+ log(dim(` Credentials: ${connectionFile(c.root)} (outside the repository, owner-only) · ${c.root === COMPUTER ? "every project on this computer" : "this project"} · until ${c.expiresAt.slice(0, 10)}${cloud ? ` · this cloud machine only - a fresh one connects again (the Decidio connector does it with no tap)${opts.holdEveryone ? ", and is refused destructive commands until it does" : ""}` : ""}`));
4494
+ if (!plugin && !everywhere && (shared || process.platform === "win32") && !sameDir(realOrSame(process.cwd()), root))
4085
4495
  log(dim(` Claude Code reads ${settings} from the folder it starts in - start it at ${root}.`));
4086
4496
  if (cloud && !opts.team)
4087
- log(`• A cloud session reads only the repository's ${settings}, so the hooks are there - not committed. Ask the person before committing it: committed, every cloud session of this repository asks too, and so does every teammate's clone (a teammate connects with npx @decidio/sdk connect).`);
4497
+ log(`• A cloud session reads the repository's settings, not a computer's, so the hooks are in ${settings} - not committed. Ask the person before committing it: committed, every cloud session of this repository asks too, and so does every teammate's clone (a teammate connects with npx @decidio/sdk connect).`);
4088
4498
  else if (shared)
4089
4499
  log(`• Commit ${settings}: every clone then asks before destructive commands (a teammate connects with npx @decidio/sdk connect).`);
4090
- if (fresh)
4091
- log(dim(` Try it: ask ${hostName} to delete a scratch folder with rm -rf - the approval reaches ${c?.approver ?? "the approver"} on their phone.`));
4500
+ if (shared && !plugin)
4501
+ log(dim(opts.holdEveryone
4502
+ ? " A computer that isn't connected is refused every destructive command until it connects (--hold-everyone)."
4503
+ : " A computer that isn't connected is told once (with how to connect), then the same command runs without Decidio's approval. To keep refusing it until it connects: npx @decidio/sdk connect --team --hold-everyone"));
4504
+ if (fresh && c?.answered !== false)
4505
+ log(dim(` Try it: ask ${hostName} to delete a scratch folder with rm -rf - the approval reaches ${approverName ?? "the approver"} on their phone.`));
4092
4506
  return 0;
4093
4507
  }
4094
4508
  const DECIDIO_PLUGIN = "decidio-approvals";
@@ -4106,21 +4520,23 @@ function decidioPluginEnabled(root) {
4106
4520
  }
4107
4521
  async function cmdDisconnect() {
4108
4522
  const root = projectRoot();
4109
- const c = loadConnection(root, API);
4110
- dropFile(pendingConnectFile(root));
4523
+ const scope = connectScope(root);
4524
+ const c = loadConnection(scope, API);
4525
+ dropFile(pendingConnectFile(scope));
4526
+ const where = scope === COMPUTER ? "this computer" : "this project on this machine";
4111
4527
  if (!c) {
4112
- log("• this project isn't connected on this machine - nothing to do.");
4528
+ log(`• ${where} isn't connected - nothing to do.`);
4113
4529
  return 0;
4114
4530
  }
4115
4531
  try {
4116
- writeSecretFile(connectionFile(root), JSON.stringify({ ...c, token: "", jti: "", disconnected: true }) + "\n");
4532
+ writeSecretFile(connectionFile(scope), JSON.stringify({ ...c, token: "", jti: "", disconnected: true }) + "\n");
4117
4533
  }
4118
4534
  catch (e) {
4119
- err(`✘ couldn't update ${connectionFile(root)} (${e?.message ?? e}) - delete it by hand to disconnect.`);
4535
+ err(`✘ couldn't update ${connectionFile(scope)} (${e?.message ?? e}) - delete it by hand to disconnect.`);
4120
4536
  return 1;
4121
4537
  }
4122
- log(`✔ disconnected this project on this machine - its token is deleted.`);
4123
- log(` Destructive commands are now refused until you connect again (the hooks stay on: switching Decidio off needs a person's approval).`);
4538
+ log(`✔ disconnected ${where} - its token is deleted.`);
4539
+ log(` Destructive commands are now refused${scope === COMPUTER ? " in every project here" : ""} until you connect again (the hooks stay on: switching Decidio off needs a person's approval).`);
4124
4540
  log(dim(` "${c.agentId}" stays listed in Decidio until its token expires (${c.expiresAt.slice(0, 10)}); to end it now, revoke it on the Agents page${c.appUrl ? `: ${c.appUrl.replace(/\/+$/, "")}/connect/agents` : ""}.`));
4125
4541
  return 0;
4126
4542
  }
@@ -5403,17 +5819,21 @@ APPROVALS AND RECEIPTS
5403
5819
  (never the receipt's own issuer field; --allow-unpinned: structural check only)
5404
5820
 
5405
5821
  CODING AGENTS (Claude Code)
5406
- npx @decidio/sdk connect connect this project's coding assistant in one tap: prints a link and a short
5407
- code (a QR and the browser too, on a real terminal); the workspace owner allows
5408
- it on the web, then this machine gets an ask-only credential (kept outside the
5409
- repository) and the hooks below switch on. Your assistant can run it for you.
5822
+ npx @decidio/sdk connect connect this computer's coding assistant once, for every project on it: prints a
5823
+ link and a short code (a QR and the browser too, on a real terminal); the workspace
5824
+ owner allows it on the web, then this computer gets an ask-only credential (kept
5825
+ outside every repository) and the hooks below switch on in ~/.claude/settings.json.
5410
5826
  npx @decidio/sdk connect --wait finish a connect started by an assistant: returns once it is allowed, within 90 s
5411
5827
  (exit 0 connected · 3 refused or expired · 1 still waiting, run it again)
5412
- --team: the shared .claude/settings.json every clone reads (default: yours
5413
- alone; a cloud session reads only the shared file, so there it is always that
5414
- one) · --no-browser · --force: a fresh token for this machine
5415
- npx @decidio/sdk disconnect delete this machine's connection for this project (destructive commands are then
5416
- refused until you connect again; the hooks stay on)
5828
+ --team: the shared .claude/settings.json every clone reads (a cloud session reads
5829
+ the repository's files, not a computer's, so there it is always the shared one); a
5830
+ computer that isn't connected is told once, then let through - --hold-everyone keeps
5831
+ refusing it · --no-browser · --force: a fresh token for this computer
5832
+ npx @decidio/sdk connect --ticket <t>
5833
+ finish the link the Decidio connector allowed (its connect_device tool returns the
5834
+ ticket): writes this computer's credential, nothing else
5835
+ npx @decidio/sdk disconnect delete this computer's connection (destructive commands are then refused until
5836
+ you connect again; the hooks stay on)
5417
5837
  npx @decidio/sdk hook install [--project] [--approver <email>] [--cli <path>]
5418
5838
  a person approves Claude Code's destructive shell commands before they run
5419
5839
  (rm -rf, git push --force, DROP TABLE, terraform apply, kubectl delete,
@@ -5434,6 +5854,11 @@ CODING AGENTS (Claude Code)
5434
5854
  (90 s; at most 120 s) for the approver, then refused with its decision id; the SAME command
5435
5855
  retried after approval runs once - its execution is recorded and that
5436
5856
  approval is spent. No credentials, no gate, no answer = refused (fail closed).
5857
+ --unconnected=tell (written into a shared file by connect): a computer with no
5858
+ credential is refused once per session and command, then let through with a line.
5859
+ npx @decidio/sdk hook prompt the UserPromptSubmit hook: the person's own typed "decidio pause" / "decidio start"
5860
+ pauses or resumes the guard for that session (never on an enterprise plan; every
5861
+ pause and every command run under it is recorded); other prompts pass untouched.
5437
5862
 
5438
5863
  CONTEXT PACKS (Claude Code, Claude.ai, ChatGPT)
5439
5864
  npx @decidio/sdk context create <slug> --title "ACME application" --trigger "whenever we discuss the ACME application"
@@ -5661,7 +6086,7 @@ async function main(argv) {
5661
6086
  logout: { flags: [] },
5662
6087
  phone: { flags: [] },
5663
6088
  init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
5664
- connect: { flags: ["--wait", "--no-browser", "--team", "--force"], valued: ["--host"] },
6089
+ connect: { flags: ["--wait", "--no-browser", "--team", "--force", "--hold-everyone"], valued: ["--host", "--ticket"] },
5665
6090
  disconnect: { flags: [] },
5666
6091
  doctor: { flags: [] },
5667
6092
  approvals: { flags: ["--json", "--mine"], valued: ["--reason"] },
@@ -5669,7 +6094,7 @@ async function main(argv) {
5669
6094
  verify: { flags: ["--allow-unpinned"], valued: ["--issuer"] },
5670
6095
  tokens: { flags: ["--mine"] },
5671
6096
  dev: { flags: [], valued: ["--target", "--port"] },
5672
- hook: { flags: ["--project"], valued: ["--timeout", "--approver", "--cli"] },
6097
+ hook: { flags: ["--project"], valued: ["--timeout", "--approver", "--cli", "--unconnected"] },
5673
6098
  github: { flags: ["--agents-md"], valued: ["--environment", "--justification", "--decision", "--workflow", "--api"] },
5674
6099
  deploys: { flags: ["--watch", "--json"], valued: ["--environment", "--repo", "--limit"] },
5675
6100
  controls: { flags: ["--json"] },
@@ -5870,13 +6295,14 @@ async function main(argv) {
5870
6295
  }
5871
6296
  return cmdContext(sub, pos.slice(1), { title: valuedOpt(argsIn, "--title"), trigger: valuedOpt(argsIn, "--trigger"), team: valuedOpt(argsIn, "--team"), unit: valuedOpt(argsIn, "--unit"), org: argsIn.includes("--org"), here: argsIn.includes("--here"), pack: valuedOpt(argsIn, "--pack"), chatgpt: argsIn.includes("--chatgpt"), json: argsIn.includes("--json") });
5872
6297
  }
5873
- case "connect": return cmdConnect({ wait: argsIn.includes("--wait"), host: valuedOpt(argsIn, "--host"), noBrowser: argsIn.includes("--no-browser"), team: argsIn.includes("--team"), force: argsIn.includes("--force") });
6298
+ case "connect": return cmdConnect({ wait: argsIn.includes("--wait"), host: valuedOpt(argsIn, "--host"), noBrowser: argsIn.includes("--no-browser"), team: argsIn.includes("--team"), force: argsIn.includes("--force"),
6299
+ ticket: valuedOpt(argsIn, "--ticket"), holdEveryone: argsIn.includes("--hold-everyone") });
5874
6300
  case "disconnect": return cmdDisconnect();
5875
6301
  case "hook": {
5876
- const VALUED = ["--timeout", "--approver", "--cli"];
6302
+ const VALUED = ["--timeout", "--approver", "--cli", "--unconnected"];
5877
6303
  const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
5878
6304
  const sub = pos[0];
5879
- const allowed = { pretool: [], posttool: [], wait: ["--timeout"], install: ["--project", "--approver", "--cli"], rules: [], justify: [] };
6305
+ const allowed = { pretool: ["--unconnected"], posttool: [], session: [], prompt: [], wait: ["--timeout"], install: ["--project", "--approver", "--cli"], rules: [], justify: [] };
5880
6306
  const given = argsIn.filter((x) => x.startsWith("--")).map((x) => x.split("=")[0]);
5881
6307
  if (sub && allowed[sub]) {
5882
6308
  const stray = given.find((f) => !allowed[sub].includes(f));
@@ -5886,8 +6312,10 @@ async function main(argv) {
5886
6312
  }
5887
6313
  }
5888
6314
  switch (sub) {
5889
- case "pretool": return cmdHookPretool();
6315
+ case "pretool": return cmdHookPretool({ unconnected: valuedOpt(argsIn, "--unconnected") === "tell" ? "tell" : "hold" });
5890
6316
  case "posttool": return cmdHookPosttool();
6317
+ case "session": return cmdHookSession();
6318
+ case "prompt": return cmdHookPrompt();
5891
6319
  case "wait": {
5892
6320
  const t = valuedOpt(argsIn, "--timeout") ?? "600";
5893
6321
  if (!/^\d+$/.test(t) || Number(t) < 1 || Number(t) > 86_400) {
@@ -5900,7 +6328,7 @@ async function main(argv) {
5900
6328
  case "rules": return cmdHookRules();
5901
6329
  case "justify": return cmdHookJustify(pos[1], pos.slice(2).join(" "));
5902
6330
  default:
5903
- err(`✘ hook needs a subcommand: install | wait <decision-id> | justify <decision-id> "<what changed>" | rules (pretool and posttool are run by Claude Code).\n\n${USAGE}`);
6331
+ err(`✘ hook needs a subcommand: install | wait <decision-id> | justify <decision-id> "<what changed>" | rules (pretool, posttool and session are run by Claude Code).\n\n${USAGE}`);
5904
6332
  return 2;
5905
6333
  }
5906
6334
  }
package/openapi.yaml CHANGED
@@ -5,7 +5,7 @@
5
5
  openapi: 3.1.0
6
6
  info:
7
7
  title: Decidio Agent Gate
8
- version: 0.10.0 # tracks the SDK release - checked by packages/conformance/manifests.mjs
8
+ version: 0.11.0 # tracks the SDK release - checked by packages/conformance/manifests.mjs
9
9
  description: >
10
10
  Authorize an AI-agent action (proceed | route | block), confirm the captured outcome, and
11
11
  receive the signed resume webhook when a routed action is decided. Every outcome seals a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decidio/sdk",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "One-line approval gate for AI-agent actions. Wrap a risky tool call with guard.protect(); Decidio's policy engine decides proceed | route | block, a human approves routed actions, and every outcome is sealed as a verifiable Authority Receipt.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -84,7 +84,7 @@
84
84
  "prepublishOnly": "npm run typecheck && npm run build && node ../publish-boundary/check.mjs --only @decidio/sdk"
85
85
  },
86
86
  "dependencies": {
87
- "@decidio/verify": "0.10.0"
87
+ "@decidio/verify": "0.11.0"
88
88
  },
89
89
  "author": "OmniTwin Technologies Inc.",
90
90
  "bugs": {