@decidio/sdk 0.9.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/dist/cli.js CHANGED
@@ -1,16 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, rmdirSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync, mkdtempSync, readdirSync, copyFileSync, statSync, lstatSync, fstatSync, readSync } from "node:fs";
3
3
  import { randomBytes, createHash } from "node:crypto";
4
- import { homedir, tmpdir } from "node:os";
4
+ import { homedir, tmpdir, hostname } from "node:os";
5
5
  import { join as joinPath, resolve as resolvePath, sep as pathSep, basename, dirname, relative, isAbsolute } from "node:path";
6
6
  import { createServer } from "node:http";
7
7
  import { isIP } from "node:net";
8
- import { execFileSync, spawnSync } from "node:child_process";
8
+ import { execFileSync, spawnSync, spawn } from "node:child_process";
9
9
  import { createInterface } from "node:readline";
10
10
  import { pathToFileURL as toFileUrl, fileURLToPath as fromFileUrl } from "node:url";
11
11
  import { realpathSync as realPath } from "node:fs";
12
12
  import { createRequire } from "node:module";
13
- import { generateKeypair, assertSafeTransport, redactUrl } from "./signing.js";
13
+ import { generateKeypair, assertSafeTransport, redactUrl, signBoundProof } from "./signing.js";
14
14
  import { hardenFile } from "./resume.js";
15
15
  import { withApproval, DecidioRateLimitError, DecidioRejectedError, DecidioTimeoutError, DecidioHttpError, didFromPrivateKey, checkApproval, signConfirm, buildConfirmBody, readConfirmResponse, bindingMaterial, boundScope, idempotencyKey, decisionUrl, decisionIdProblem } from "./index.js";
16
16
  import { gateAuthorizeTwinAware, adapterRequest } from "./adapters/_gate.js";
@@ -76,6 +76,22 @@ 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;
87
+ let CREDS_FROM = SHELL_ENV.DECIDIO_API_TOKEN ? "shell" : process.env.DECIDIO_API_TOKEN ? ".env" : null;
88
+ if (!OFFLINE_CMD && !["init", "quickstart", "login", "logout", "phone"].includes(ARGV_TAIL[0] ?? "") && !process.env.DECIDIO_API_TOKEN && !process.env.DECIDIO_AGENT_ID) {
89
+ const c = activeConnection(projectRoot(), API);
90
+ if (c) {
91
+ promoteConnection(c);
92
+ CREDS_FROM = "connection";
93
+ }
94
+ }
79
95
  let DEMO = false;
80
96
  function enterDemo(url) { API = url; process.env.DECIDIO_API_URL = url; DEMO = true; }
81
97
  const pinWords = () => (DEMO ? "PINNED to this demo's own key" : "PINNED to your workspace issuer");
@@ -90,7 +106,7 @@ let hostAnnounced = false;
90
106
  async function api(path, init = {}, bearer, soft = false) {
91
107
  if (!hostAnnounced) {
92
108
  hostAnnounced = true;
93
- err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
109
+ err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}${APP_URL_OVERRIDDEN ? ` · links → ${APP_URL} (DECIDIO_APP_URL)` : ""}`);
94
110
  }
95
111
  const auth = bearer ?? TOKEN();
96
112
  if (auth && envUnreadable && !process.env.DECIDIO_API_URL) {
@@ -462,7 +478,24 @@ function block(n, title, rows, tone) {
462
478
  }
463
479
  log(f("└" + "─".repeat(width - 2) + "┘"));
464
480
  }
465
- const APP_URL = API.replace("decidio-api.onrender.com", "app.decidioai.com");
481
+ const APP_URL_SHAPE = /^https?:\/\/[\x21-\x7e]+$/;
482
+ const asciiTrim = (v) => v.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, "");
483
+ export function appUrlFor(api, override = process.env.DECIDIO_APP_URL) {
484
+ const o = asciiTrim(override ?? "");
485
+ if (APP_URL_SHAPE.test(o))
486
+ return o.replace(/\/+$/, "");
487
+ const base = api.replace(/\/+$/, "");
488
+ const known = {
489
+ "https://decidio-api.onrender.com": "https://staging.decidioai.com",
490
+ "https://mcp-staging.decidioai.com": "https://staging.decidioai.com",
491
+ "https://mcp.decidioai.com": "https://app.decidioai.com",
492
+ };
493
+ return known[base] ?? base;
494
+ }
495
+ const APP_URL = appUrlFor(API);
496
+ const APP_URL_OVERRIDDEN = APP_URL !== appUrlFor(API, "");
497
+ if (asciiTrim(process.env.DECIDIO_APP_URL ?? "") && !APP_URL_SHAPE.test(asciiTrim(process.env.DECIDIO_APP_URL ?? "")))
498
+ console.error("⚠ ignoring DECIDIO_APP_URL: it must be a plain http(s) address (printable characters only)");
466
499
  let stdinReadAbandoned = false;
467
500
  let EXPLAIN = false;
468
501
  let currentRl = null;
@@ -2739,6 +2772,14 @@ function hookMatch(command, at, rules = [...HOOK_RULES, ...hookExtraRules()], de
2739
2772
  }
2740
2773
  return null;
2741
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
+ }
2742
2783
  function hookSelfDecides(command, depth = 0) {
2743
2784
  const hit = hookSegments(command).some((s) => {
2744
2785
  const a = s.args.map((x) => x.replace(/^["']|["']$/g, ""));
@@ -3013,7 +3054,7 @@ function hookSubject(category, segs, cwdAbs, projectDir, at) {
3013
3054
  }
3014
3055
  return textOnly("text binds this category");
3015
3056
  }
3016
- 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);
3017
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;
3018
3059
  function hookOurHandlers(settings) {
3019
3060
  const out = [];
@@ -3263,11 +3304,11 @@ async function readHookStdin() {
3263
3304
  function hookConfig() {
3264
3305
  const token = process.env.DECIDIO_API_TOKEN;
3265
3306
  if (!token)
3266
- return { missing: "DECIDIO_API_TOKEN is not set - no agent token in this folder's .env or the environment" };
3307
+ return { missing: "this project is not connected to Decidio (no connection, no agent token in this folder's .env or the environment)" };
3267
3308
  return {
3268
3309
  apiUrl: API, agentId: process.env.DECIDIO_AGENT_ID || defaultAgentId(), apiToken: token, sourceSystem: "claude-code",
3269
3310
  agentKey: process.env.DECIDIO_AGENT_KEY || undefined, agentDid: process.env.DECIDIO_AGENT_DID || undefined, workspaceId: process.env.DECIDIO_WORKSPACE_ID || undefined,
3270
- appUrl: process.env.DECIDIO_APP_URL || APP_URL,
3311
+ appUrl: APP_URL,
3271
3312
  };
3272
3313
  }
3273
3314
  const hookStateDir = () => (process.env.CLAUDE_PLUGIN_DATA ? joinPath(process.env.CLAUDE_PLUGIN_DATA, "hooks") : joinPath(sessionDir(), "hooks"));
@@ -3373,8 +3414,91 @@ async function hookHold(cfg, decisionId, holdMs) {
3373
3414
  }
3374
3415
  return { status, lastError };
3375
3416
  }
3376
- async function cmdHookPretool() {
3377
- 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
+ };
3378
3502
  const deny = (reason, user) => out({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason }, ...(user ? { systemMessage: user } : {}) });
3379
3503
  const allow = (reason) => out({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", permissionDecisionReason: reason } });
3380
3504
  try {
@@ -3391,6 +3515,8 @@ async function cmdHookPretool() {
3391
3515
  const command = typeof input.tool_input?.command === "string" ? input.tool_input.command : "";
3392
3516
  if (!command.trim())
3393
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.");
3394
3520
  if (hookSelfDecides(command))
3395
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");
3396
3522
  const segs = hookSegments(command);
@@ -3410,7 +3536,8 @@ async function cmdHookPretool() {
3410
3536
  const canonical = command.replace(/\s+/g, " ").trim();
3411
3537
  const base = hookSubject(rule.category, segs, cwdAbs, projectDir, at);
3412
3538
  const subject = followed ? { fingerprint: `${base.fingerprint ?? ""}\nfile:${followed.rel}:${followed.sha}`, display: followed.rel } : base;
3413
- 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,
3414
3541
  ...(followed ? { scriptLine: hookRedact(`${followed.rel}:${followed.line}: ${followed.text.replace(/\s+/g, " ").trim()}`).slice(0, 160) } : {}) };
3415
3542
  }
3416
3543
  else if (tool === "Write" || tool === "Edit" || tool === "MultiEdit") {
@@ -3418,7 +3545,7 @@ async function cmdHookPretool() {
3418
3545
  if (!fc)
3419
3546
  return 0;
3420
3547
  const what = fc.changes.join("; ");
3421
- 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 };
3422
3549
  }
3423
3550
  else
3424
3551
  return 0;
@@ -3426,9 +3553,23 @@ async function cmdHookPretool() {
3426
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." }
3427
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." };
3428
3555
  const short = hookShort(hookRedact(g.text.replace(/\s+/g, " ").trim()));
3429
- 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
+ }
3430
3571
  if ("missing" in cfg)
3431
- return deny(`Decidio: "${short}" is a ${g.category} ${w.noun} that needs a person's approval, but this project has no Decidio agent credentials (${cfg.missing}). ${w.ran} Ask the user to run \`npx @decidio/sdk init\` in ${projectDir}, then retry the same ${w.noun}.`, `Decidio: no agent credentials in ${projectDir} - run npx @decidio/sdk init`);
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`);
3432
3573
  const rel = relative(projectDir, cwdAbs);
3433
3574
  const cwdRel = !rel ? "." : rel.startsWith("..") || isAbsolute(rel) ? cwdAbs : rel;
3434
3575
  const session = typeof input.session_id === "string" ? input.session_id : "";
@@ -3439,6 +3580,13 @@ async function cmdHookPretool() {
3439
3580
  catch (e) {
3440
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\`.`);
3441
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
+ }
3442
3590
  if (!material)
3443
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\`.`);
3444
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 } : {}) };
@@ -3483,7 +3631,7 @@ async function cmdHookPretool() {
3483
3631
  if (auth.decision === "proceed") {
3484
3632
  writeHookState(input.tool_use_id, { v: 1, decisionId: auth.decisionId, receiptId: auth.receiptId ?? null, tool, category: g.category, at: new Date().toISOString() });
3485
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.`);
3486
- return 0;
3634
+ return connectNotice ? out({}) : 0;
3487
3635
  }
3488
3636
  const holdRaw = Number(process.env.DECIDIO_HOOK_HOLD_MS ?? "90000");
3489
3637
  const held = await hookHold(cfg, auth.decisionId, Number.isFinite(holdRaw) && holdRaw >= 0 ? Math.min(holdRaw, HOOK_HOLD_CAP_MS) : 90_000);
@@ -3491,7 +3639,7 @@ async function cmdHookPretool() {
3491
3639
  if (!st)
3492
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}.`);
3493
3641
  if (st.state === "pending")
3494
- 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.`);
3495
3643
  if (st.state === "rejected" || st.state === "blocked")
3496
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}`);
3497
3645
  const chk = await checkApproval(cfg, auth.decisionId, request, { status: st });
@@ -3569,7 +3717,7 @@ async function cmdHookWait(id, timeoutSec) {
3569
3717
  }
3570
3718
  const cfg = hookConfig();
3571
3719
  if ("missing" in cfg) {
3572
- err(`✘ ${cfg.missing} - run \`npx @decidio/sdk init\` in this project first.`);
3720
+ err(`✘ ${cfg.missing} - connect this project first: npx @decidio/sdk connect`);
3573
3721
  return 1;
3574
3722
  }
3575
3723
  const where = decisionUrl(cfg.appUrl, id);
@@ -3650,10 +3798,10 @@ function cmdHookRules() {
3650
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".`);
3651
3799
  return 0;
3652
3800
  }
3653
- function hookRunner() {
3801
+ function hookRunner(portable = false) {
3654
3802
  const q = (p) => `"${p.replace(/(["\\$`])/g, "\\$1")}"`;
3655
3803
  const entry = process.argv[1] ?? "";
3656
- if (!entry || /[\\/]_npx[\\/]/.test(entry))
3804
+ if (portable || !entry || /[\\/]_npx[\\/]/.test(entry))
3657
3805
  return `npx -y @decidio/sdk@${VERSION}`;
3658
3806
  let real = entry;
3659
3807
  try {
@@ -3662,16 +3810,17 @@ function hookRunner() {
3662
3810
  catch { }
3663
3811
  const localRoot = resolvePath("node_modules") + pathSep;
3664
3812
  if (real.startsWith(localRoot))
3665
- 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")}"`;
3666
3814
  real = real.split(pathSep).join("/");
3667
3815
  if (/\.ts$/.test(real))
3668
3816
  return `npx tsx ${q(real)}`;
3669
3817
  return `node ${q(real)}`;
3670
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`;
3671
3820
  const HOOK_FILE_TOOLS = ["Write", "Edit", "MultiEdit"];
3672
3821
  const HOOK_GUARD_PATHS = ["**/.claude/settings*.json", "~/.claude/settings*.json", "~/.claude/plugins/**"];
3673
3822
  async function cmdHookInstall(opts) {
3674
- const settingsPath = joinPath(".claude", opts.project ? "settings.json" : "settings.local.json");
3823
+ const settingsPath = joinPath(opts.dir ?? ".", ".claude", opts.project ? "settings.json" : "settings.local.json");
3675
3824
  let settings = {};
3676
3825
  if (existsSync(settingsPath)) {
3677
3826
  try {
@@ -3691,14 +3840,14 @@ async function cmdHookInstall(opts) {
3691
3840
  return 2;
3692
3841
  }
3693
3842
  const cliAt = opts.cli ? resolvePath(opts.cli).split(pathSep).join("/") : "";
3694
- 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);
3695
3844
  const hooks = (settings.hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {});
3696
3845
  const place = (event, matcher, iff, command, extra) => {
3697
3846
  const list = Array.isArray(hooks[event]) ? hooks[event] : (hooks[event] = []);
3698
3847
  const want = { type: "command", ...(iff ? { if: iff } : {}), command, ...extra };
3699
3848
  let state = "added";
3700
3849
  for (const g of list) {
3701
- 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))
3702
3851
  continue;
3703
3852
  g.hooks = g.hooks.flatMap((h) => {
3704
3853
  if (!hookIsOurs(h) || (typeof h.if === "string" ? h.if : undefined) !== iff)
@@ -3713,39 +3862,684 @@ async function cmdHookInstall(opts) {
3713
3862
  }
3714
3863
  hooks[event] = list.filter((g) => !(g && typeof g === "object" && Array.isArray(g.hooks) && g.hooks.length === 0));
3715
3864
  if (state === "added")
3716
- hooks[event].push({ matcher, hooks: [want] });
3865
+ hooks[event].push(matcher ? { matcher, hooks: [want] } : { hooks: [want] });
3717
3866
  return state;
3718
3867
  };
3719
- 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`;
3720
3869
  const pre = place("PreToolUse", "Bash|PowerShell", undefined, preLine, { timeout: 150, onFailure: "block" });
3721
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 });
3722
3873
  const guard = { added: 0, updated: 0, kept: 0 };
3723
3874
  for (const t of HOOK_FILE_TOOLS)
3724
3875
  for (const p of HOOK_GUARD_PATHS) {
3725
3876
  guard[place("PreToolUse", t, `${t}(${p})`, preLine, { timeout: 150, onFailure: "block" })]++;
3726
3877
  guard[place("PostToolUse", t, `${t}(${p})`, postLine, { timeout: 30 })]++;
3727
3878
  }
3728
- mkdirSync(".claude", { recursive: true });
3729
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
3879
+ mkdirSync(dirname(settingsPath), { recursive: true });
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);
3883
+ lastHookInstall = { settingsPath, pre, post };
3884
+ if (opts.quiet)
3885
+ return 0;
3730
3886
  log(`✔ ${settingsPath.split(pathSep).join("/")}: PreToolUse hook ${pre}, PostToolUse hook ${post} (matcher Bash|PowerShell${opts.project ? "; this file is shared with everyone who clones the project" : "; this file is yours alone - Claude Code keeps it out of git"}).`);
3731
3887
  log(`✔ the guard on Claude Code's own settings - ${HOOK_FILE_TOOLS.join(", ")} on ${HOOK_GUARD_PATHS.join(", ")}, PreToolUse and PostToolUse: ${Object.entries(guard).filter(([, n]) => n).map(([k, n]) => `${n} ${k}`).join(", ")}.`);
3732
3888
  log(` Runs: ${runner} hook pretool|posttool - the pretool hook times out at 150 s and BLOCKS the call if it fails (fail closed).`);
3733
3889
  const env = readEnvFile();
3734
3890
  const token = process.env.DECIDIO_API_TOKEN || env.DECIDIO_API_TOKEN;
3735
- if (token)
3891
+ if (token && CREDS_FROM === "connection")
3892
+ log(`✔ credentials: this project's Decidio connection (${process.env.DECIDIO_AGENT_ID}) - kept outside the repository, owner-only.`);
3893
+ else if (token)
3736
3894
  log(`✔ agent credentials: ${ENV_PATH} holds DECIDIO_API_TOKEN${(process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) ? " and the agent's signing key" : ""} for "${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || defaultAgentId()}".`);
3737
3895
  else
3738
- log(`⚠ no agent credentials here (no DECIDIO_API_TOKEN in ${ENV_PATH}) - until there are, EVERY gated command is refused. Run: npx @decidio/sdk init ${defaultAgentId()}`);
3896
+ log(`⚠ not connected yet - until this project is, EVERY gated command is refused. Run: npx @decidio/sdk connect (or, for your own agent: npx @decidio/sdk init ${defaultAgentId()})`);
3739
3897
  log(` Gated: ${HOOK_RULES.map((r) => r.category).join(", ")} (npx @decidio/sdk hook rules lists the patterns; DECIDIO_HOOK_EXTRA_RULES adds yours).`);
3740
3898
  log(` Approver: anyone in your workspace with a role other than the agent's owner (the person who ran init) - on the phone app, the web app, Slack or Teams.`);
3741
3899
  log(` Nobody yet? Invite one: npx @decidio/sdk hook install --approver <email> (single-use link; share by WhatsApp, SMS or email)`);
3742
3900
  log(` Flow: a gated command is held up to ${Math.min(Number(process.env.DECIDIO_HOOK_HOLD_MS ?? 90_000) || 90_000, HOOK_HOLD_CAP_MS) / 1000} s for the approver, then refused with its decision id; \`npx @decidio/sdk hook wait <id>\` returns when they decide,`);
3743
3901
  log(` and the SAME command retried then runs - once. A lab workspace allows 30 governed actions an hour; a gated command spends two (ask, retry).`);
3744
- log(` Reload: restart Claude Code, or run /hooks in it - hooks are read when a session starts.`);
3902
+ log(` Reload: none needed - Claude Code picks up the settings file by itself. If a gated command is not asked about, open /hooks once (or restart Claude Code).`);
3745
3903
  if (opts.approver !== undefined)
3746
3904
  return inviteApprover(opts.approver);
3747
3905
  return 0;
3748
3906
  }
3907
+ function connectHostLabel(h) { return h === "claude-code" ? "Claude Code" : h; }
3908
+ function connectDir() { return joinPath(sessionDir(), "connections"); }
3909
+ function realOrSame(p) { try {
3910
+ return realPath(p);
3911
+ }
3912
+ catch {
3913
+ return resolvePath(p);
3914
+ } }
3915
+ const sameDir = (a, b) => (process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b);
3916
+ function projectRoot() {
3917
+ try {
3918
+ const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
3919
+ const top = String(r.stdout ?? "").trim();
3920
+ if (r.status === 0 && top)
3921
+ return realOrSame(top);
3922
+ }
3923
+ catch { }
3924
+ return realOrSame(SHELL_ENV.CLAUDE_PROJECT_DIR || process.cwd());
3925
+ }
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); }
3927
+ function connectionFile(root) { return joinPath(connectDir(), `${connectionKey(root)}.json`); }
3928
+ function pendingConnectFile(root) { return joinPath(connectDir(), `${connectionKey(root)}.pending.json`); }
3929
+ function readJsonQuiet(f) {
3930
+ try {
3931
+ return existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : null;
3932
+ }
3933
+ catch {
3934
+ return null;
3935
+ }
3936
+ }
3937
+ function activeConnection(root, api) { return loadConnection(root, api) ?? loadConnection(COMPUTER, api); }
3938
+ const connectScope = (root) => (connectionRecord(root, API) ? root : COMPUTER);
3939
+ function loadConnection(root, api) {
3940
+ const c = connectionRecord(root, api);
3941
+ return c && !c.disconnected && c.token ? c : null;
3942
+ }
3943
+ function connectionRecord(root, api) {
3944
+ const c = readJsonQuiet(connectionFile(root));
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;
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
+ }
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;
3956
+ process.env.DECIDIO_API_TOKEN = c.token;
3957
+ process.env.DECIDIO_AGENT_ID = c.agentId;
3958
+ process.env.DECIDIO_AGENT_DID = c.did;
3959
+ process.env.DECIDIO_AGENT_KEY = c.key;
3960
+ process.env.DECIDIO_WORKSPACE_ID = c.workspaceId;
3961
+ process.env.DECIDIO_TOKEN_JTI = c.jti;
3962
+ if (c.appUrl && process.env.DECIDIO_APP_URL === undefined)
3963
+ process.env.DECIDIO_APP_URL = c.appUrl;
3964
+ }
3965
+ function inCloudSession() { return SHELL_ENV.CLAUDE_CODE_REMOTE === "true"; }
3966
+ function machineId() {
3967
+ const f = joinPath(sessionDir(), "machine-id");
3968
+ let cur = "";
3969
+ try {
3970
+ cur = existsSync(f) ? readFileSync(f, "utf8").trim() : "";
3971
+ }
3972
+ catch { }
3973
+ if (/^[0-9a-f]{8,32}$/.test(cur))
3974
+ return cur;
3975
+ const id = randomBytes(8).toString("hex");
3976
+ mkdirSync(sessionDir(), { recursive: true });
3977
+ writeFileSync(f, id + "\n");
3978
+ return id;
3979
+ }
3980
+ function machineLabel(cloud) {
3981
+ if (cloud)
3982
+ return `cloud-${machineId().slice(0, 4)}`;
3983
+ const h = (() => { try {
3984
+ return hostname();
3985
+ }
3986
+ catch {
3987
+ return "";
3988
+ } })().split(".")[0].toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
3989
+ return (h || "machine").slice(0, 40);
3990
+ }
3991
+ function projectLabel(root) {
3992
+ try {
3993
+ const r = spawnSync("git", ["-C", root, "config", "--get", "remote.origin.url"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
3994
+ const m = String(r.stdout ?? "").trim().match(/([^/:\\]+?)(?:\.git)?\/?$/);
3995
+ if (r.status === 0 && m?.[1])
3996
+ return m[1].slice(0, 80);
3997
+ }
3998
+ catch { }
3999
+ try {
4000
+ const pj = JSON.parse(readFileSync(joinPath(root, "package.json"), "utf8"));
4001
+ if (typeof pj?.name === "string" && pj.name)
4002
+ return pj.name.replace(/^@[^/]+\//, "").slice(0, 80);
4003
+ }
4004
+ catch { }
4005
+ return (basename(root) || "project").slice(0, 80);
4006
+ }
4007
+ function localDesktop() {
4008
+ if (inCloudSession() || SHELL_ENV.CI || SHELL_ENV.SSH_CONNECTION || SHELL_ENV.SSH_TTY || SHELL_ENV.CODESPACES || SHELL_ENV.GITPOD_WORKSPACE_ID || SHELL_ENV.DECIDIO_NO_BROWSER)
4009
+ return false;
4010
+ if (process.platform === "darwin" || process.platform === "win32")
4011
+ return true;
4012
+ return !!(SHELL_ENV.DISPLAY || SHELL_ENV.WAYLAND_DISPLAY);
4013
+ }
4014
+ function openInBrowser(url) {
4015
+ if (!/^https?:\/\/[^\s"'<>`]+$/.test(url))
4016
+ return false;
4017
+ try {
4018
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
4019
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
4020
+ child.on("error", () => { });
4021
+ child.unref();
4022
+ return true;
4023
+ }
4024
+ catch {
4025
+ return false;
4026
+ }
4027
+ }
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; };
4029
+ let lastHookInstall = null;
4030
+ let connectError = "";
4031
+ async function startConnect(root, host, quiet = false) {
4032
+ const fail = (m) => { connectError = m; if (!quiet)
4033
+ err(`✘ ${m}`); return null; };
4034
+ const prior = connectionRecord(root, API);
4035
+ const kp = prior && !inCloudSession() ? { did: prior.did, privateKeyBase64: prior.key } : generateKeypair();
4036
+ const cloud = inCloudSession();
4037
+ const project = (root === COMPUTER ? EVERY_PROJECT : projectLabel(root)).trim() || "project", machine = machineLabel(cloud).trim() || "machine";
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") });
4039
+ const r = await api("/connect/start", { method: "POST", body: JSON.stringify({ host, project, machine, did: kp.did, cloud, proof }) }, "", true);
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.`);
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),
4049
+ pollSecret: r.body.pollSecret, expiresAt: String(r.body.expiresAt), interval: Number(r.body.interval) || 2, startedAt: new Date().toISOString() };
4050
+ mkdirSync(connectDir(), { recursive: true });
4051
+ writeSecretFile(pendingConnectFile(root), JSON.stringify(p) + "\n");
4052
+ if (typeof r.body.qr === "string")
4053
+ pendingQr = r.body.qr;
4054
+ return p;
4055
+ }
4056
+ let pendingQr = null;
4057
+ function readPendingConnect(root) {
4058
+ const p = readJsonQuiet(pendingConnectFile(root));
4059
+ return p && p.v === 1 && p.api === API && p.root === root && typeof p.pollSecret === "string" ? p : null;
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
+ }
4225
+ function dropFile(f) { try {
4226
+ rmSync(f, { force: true });
4227
+ }
4228
+ catch { } }
4229
+ const minutesLeft = (iso) => Math.max(0, Math.round((new Date(iso).getTime() - Date.now()) / 60_000));
4230
+ function showConnectLink(p, opened, tty) {
4231
+ const title = `Connect ${connectHostLabel(p.host)} to Decidio`;
4232
+ log(`${brand()} ${bold(title)}`);
4233
+ log(` Open: ${link(p.link)}`);
4234
+ log(` Code: ${bold(p.code)} ${dim("(the page shows the same code - only allow it if they match)")}`);
4235
+ log(dim(` ${p.project} · ${p.machine} · the link works for ${minutesLeft(p.expiresAt) || 1} more minute${minutesLeft(p.expiresAt) === 1 ? "" : "s"}${opened ? " · opened in your browser" : ""}`));
4236
+ if (tty && pendingQr) {
4237
+ log("");
4238
+ for (const line of pendingQr.split("\n"))
4239
+ if (line.trim())
4240
+ log(` ${line}`);
4241
+ log(dim(" Scan with your phone's camera to allow it there."));
4242
+ }
4243
+ }
4244
+ async function cmdConnect(opts) {
4245
+ const host = (opts.host ?? "claude-code");
4246
+ if (host !== "claude-code") {
4247
+ err(`✘ connect knows Claude Code today (--host claude-code); "${opts.host}" is not supported yet.`);
4248
+ return 2;
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
+ }
4258
+ try {
4259
+ assertSafeTransport(API, true);
4260
+ }
4261
+ catch (e) {
4262
+ err(`✘ ${String(e?.message ?? e).replace(/^\[decidio\] /, "")}`);
4263
+ return 1;
4264
+ }
4265
+ const root = projectRoot();
4266
+ if (opts.ticket !== undefined)
4267
+ return connectWithTicket(root, opts.ticket);
4268
+ const tty = !!(process.stdin.isTTY && process.stdout.isTTY);
4269
+ if (CREDS_FROM === "shell" || CREDS_FROM === ".env") {
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.`);
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.`));
4272
+ return finishConnect(root, null, opts, false);
4273
+ }
4274
+ const existing = activeConnection(root, API);
4275
+ if (existing && !opts.force && new Date(existing.expiresAt).getTime() > Date.now())
4276
+ return finishConnect(root, existing, opts, false);
4277
+ const scope = connectScope(root);
4278
+ if (opts.wait)
4279
+ return waitConnect(root, scope, opts, false);
4280
+ let p = readPendingConnect(scope);
4281
+ if (!p || p.host !== host || new Date(p.expiresAt).getTime() <= Date.now() + 15_000)
4282
+ p = await startConnect(scope, host);
4283
+ if (!p)
4284
+ return 1;
4285
+ const opened = !opts.noBrowser && localDesktop() && openInBrowser(p.link);
4286
+ showConnectLink(p, opened, tty);
4287
+ if (tty) {
4288
+ log("");
4289
+ return waitConnect(root, scope, opts, true);
4290
+ }
4291
+ log("");
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).`);
4293
+ log(dim("Assistants: show the person the link and the code above, then run npx @decidio/sdk connect --wait."));
4294
+ return 0;
4295
+ }
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);
4380
+ if (!p) {
4381
+ const c = activeConnection(root, API);
4382
+ if (c)
4383
+ return finishConnect(root, c, opts, false);
4384
+ err("✘ no connection is in progress for this project - start one: npx @decidio/sdk connect");
4385
+ return 1;
4386
+ }
4387
+ const deadline = interactive ? new Date(p.expiresAt).getTime() + 5_000 : Math.min(new Date(p.expiresAt).getTime() + 5_000, Date.now() + CONNECT_WAIT_S() * 1000);
4388
+ const spin = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
4389
+ let i = 0, misses = 0;
4390
+ if (!interactive)
4391
+ err(`· waiting for the connection to be allowed at ${p.link} (code ${p.code}; up to ${Math.round((deadline - Date.now()) / 1000)} s)`);
4392
+ while (Date.now() < deadline) {
4393
+ const r = await api("/connect/poll", { method: "POST", headers: { "x-decidio-connect-secret": p.pollSecret }, body: "{}" }, "", true);
4394
+ if (r.status === 200 && r.body?.status === "connected" && typeof r.body.token === "string") {
4395
+ if (interactive && TTY_OUT)
4396
+ process.stderr.write("\r\x1b[K");
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).`);
4400
+ return 1;
4401
+ }
4402
+ return finishConnect(root, c, opts, true);
4403
+ }
4404
+ if (r.status === 403 && r.body?.status === "denied") {
4405
+ dropFile(pendingConnectFile(scope));
4406
+ if (interactive && TTY_OUT)
4407
+ process.stderr.write("\r\x1b[K");
4408
+ err("✘ the connection was refused on the web (Not me). Nothing was saved.");
4409
+ return 3;
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
+ }
4424
+ if ((r.status === 404 || r.status === 410) && (r.body?.status === "expired" || r.body?.status === "collected")) {
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));
4432
+ if (interactive && TTY_OUT)
4433
+ process.stderr.write("\r\x1b[K");
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");
4435
+ return 3;
4436
+ }
4437
+ if (r.status === 0 && ++misses > 10) {
4438
+ err(`✘ lost touch with Decidio at ${API} - check your connection, then run npx @decidio/sdk connect --wait again.`);
4439
+ return 1;
4440
+ }
4441
+ if (interactive && TTY_OUT)
4442
+ process.stderr.write(`\r${spin[i++ % spin.length]} waiting for you to allow it in the browser… ${dim(`(${minutesLeft(p.expiresAt)} min left · Ctrl-C stops waiting; npx @decidio/sdk connect --wait picks it up again)`)}`);
4443
+ await new Promise((res) => setTimeout(res, (r.status === 429 ? 2 : 1) * Math.max(1, p.interval) * 1000));
4444
+ }
4445
+ if (interactive && TTY_OUT)
4446
+ process.stderr.write("\r\x1b[K");
4447
+ log(`• still waiting for it to be allowed: ${link(p.link)} (code ${p.code}, ${minutesLeft(p.expiresAt)} min left). Run npx @decidio/sdk connect --wait again once it is.`);
4448
+ return 1;
4449
+ }
4450
+ async function finishConnect(root, c, opts, fresh) {
4451
+ if (c) {
4452
+ promoteConnection(c);
4453
+ CREDS_FROM = "connection";
4454
+ }
4455
+ const cloud = inCloudSession();
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());
4459
+ const plugin = !cloud && decidioPluginEnabled(root);
4460
+ if (!plugin) {
4461
+ const rc = await cmdHookInstall({ project: shared || everywhere, dir: everywhere ? home : root, quiet: true, portable: everywhere, ...(shared && !opts.holdEveryone ? { unconnected: "tell" } : {}) });
4462
+ if (rc !== 0)
4463
+ return rc;
4464
+ }
4465
+ const probe = await api("/agent/status/doctor-scope-probe", {}, process.env.DECIDIO_API_TOKEN, true);
4466
+ const verified = probe.status === 404;
4467
+ const settings = everywhere ? "~/.claude/settings.json" : lastHookInstall ? relative(root, lastHookInstall.settingsPath).split(pathSep).join("/") || lastHookInstall.settingsPath : ".claude/settings.json";
4468
+ const hostName = connectHostLabel(c?.host ?? "claude-code");
4469
+ const approverName = printableName(c?.approver);
4470
+ if (c) {
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`);
4477
+ }
4478
+ if (!verified) {
4479
+ err(probe.status === 401 || probe.status === 403
4480
+ ? `✘ Decidio refused this connection's token (${probe.status}) - it was revoked or has expired. Reconnect: npx @decidio/sdk connect --force`
4481
+ : `⚠ couldn't check the token just now (${probe.status || "no answer"}) - the hooks are on; a gated command will say if anything is wrong.`);
4482
+ if (probe.status === 401 || probe.status === 403)
4483
+ return 1;
4484
+ }
4485
+ const changed = !plugin && lastHookInstall && (lastHookInstall.pre !== "kept" || lastHookInstall.post !== "kept");
4486
+ log(plugin
4487
+ ? `✔ ${hostName} asks before destructive commands in this project (the Decidio plugin's hooks)`
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.`));
4490
+ if (changed)
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).`));
4492
+ if (c)
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))
4495
+ log(dim(` Claude Code reads ${settings} from the folder it starts in - start it at ${root}.`));
4496
+ if (cloud && !opts.team)
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).`);
4498
+ else if (shared)
4499
+ log(`• Commit ${settings}: every clone then asks before destructive commands (a teammate connects with npx @decidio/sdk connect).`);
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.`));
4506
+ return 0;
4507
+ }
4508
+ const DECIDIO_PLUGIN = "decidio-approvals";
4509
+ function decidioPluginEnabled(root) {
4510
+ const home = process.platform === "win32" ? (SHELL_ENV.USERPROFILE ?? homedir()) : (SHELL_ENV.HOME ?? homedir());
4511
+ for (const f of [joinPath(root, ".claude", "settings.local.json"), joinPath(root, ".claude", "settings.json"), joinPath(home, ".claude", "settings.json")]) {
4512
+ const ep = readJsonQuiet(f)?.enabledPlugins;
4513
+ if (!ep || typeof ep !== "object" || Array.isArray(ep))
4514
+ continue;
4515
+ const named = Object.entries(ep).find(([k]) => k.split("@")[0] === DECIDIO_PLUGIN);
4516
+ if (named)
4517
+ return named[1] === true;
4518
+ }
4519
+ return false;
4520
+ }
4521
+ async function cmdDisconnect() {
4522
+ const root = projectRoot();
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";
4527
+ if (!c) {
4528
+ log(`• ${where} isn't connected - nothing to do.`);
4529
+ return 0;
4530
+ }
4531
+ try {
4532
+ writeSecretFile(connectionFile(scope), JSON.stringify({ ...c, token: "", jti: "", disconnected: true }) + "\n");
4533
+ }
4534
+ catch (e) {
4535
+ err(`✘ couldn't update ${connectionFile(scope)} (${e?.message ?? e}) - delete it by hand to disconnect.`);
4536
+ return 1;
4537
+ }
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).`);
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` : ""}.`));
4541
+ return 0;
4542
+ }
3749
4543
  async function inviteApprover(email) {
3750
4544
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
3751
4545
  err(`✘ --approver needs an email address (got "${email}")`);
@@ -4911,7 +5705,7 @@ async function cmdGithubInit(opts) {
4911
5705
  log(` ${line}`);
4912
5706
  }
4913
5707
  const agentsRel = "AGENTS.md", agentsAbs = joinPath(root, agentsRel);
4914
- const appUrl = (process.env.DECIDIO_APP_URL || apiUrl.replace(/^https:\/\/(decidio-api\.onrender\.com|mcp\.decidioai\.com)$/, "https://app.decidioai.com")).replace(/\/$/, "");
5708
+ const appUrl = appUrlFor(apiUrl);
4915
5709
  const section = agentsMdSection(appUrl);
4916
5710
  if (!plainRepoPath(root, agentsRel))
4917
5711
  err(`✘ ${agentsRel} is a symbolic link - nothing was written through it`);
@@ -5025,6 +5819,21 @@ APPROVALS AND RECEIPTS
5025
5819
  (never the receipt's own issuer field; --allow-unpinned: structural check only)
5026
5820
 
5027
5821
  CODING AGENTS (Claude Code)
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.
5826
+ npx @decidio/sdk connect --wait finish a connect started by an assistant: returns once it is allowed, within 90 s
5827
+ (exit 0 connected · 3 refused or expired · 1 still waiting, run it again)
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)
5028
5837
  npx @decidio/sdk hook install [--project] [--approver <email>] [--cli <path>]
5029
5838
  a person approves Claude Code's destructive shell commands before they run
5030
5839
  (rm -rf, git push --force, DROP TABLE, terraform apply, kubectl delete,
@@ -5045,6 +5854,11 @@ CODING AGENTS (Claude Code)
5045
5854
  (90 s; at most 120 s) for the approver, then refused with its decision id; the SAME command
5046
5855
  retried after approval runs once - its execution is recorded and that
5047
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.
5048
5862
 
5049
5863
  CONTEXT PACKS (Claude Code, Claude.ai, ChatGPT)
5050
5864
  npx @decidio/sdk context create <slug> --title "ACME application" --trigger "whenever we discuss the ACME application"
@@ -5129,7 +5943,8 @@ NOTES
5129
5943
  Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error ·
5130
5944
  3 quickstart stopped at the human moment (headless: routed, but nothing executed and no
5131
5945
  receipt written — pass --auto-approve-demo for a run that completes unattended), or
5132
- hook wait: the approver said no. hook pretool/posttool always exit 0 (their answer is the JSON).
5946
+ hook wait: the approver said no, or connect --wait: the connection was refused or expired (1 there: still
5947
+ waiting - run it again). hook pretool/posttool always exit 0 (their answer is the JSON).
5133
5948
  Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
5134
5949
  pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
5135
5950
  Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
@@ -5271,13 +6086,15 @@ async function main(argv) {
5271
6086
  logout: { flags: [] },
5272
6087
  phone: { flags: [] },
5273
6088
  init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
6089
+ connect: { flags: ["--wait", "--no-browser", "--team", "--force", "--hold-everyone"], valued: ["--host", "--ticket"] },
6090
+ disconnect: { flags: [] },
5274
6091
  doctor: { flags: [] },
5275
6092
  approvals: { flags: ["--json", "--mine"], valued: ["--reason"] },
5276
6093
  receipt: { flags: ["--json"] },
5277
6094
  verify: { flags: ["--allow-unpinned"], valued: ["--issuer"] },
5278
6095
  tokens: { flags: ["--mine"] },
5279
6096
  dev: { flags: [], valued: ["--target", "--port"] },
5280
- hook: { flags: ["--project"], valued: ["--timeout", "--approver", "--cli"] },
6097
+ hook: { flags: ["--project"], valued: ["--timeout", "--approver", "--cli", "--unconnected"] },
5281
6098
  github: { flags: ["--agents-md"], valued: ["--environment", "--justification", "--decision", "--workflow", "--api"] },
5282
6099
  deploys: { flags: ["--watch", "--json"], valued: ["--environment", "--repo", "--limit"] },
5283
6100
  controls: { flags: ["--json"] },
@@ -5320,7 +6137,7 @@ async function main(argv) {
5320
6137
  return 2;
5321
6138
  }
5322
6139
  }
5323
- const MAX_POS = { login: 1, logout: 0, phone: 0, quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0, hook: 2, github: 1, decisions: 1 };
6140
+ const MAX_POS = { login: 1, logout: 0, phone: 0, quickstart: 1, init: 1, connect: 0, disconnect: 0, doctor: 0, receipt: 1, tokens: 2, dev: 0, hook: 2, github: 1, decisions: 1 };
5324
6141
  const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || (cmd === "hook" && positionals[0] === "justify") || cmd === "verify";
5325
6142
  if (!exemptTail && cmd === "approvals" && positionals.length > 0) {
5326
6143
  err(`✘ unknown approvals subcommand "${positionals[0]}" — use approve or reject (or no subcommand to list).`);
@@ -5478,11 +6295,14 @@ async function main(argv) {
5478
6295
  }
5479
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") });
5480
6297
  }
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") });
6300
+ case "disconnect": return cmdDisconnect();
5481
6301
  case "hook": {
5482
- const VALUED = ["--timeout", "--approver", "--cli"];
6302
+ const VALUED = ["--timeout", "--approver", "--cli", "--unconnected"];
5483
6303
  const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
5484
6304
  const sub = pos[0];
5485
- 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: [] };
5486
6306
  const given = argsIn.filter((x) => x.startsWith("--")).map((x) => x.split("=")[0]);
5487
6307
  if (sub && allowed[sub]) {
5488
6308
  const stray = given.find((f) => !allowed[sub].includes(f));
@@ -5492,8 +6312,10 @@ async function main(argv) {
5492
6312
  }
5493
6313
  }
5494
6314
  switch (sub) {
5495
- case "pretool": return cmdHookPretool();
6315
+ case "pretool": return cmdHookPretool({ unconnected: valuedOpt(argsIn, "--unconnected") === "tell" ? "tell" : "hold" });
5496
6316
  case "posttool": return cmdHookPosttool();
6317
+ case "session": return cmdHookSession();
6318
+ case "prompt": return cmdHookPrompt();
5497
6319
  case "wait": {
5498
6320
  const t = valuedOpt(argsIn, "--timeout") ?? "600";
5499
6321
  if (!/^\d+$/.test(t) || Number(t) < 1 || Number(t) > 86_400) {
@@ -5506,7 +6328,7 @@ async function main(argv) {
5506
6328
  case "rules": return cmdHookRules();
5507
6329
  case "justify": return cmdHookJustify(pos[1], pos.slice(2).join(" "));
5508
6330
  default:
5509
- 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}`);
5510
6332
  return 2;
5511
6333
  }
5512
6334
  }