@squadrant-ai/auto-gate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +2 -0
  2. package/dist/adapters/claude/blocked-signal.d.ts +16 -0
  3. package/dist/adapters/claude/blocked-signal.js +21 -0
  4. package/dist/adapters/claude/install.d.ts +12 -0
  5. package/dist/adapters/claude/install.js +52 -0
  6. package/dist/adapters/claude/output.d.ts +5 -0
  7. package/dist/adapters/claude/output.js +16 -0
  8. package/dist/adapters/claude/payload.d.ts +14 -0
  9. package/dist/adapters/claude/payload.js +36 -0
  10. package/dist/adapters/claude/settings.d.ts +14 -0
  11. package/dist/adapters/claude/settings.js +48 -0
  12. package/dist/adapters/opencode/answer.d.ts +24 -0
  13. package/dist/adapters/opencode/answer.js +25 -0
  14. package/dist/adapters/opencode/install.d.ts +5 -0
  15. package/dist/adapters/opencode/install.js +30 -0
  16. package/dist/adapters/opencode/intent.d.ts +10 -0
  17. package/dist/adapters/opencode/intent.js +11 -0
  18. package/dist/adapters/opencode/lock.d.ts +18 -0
  19. package/dist/adapters/opencode/lock.js +72 -0
  20. package/dist/adapters/opencode/port.d.ts +17 -0
  21. package/dist/adapters/opencode/port.js +29 -0
  22. package/dist/adapters/opencode/run.d.ts +40 -0
  23. package/dist/adapters/opencode/run.js +69 -0
  24. package/dist/adapters/opencode/sse.d.ts +18 -0
  25. package/dist/adapters/opencode/sse.js +29 -0
  26. package/dist/adapters/opencode/watch.d.ts +43 -0
  27. package/dist/adapters/opencode/watch.js +138 -0
  28. package/dist/classifiers/battery.d.ts +26 -0
  29. package/dist/classifiers/battery.js +27 -0
  30. package/dist/classifiers/classify.d.ts +15 -0
  31. package/dist/classifiers/classify.js +25 -0
  32. package/dist/classifiers/generative.d.ts +16 -0
  33. package/dist/classifiers/generative.js +50 -0
  34. package/dist/classifiers/jev-parse.d.ts +6 -0
  35. package/dist/classifiers/jev-parse.js +35 -0
  36. package/dist/classifiers/jev-state.d.ts +10 -0
  37. package/dist/classifiers/jev-state.js +13 -0
  38. package/dist/classifiers/jev.d.ts +15 -0
  39. package/dist/classifiers/jev.js +66 -0
  40. package/dist/classifiers/null.d.ts +6 -0
  41. package/dist/classifiers/null.js +9 -0
  42. package/dist/cli/claude.d.ts +21 -0
  43. package/dist/cli/claude.js +20 -0
  44. package/dist/cli/decide.d.ts +17 -0
  45. package/dist/cli/decide.js +16 -0
  46. package/dist/cli/doctor.d.ts +4 -0
  47. package/dist/cli/doctor.js +26 -0
  48. package/dist/cli/index.d.ts +2 -0
  49. package/dist/cli/index.js +109 -0
  50. package/dist/cli/runtime.d.ts +19 -0
  51. package/dist/cli/runtime.js +86 -0
  52. package/dist/cli/stats.d.ts +7 -0
  53. package/dist/cli/stats.js +29 -0
  54. package/dist/cli/test.d.ts +2 -0
  55. package/dist/cli/test.js +9 -0
  56. package/dist/config/keys.d.ts +8 -0
  57. package/dist/config/keys.js +17 -0
  58. package/dist/config/resolve.d.ts +17 -0
  59. package/dist/config/resolve.js +52 -0
  60. package/dist/config/schema.d.ts +38 -0
  61. package/dist/config/schema.js +19 -0
  62. package/dist/core/audit.d.ts +21 -0
  63. package/dist/core/audit.js +12 -0
  64. package/dist/core/cache.d.ts +22 -0
  65. package/dist/core/cache.js +59 -0
  66. package/dist/core/decide.d.ts +26 -0
  67. package/dist/core/decide.js +75 -0
  68. package/dist/core/policy-version.d.ts +2 -0
  69. package/dist/core/policy-version.js +14 -0
  70. package/dist/core/policy.d.ts +2 -0
  71. package/dist/core/policy.js +36 -0
  72. package/dist/core/presets.d.ts +9 -0
  73. package/dist/core/presets.js +15 -0
  74. package/dist/core/redact.d.ts +7 -0
  75. package/dist/core/redact.js +26 -0
  76. package/dist/core/scope.d.ts +2 -0
  77. package/dist/core/scope.js +7 -0
  78. package/dist/core/tier1.d.ts +3 -0
  79. package/dist/core/tier1.js +16 -0
  80. package/dist/core/tools.d.ts +5 -0
  81. package/dist/core/tools.js +21 -0
  82. package/dist/core/types.d.ts +68 -0
  83. package/dist/core/types.js +1 -0
  84. package/dist/index.d.ts +30 -0
  85. package/dist/index.js +30 -0
  86. package/dist/integration/host.d.ts +28 -0
  87. package/dist/integration/host.js +37 -0
  88. package/dist/integration/project.d.ts +24 -0
  89. package/dist/integration/project.js +19 -0
  90. package/package.json +37 -0
package/README.md ADDED
@@ -0,0 +1,2 @@
1
+ # auto-gate
2
+ Provider-agnostic, cross-agent auto permission gate (Jev-backed). Design: squadrant #828.
@@ -0,0 +1,16 @@
1
+ export interface BlockedContext {
2
+ agent: string;
3
+ tool: string;
4
+ reason: string;
5
+ project?: string;
6
+ gateId?: string;
7
+ }
8
+ export interface BlockedSignalDeps {
9
+ command: string | null;
10
+ exec?: (cmd: string, env: Record<string, string>) => Promise<unknown>;
11
+ log?: (m: string) => void;
12
+ }
13
+ /** The standalone analogue of squadrant's #560 `task.blocked` signal (§3 bridge
14
+ * interface 3). Best-effort: an unset or failing command NEVER throws — the
15
+ * agent's own dialog is still the fallback. */
16
+ export declare function createBlockedSignal(deps: BlockedSignalDeps): (ctx: BlockedContext) => Promise<void>;
@@ -0,0 +1,21 @@
1
+ /** The standalone analogue of squadrant's #560 `task.blocked` signal (§3 bridge
2
+ * interface 3). Best-effort: an unset or failing command NEVER throws — the
3
+ * agent's own dialog is still the fallback. */
4
+ export function createBlockedSignal(deps) {
5
+ const exec = deps.exec ?? (async () => ({}));
6
+ return async (ctx) => {
7
+ if (!deps.command)
8
+ return;
9
+ try {
10
+ await exec(deps.command, {
11
+ AUTO_GATE_TOOL: ctx.tool,
12
+ AUTO_GATE_REASON: ctx.reason,
13
+ ...(ctx.project ? { AUTO_GATE_PROJECT: ctx.project } : {}),
14
+ ...(ctx.gateId ? { AUTO_GATE_GATE_ID: ctx.gateId } : {}),
15
+ });
16
+ }
17
+ catch (e) {
18
+ deps.log?.(`blocked-signal failed: ${e.message}`);
19
+ }
20
+ };
21
+ }
@@ -0,0 +1,12 @@
1
+ export interface InstallResult {
2
+ installed: boolean;
3
+ deferredTo?: "squadrant";
4
+ reason?: string;
5
+ }
6
+ /** Idempotent, non-clobbering. DEFERS when squadrant owns the event (§15 #5). */
7
+ export declare function installClaude(o?: {
8
+ settingsPath?: string;
9
+ }): InstallResult;
10
+ export declare function uninstallClaude(o?: {
11
+ settingsPath?: string;
12
+ }): InstallResult;
@@ -0,0 +1,52 @@
1
+ import { copyFileSync, existsSync, readFileSync, renameSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { addOurHandler, removeOurHandler, foreignOwnerOf } from "./settings.js";
5
+ function settingsPathOf(o) {
6
+ return o.settingsPath ?? join(homedir(), ".claude", "settings.json");
7
+ }
8
+ function readSettings(p) {
9
+ if (!existsSync(p))
10
+ return { missing: true };
11
+ try {
12
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
13
+ return parsed && typeof parsed === "object" ? { json: parsed } : { malformed: true };
14
+ }
15
+ catch {
16
+ return { malformed: true };
17
+ }
18
+ }
19
+ function writeWithBackup(p, json) {
20
+ mkdirSync(dirname(p), { recursive: true });
21
+ if (existsSync(p))
22
+ copyFileSync(p, `${p}.backup`);
23
+ const tmp = `${p}.${process.pid}.tmp`;
24
+ writeFileSync(tmp, JSON.stringify(json, null, 2) + "\n");
25
+ renameSync(tmp, p);
26
+ }
27
+ /** Idempotent, non-clobbering. DEFERS when squadrant owns the event (§15 #5). */
28
+ export function installClaude(o = {}) {
29
+ const p = settingsPathOf(o);
30
+ const { json, malformed } = readSettings(p);
31
+ if (malformed)
32
+ return { installed: false, reason: "malformed settings file — refusing to blind-reset" };
33
+ const base = json ?? {};
34
+ const owner = foreignOwnerOf(base);
35
+ if (owner)
36
+ return { installed: false, deferredTo: owner };
37
+ const next = addOurHandler(base, "claude permission-request");
38
+ if (JSON.stringify(next) === JSON.stringify(base))
39
+ return { installed: true }; // already ours
40
+ writeWithBackup(p, next);
41
+ return { installed: true };
42
+ }
43
+ export function uninstallClaude(o = {}) {
44
+ const p = settingsPathOf(o);
45
+ const { json, malformed } = readSettings(p);
46
+ if (malformed || !json)
47
+ return { installed: false, reason: "nothing to uninstall" };
48
+ const next = removeOurHandler(json);
49
+ if (JSON.stringify(next) !== JSON.stringify(json))
50
+ writeWithBackup(p, next);
51
+ return { installed: true };
52
+ }
@@ -0,0 +1,5 @@
1
+ import type { GateOutcome } from "../../core/types.js";
2
+ /** `ask`/`yield` return undefined ⇒ the caller prints NOTHING so the normal
3
+ * permission dialog appears (§11). NOTE: this is `hookSpecificOutput.decision.
4
+ * behavior`, NOT `permissionDecision` (that shape is PreToolUse-only). */
5
+ export declare function formatClaudeHookOutput(outcome: GateOutcome): string | undefined;
@@ -0,0 +1,16 @@
1
+ /** `ask`/`yield` return undefined ⇒ the caller prints NOTHING so the normal
2
+ * permission dialog appears (§11). NOTE: this is `hookSpecificOutput.decision.
3
+ * behavior`, NOT `permissionDecision` (that shape is PreToolUse-only). */
4
+ export function formatClaudeHookOutput(outcome) {
5
+ if (outcome.decision !== "allow" && outcome.decision !== "deny")
6
+ return undefined;
7
+ return JSON.stringify({
8
+ hookSpecificOutput: {
9
+ hookEventName: "PermissionRequest",
10
+ decision: {
11
+ behavior: outcome.decision,
12
+ ...(outcome.decision === "deny" ? { message: outcome.reason } : {}),
13
+ },
14
+ },
15
+ });
16
+ }
@@ -0,0 +1,14 @@
1
+ import type { GateRequest } from "../../core/types.js";
2
+ export interface ClaudeHookPayload {
3
+ hook_event_name?: string;
4
+ session_id?: string;
5
+ transcript_path?: string;
6
+ cwd?: string;
7
+ tool_name?: string;
8
+ tool_input?: Record<string, unknown>;
9
+ permission_mode?: string;
10
+ }
11
+ /** Only the bare executable field (spec §6.2). Never the whole tool_input. */
12
+ export declare function extractToolPayload(toolName: string, input?: Record<string, unknown>): string;
13
+ /** Pure. `readIntent` is injected so this stays free of fs/transcript knowledge. */
14
+ export declare function mapClaudeHookPayload(p: ClaudeHookPayload, env: Record<string, string | undefined>, readIntent: (transcriptPath: string | undefined) => string | undefined): GateRequest;
@@ -0,0 +1,36 @@
1
+ /** Only the bare executable field (spec §6.2). Never the whole tool_input. */
2
+ export function extractToolPayload(toolName, input = {}) {
3
+ switch (toolName) {
4
+ case "Bash": return String(input.command ?? "");
5
+ case "Write": return `${input.file_path ?? ""}\n${input.content ?? ""}`;
6
+ case "Edit":
7
+ case "MultiEdit": return `${input.file_path ?? ""}\n${input.old_string ?? ""}\n${input.new_string ?? ""}`;
8
+ case "NotebookEdit": return `${input.notebook_path ?? ""}\n${input.new_source ?? ""}`;
9
+ default: return JSON.stringify(input);
10
+ }
11
+ }
12
+ function sessionKindFromEnv(env) {
13
+ if (env.SQUADRANT_CREW_TASK_ID)
14
+ return "crew";
15
+ if (env.SQUADRANT_SIDE_SESSION === "1")
16
+ return "side";
17
+ if (env.SQUADRANT_ROLE === "captain")
18
+ return "captain";
19
+ return "standalone";
20
+ }
21
+ /** Pure. `readIntent` is injected so this stays free of fs/transcript knowledge. */
22
+ export function mapClaudeHookPayload(p, env, readIntent) {
23
+ const intent = readIntent(p.transcript_path);
24
+ const intentSupport = intent ? "available" : "pending";
25
+ return {
26
+ agent: "claude",
27
+ toolName: p.tool_name ?? "Unknown",
28
+ toolPayload: extractToolPayload(p.tool_name ?? "Unknown", p.tool_input),
29
+ cwd: p.cwd ?? process.cwd(),
30
+ ...(intent ? { userIntent: intent } : {}),
31
+ intentSupport,
32
+ ...(p.permission_mode ? { permissionMode: p.permission_mode } : {}),
33
+ sessionKind: sessionKindFromEnv(env),
34
+ raw: { session_id: p.session_id },
35
+ };
36
+ }
@@ -0,0 +1,14 @@
1
+ export declare const OUR_MARKER = " # auto-gate-managed";
2
+ export declare const OUR_COMMAND = "auto-gate decide --agent claude # auto-gate-managed";
3
+ type Json = Record<string, unknown>;
4
+ /** True when a command string is ours — matched by the marker OR by the parsed
5
+ * argv basename `auto-gate`, so `npx @squadrant/auto-gate …` / `node <path> …`
6
+ * forms are still recognized (§15 #5). */
7
+ export declare function isOurCommand(command: string): boolean;
8
+ /** Idempotent, non-clobbering: appends our handler to the PermissionRequest list. */
9
+ export declare function addOurHandler(settings: Json, _sub: string): Json;
10
+ /** Removes ONLY our handlers; drops entries left empty. Never touches a user's. */
11
+ export declare function removeOurHandler(settings: Json): Json;
12
+ /** "squadrant" when squadrant owns the PermissionRequest event, else null (§15 #5). */
13
+ export declare function foreignOwnerOf(settings: Json): "squadrant" | null;
14
+ export {};
@@ -0,0 +1,48 @@
1
+ export const OUR_MARKER = " # auto-gate-managed";
2
+ export const OUR_COMMAND = `auto-gate decide --agent claude${OUR_MARKER}`;
3
+ /** squadrant's managed PermissionRequest command (exact string, #782). */
4
+ const SQUADRANT_COMMAND = "squadrant gate claude permission-request";
5
+ /** True when a command string is ours — matched by the marker OR by the parsed
6
+ * argv basename `auto-gate`, so `npx @squadrant/auto-gate …` / `node <path> …`
7
+ * forms are still recognized (§15 #5). */
8
+ export function isOurCommand(command) {
9
+ if (command.includes(OUR_MARKER.trim()))
10
+ return true;
11
+ const first = command.trim().split(/\s+/)[0] ?? "";
12
+ return /(^|[/\\])auto-gate$/.test(first);
13
+ }
14
+ function entries(settings) {
15
+ const hooks = (settings.hooks ?? {});
16
+ const list = hooks.PermissionRequest;
17
+ return Array.isArray(list) ? list : [];
18
+ }
19
+ function setEntries(settings, list) {
20
+ const hooks = { ...(settings.hooks ?? {}) };
21
+ hooks.PermissionRequest = list;
22
+ return { ...settings, hooks };
23
+ }
24
+ /** Idempotent, non-clobbering: appends our handler to the PermissionRequest list. */
25
+ export function addOurHandler(settings, _sub) {
26
+ const list = entries(settings);
27
+ const already = list.some((e) => Array.isArray(e.hooks) && e.hooks.some((h) => isOurCommand(String(h.command ?? ""))));
28
+ if (already)
29
+ return settings;
30
+ return setEntries(settings, [...list, { hooks: [{ type: "command", command: OUR_COMMAND }] }]);
31
+ }
32
+ /** Removes ONLY our handlers; drops entries left empty. Never touches a user's. */
33
+ export function removeOurHandler(settings) {
34
+ const next = [];
35
+ for (const entry of entries(settings)) {
36
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
37
+ const kept = hooks.filter((h) => !isOurCommand(String(h.command ?? "")));
38
+ if (kept.length > 0)
39
+ next.push({ ...entry, hooks: kept });
40
+ }
41
+ return setEntries(settings, next);
42
+ }
43
+ /** "squadrant" when squadrant owns the PermissionRequest event, else null (§15 #5). */
44
+ export function foreignOwnerOf(settings) {
45
+ const owns = entries(settings).some((e) => Array.isArray(e.hooks) &&
46
+ e.hooks.some((h) => String(h.command ?? "") === SQUADRANT_COMMAND));
47
+ return owns ? "squadrant" : null;
48
+ }
@@ -0,0 +1,24 @@
1
+ import type { GateOutcome } from "../../core/types.js";
2
+ /** §11: the opencode answer vocabulary. `ask`/`yield` have NO answer — the native
3
+ * prompt stays pending (which is correct only when a human is watching). */
4
+ export declare function answerFor(o: GateOutcome): "once" | "reject" | undefined;
5
+ /** Attendance is an EXPLICIT marker (§15 #4), never TTY sniffing — the watcher's
6
+ * TTY is not the crew's TTY. */
7
+ export declare function shouldLeavePending(o: {
8
+ attended: boolean;
9
+ }): boolean;
10
+ export interface AskOpts {
11
+ attended: boolean;
12
+ onAsk?: (ctx: {
13
+ tool: string;
14
+ reason: string;
15
+ }) => Promise<void>;
16
+ timeoutMs: number;
17
+ tool: string;
18
+ reason: string;
19
+ sleep?: (ms: number) => Promise<void>;
20
+ now?: () => number;
21
+ }
22
+ /** Unattended `ask`: fire the notification hook, then DENY on timeout so the crew
23
+ * never hangs. The deny is audited+notified by the caller, never silent (§15 #4). */
24
+ export declare function handleAsk(o: AskOpts): Promise<"reject" | undefined>;
@@ -0,0 +1,25 @@
1
+ /** §11: the opencode answer vocabulary. `ask`/`yield` have NO answer — the native
2
+ * prompt stays pending (which is correct only when a human is watching). */
3
+ export function answerFor(o) {
4
+ if (o.decision === "allow")
5
+ return "once";
6
+ if (o.decision === "deny")
7
+ return "reject";
8
+ return undefined;
9
+ }
10
+ /** Attendance is an EXPLICIT marker (§15 #4), never TTY sniffing — the watcher's
11
+ * TTY is not the crew's TTY. */
12
+ export function shouldLeavePending(o) { return o.attended; }
13
+ /** Unattended `ask`: fire the notification hook, then DENY on timeout so the crew
14
+ * never hangs. The deny is audited+notified by the caller, never silent (§15 #4). */
15
+ export async function handleAsk(o) {
16
+ if (o.attended)
17
+ return undefined;
18
+ try {
19
+ await o.onAsk?.({ tool: o.tool, reason: o.reason });
20
+ }
21
+ catch { /* best-effort */ }
22
+ const sleep = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
23
+ await sleep(o.timeoutMs);
24
+ return "reject";
25
+ }
@@ -0,0 +1,5 @@
1
+ type Json = Record<string, unknown>;
2
+ export declare function mergePermissionBlock(cfg: Json, tools: string[]): Json;
3
+ /** Removes only the `ask` values we set; a user's own value is preserved. */
4
+ export declare function unmergePermissionBlock(cfg: Json): Json;
5
+ export {};
@@ -0,0 +1,30 @@
1
+ /** The canonical scope → the native opencode permission keys it maps onto. The
2
+ * alias table is §12's (set-valued): `edit` covers write/patch too. */
3
+ const CANONICAL_TO_NATIVE = {
4
+ Bash: ["bash"], Write: ["edit"], Edit: ["edit"], MultiEdit: ["edit"], NotebookEdit: ["edit"],
5
+ };
6
+ function nativeKeys(tools) {
7
+ const keys = new Set();
8
+ for (const t of tools)
9
+ for (const k of CANONICAL_TO_NATIVE[t] ?? [])
10
+ keys.add(k);
11
+ return [...keys].sort();
12
+ }
13
+ export function mergePermissionBlock(cfg, tools) {
14
+ const permission = { ...(cfg.permission ?? {}) };
15
+ for (const k of nativeKeys(tools)) {
16
+ if (permission[k] === "ask")
17
+ continue; // already ours ⇒ idempotent
18
+ if (permission[k] === undefined)
19
+ permission[k] = "ask";
20
+ }
21
+ return { ...cfg, permission };
22
+ }
23
+ /** Removes only the `ask` values we set; a user's own value is preserved. */
24
+ export function unmergePermissionBlock(cfg) {
25
+ const permission = { ...(cfg.permission ?? {}) };
26
+ for (const k of ["bash", "edit"])
27
+ if (permission[k] === "ask")
28
+ delete permission[k];
29
+ return { ...cfg, permission };
30
+ }
@@ -0,0 +1,10 @@
1
+ import type { SseEvent } from "./sse.js";
2
+ import type { IntentSupport } from "../../core/types.js";
3
+ export interface ReconstructedIntent {
4
+ text?: string;
5
+ support: IntentSupport;
6
+ }
7
+ /** §15 #3: reconstruct the last HUMAN message from the frame sequence. When none
8
+ * has been seen yet the support is `pending` — the policy engine then asks, so
9
+ * an unknown intent can never produce a silent auto-allow. */
10
+ export declare function reconstructIntent(sessionID: string, frames: SseEvent[]): ReconstructedIntent;
@@ -0,0 +1,11 @@
1
+ /** §15 #3: reconstruct the last HUMAN message from the frame sequence. When none
2
+ * has been seen yet the support is `pending` — the policy engine then asks, so
3
+ * an unknown intent can never produce a silent auto-allow. */
4
+ export function reconstructIntent(sessionID, frames) {
5
+ let text;
6
+ for (const f of frames) {
7
+ if (f.kind === "user-text" && f.sessionID === sessionID)
8
+ text = f.text;
9
+ }
10
+ return text ? { text, support: "available" } : { support: "pending" };
11
+ }
@@ -0,0 +1,18 @@
1
+ /** Mirrors the #830 telegram poll-lock shape so there is one lock idiom in the codebase. */
2
+ export declare function lockPathFor(host: string, port: number, dir?: string): string;
3
+ export interface AcquireOpts {
4
+ host: string;
5
+ port: number;
6
+ dir?: string;
7
+ pid?: number;
8
+ now?: () => number;
9
+ }
10
+ export interface AcquireResult {
11
+ ok: boolean;
12
+ release?(): void;
13
+ }
14
+ /** Atomic O_EXCL acquire. A dead owner's lock is reclaimed at most once. The
15
+ * loser YIELDS PERMANENTLY (never loops) — see §15 #7 contention policy. */
16
+ export declare function acquireLock(o: AcquireOpts): AcquireResult;
17
+ /** Exposed for the watcher: is the lock present and live? */
18
+ export declare function lockHeldByOther(o: AcquireOpts): boolean;
@@ -0,0 +1,72 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync, mkdirSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ const STALE_MS = 2 * 60_000;
6
+ /** Mirrors the #830 telegram poll-lock shape so there is one lock idiom in the codebase. */
7
+ export function lockPathFor(host, port, dir = tmpdir()) {
8
+ const hash = createHash("sha256").update(`${host}:${port}`).digest("hex").slice(0, 16);
9
+ return join(dir, `auto-gate-opencode-${hash}.lock`);
10
+ }
11
+ function pidAlive(pid) {
12
+ try {
13
+ process.kill(pid, 0);
14
+ return true;
15
+ }
16
+ catch (e) {
17
+ return e.code === "EPERM";
18
+ }
19
+ }
20
+ /** Atomic O_EXCL acquire. A dead owner's lock is reclaimed at most once. The
21
+ * loser YIELDS PERMANENTLY (never loops) — see §15 #7 contention policy. */
22
+ export function acquireLock(o) {
23
+ const dir = o.dir ?? tmpdir();
24
+ const pid = o.pid ?? process.pid;
25
+ const file = lockPathFor(o.host, o.port, dir);
26
+ mkdirSync(dir, { recursive: true });
27
+ for (let attempt = 0; attempt < 2; attempt++) {
28
+ try {
29
+ writeFileSync(file, String(pid), { flag: "wx" });
30
+ return { ok: true, release: () => { try {
31
+ unlinkSync(file);
32
+ }
33
+ catch { /* already gone */ } } };
34
+ }
35
+ catch (e) {
36
+ if (e.code !== "EEXIST")
37
+ return { ok: false };
38
+ let owner;
39
+ let ageMs = Infinity;
40
+ try {
41
+ owner = Number.parseInt(readFileSync(file, "utf8").trim(), 10);
42
+ ageMs = Date.now() - statSync(file).mtimeMs;
43
+ }
44
+ catch { /* absent */ }
45
+ const held = owner !== undefined && Number.isFinite(owner) ? pidAlive(owner) : ageMs < STALE_MS;
46
+ if (attempt === 0 && !held) {
47
+ try {
48
+ unlinkSync(file);
49
+ }
50
+ catch { /* raced */ }
51
+ continue;
52
+ }
53
+ return { ok: false };
54
+ }
55
+ }
56
+ return { ok: false };
57
+ }
58
+ /** Exposed for the watcher: is the lock present and live? */
59
+ export function lockHeldByOther(o) {
60
+ const file = lockPathFor(o.host, o.port, o.dir ?? tmpdir());
61
+ if (!existsSync(file))
62
+ return false;
63
+ try {
64
+ const pid = Number.parseInt(readFileSync(file, "utf8").trim(), 10);
65
+ if (Number.isFinite(pid))
66
+ return pidAlive(pid) && pid !== (o.pid ?? process.pid);
67
+ return Date.now() - statSync(file).mtimeMs < STALE_MS;
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
@@ -0,0 +1,17 @@
1
+ export declare const ENV_PORT = "AUTO_GATE_OPENCODE_PORT";
2
+ export interface PortInput {
3
+ flag?: number;
4
+ env?: Record<string, string | undefined>;
5
+ }
6
+ export type PortResult = {
7
+ port: number;
8
+ source: "flag" | "env";
9
+ } | {
10
+ error: string;
11
+ };
12
+ /** Precedence: explicit --port > AUTO_GATE_OPENCODE_PORT > error. There is NO
13
+ * default — a fixed default would collide across concurrent crews (§15 #1). */
14
+ export declare function resolvePort(i: PortInput): PortResult;
15
+ /** Bind :0, read the assigned port, release. The window is tiny and the wrapper
16
+ * passes the same N to opencode, so the race is a no-op in practice. */
17
+ export declare function allocatePort(): Promise<number>;
@@ -0,0 +1,29 @@
1
+ import { createServer } from "node:net";
2
+ export const ENV_PORT = "AUTO_GATE_OPENCODE_PORT";
3
+ function valid(n) { return Number.isInteger(n) && n > 0 && n < 65536; }
4
+ /** Precedence: explicit --port > AUTO_GATE_OPENCODE_PORT > error. There is NO
5
+ * default — a fixed default would collide across concurrent crews (§15 #1). */
6
+ export function resolvePort(i) {
7
+ const env = i.env ?? process.env;
8
+ if (i.flag !== undefined)
9
+ return valid(i.flag) ? { port: i.flag, source: "flag" } : { error: `invalid --port ${i.flag}` };
10
+ const raw = env[ENV_PORT];
11
+ if (raw !== undefined) {
12
+ const n = Number(raw);
13
+ return valid(n) ? { port: n, source: "env" } : { error: `invalid ${ENV_PORT} '${raw}'` };
14
+ }
15
+ return { error: `no port: pass --port N or set ${ENV_PORT}` };
16
+ }
17
+ /** Bind :0, read the assigned port, release. The window is tiny and the wrapper
18
+ * passes the same N to opencode, so the race is a no-op in practice. */
19
+ export function allocatePort() {
20
+ return new Promise((resolve, reject) => {
21
+ const srv = createServer();
22
+ srv.on("error", reject);
23
+ srv.listen(0, "127.0.0.1", () => {
24
+ const addr = srv.address();
25
+ const port = typeof addr === "object" && addr ? addr.port : 0;
26
+ srv.close(() => (port ? resolve(port) : reject(new Error("could not allocate a port"))));
27
+ });
28
+ });
29
+ }
@@ -0,0 +1,40 @@
1
+ export interface RunPlan {
2
+ opencodeArgs: string[];
3
+ env: Record<string, string>;
4
+ watcherArgs: string[];
5
+ }
6
+ export interface RunInput {
7
+ args: string[];
8
+ allocate?: () => Promise<number>;
9
+ }
10
+ /** The wrapper OWNS both the port and the watcher's lifetime (§15 #1/#2): it picks
11
+ * the port, passes it to opencode, exports it so nothing has to discover it, and
12
+ * runs opencode + the watcher as one supervised unit. */
13
+ export declare function buildRunPlan(i: RunInput): Promise<RunPlan>;
14
+ export interface SpawnedProc {
15
+ pid?: number;
16
+ on(event: "exit", cb: (code: number | null) => void): void;
17
+ on(event: "error", cb: (err: Error) => void): void;
18
+ kill(signal?: NodeJS.Signals): boolean;
19
+ }
20
+ export interface SpawnOptions {
21
+ stdio: "inherit";
22
+ detached?: boolean;
23
+ env?: NodeJS.ProcessEnv;
24
+ }
25
+ export type SpawnFn = (cmd: string, args: string[], opts: SpawnOptions) => SpawnedProc;
26
+ export interface SupervisorDeps {
27
+ spawn?: SpawnFn;
28
+ killPid?: (pid: number, signal?: NodeJS.Signals) => void;
29
+ log?: (m: string) => void;
30
+ }
31
+ /** Documented supervision contract, asserted by the test so it cannot silently
32
+ * drift: watcher death ⇒ kill opencode, exit with the watcher's code (unattended
33
+ * must not hang); opencode exit ⇒ SIGTERM the watcher (§15 #2). */
34
+ export declare const runPolicy: "watcher-death-kills-opencode";
35
+ /** Runs opencode and the watcher as one supervised unit and resolves with the
36
+ * exit code the wrapper should propagate. `spawn`/`killPid` are injectable so the
37
+ * process-group supervision is testable without a live opencode. opencode is
38
+ * spawned detached so it leads its own process group — node cannot join an
39
+ * existing pgid, so `process.kill(-pid)` targets opencode's group. */
40
+ export declare function supervise(plan: RunPlan, deps?: SupervisorDeps): Promise<number>;
@@ -0,0 +1,69 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import { allocatePort as defaultAllocate } from "./port.js";
3
+ /** The wrapper OWNS both the port and the watcher's lifetime (§15 #1/#2): it picks
4
+ * the port, passes it to opencode, exports it so nothing has to discover it, and
5
+ * runs opencode + the watcher as one supervised unit. */
6
+ export async function buildRunPlan(i) {
7
+ if (i.args.includes("--port"))
8
+ throw new Error("args already specifies --port — the wrapper owns the port (§15#1)");
9
+ const port = await (i.allocate ?? defaultAllocate)();
10
+ return {
11
+ opencodeArgs: ["--port", String(port), ...i.args],
12
+ // D5: the watcher builds `sessionKind: "standalone"`, so without a positive
13
+ // marker `decideRequest` YIELDS every permission. Export AUTO_GATE_SESSION=1
14
+ // so `isGateSession(env)` is true in the watcher child (inherited via spawn).
15
+ env: { AUTO_GATE_OPENCODE_PORT: String(port), AUTO_GATE_SESSION: "1" },
16
+ watcherArgs: ["opencode", "watch", "--port", String(port)],
17
+ };
18
+ }
19
+ /** Documented supervision contract, asserted by the test so it cannot silently
20
+ * drift: watcher death ⇒ kill opencode, exit with the watcher's code (unattended
21
+ * must not hang); opencode exit ⇒ SIGTERM the watcher (§15 #2). */
22
+ export const runPolicy = "watcher-death-kills-opencode";
23
+ buildRunPlan.__policy = runPolicy;
24
+ /** Runs opencode and the watcher as one supervised unit and resolves with the
25
+ * exit code the wrapper should propagate. `spawn`/`killPid` are injectable so the
26
+ * process-group supervision is testable without a live opencode. opencode is
27
+ * spawned detached so it leads its own process group — node cannot join an
28
+ * existing pgid, so `process.kill(-pid)` targets opencode's group. */
29
+ export function supervise(plan, deps = {}) {
30
+ const spawn = deps.spawn ?? ((cmd, args, opts) => nodeSpawn(cmd, args, opts));
31
+ const killPid = deps.killPid ?? ((pid, signal) => { process.kill(pid, signal); });
32
+ const log = deps.log;
33
+ const env = { ...process.env, ...plan.env };
34
+ const opencode = spawn("opencode", plan.opencodeArgs, { stdio: "inherit", detached: true, env });
35
+ const watcher = spawn(plan.watcherArgs[0], plan.watcherArgs.slice(1), { stdio: "inherit", env });
36
+ return new Promise((resolve) => {
37
+ let settled = false;
38
+ const settle = (code) => { if (settled)
39
+ return; settled = true; resolve(code); };
40
+ const killOpencodeGroup = () => {
41
+ if (opencode.pid === undefined)
42
+ return;
43
+ try {
44
+ killPid(-opencode.pid, "SIGTERM");
45
+ }
46
+ catch (e) {
47
+ log?.(`auto-gate opencode: group kill failed — ${e.message}`);
48
+ }
49
+ };
50
+ watcher.on("exit", (code) => {
51
+ log?.("auto-gate opencode: watcher exited — killing the opencode group (§15#2)");
52
+ killOpencodeGroup();
53
+ settle(code ?? 1);
54
+ });
55
+ watcher.on("error", (err) => {
56
+ log?.(`auto-gate opencode: watcher error — ${err.message}; killing the opencode group (§15#2)`);
57
+ killOpencodeGroup();
58
+ settle(1);
59
+ });
60
+ opencode.on("exit", (code) => {
61
+ log?.("auto-gate opencode: opencode exited — signalling the watcher (§15#2)");
62
+ try {
63
+ watcher.kill("SIGTERM");
64
+ }
65
+ catch { /* already gone */ }
66
+ settle(code ?? 0);
67
+ });
68
+ });
69
+ }
@@ -0,0 +1,18 @@
1
+ export type SseEvent = {
2
+ kind: "permission.asked";
3
+ id: string;
4
+ sessionID: string;
5
+ permission: string;
6
+ patterns: string[];
7
+ } | {
8
+ kind: "permission.replied";
9
+ id: string;
10
+ } | {
11
+ kind: "user-text";
12
+ sessionID: string;
13
+ text: string;
14
+ };
15
+ /** Pure. Unknown / heartbeat / non-JSON frames return null (the caller ignores
16
+ * them). Live-verified shape (opencode 1.15.13): permission.asked properties =
17
+ * { id:"per_…", sessionID:"ses_…", permission:"bash", patterns:[cmd] }. */
18
+ export declare function parseSseFrame(raw: string): SseEvent | null;