@scopebond/hook 0.11.0 → 0.13.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.
@@ -1 +1 @@
1
- {"version":3,"file":"cloud.d.ts","sourceRoot":"","sources":["../src/cloud.ts"],"names":[],"mappings":"AAQA,OAAO,EAEL,KAAK,qBAAqB,EAAE,KAAK,qBAAqB,EAAE,KAAK,aAAa,EAAE,KAAK,YAAY,EAC9F,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,iBAAiB,EAAwB,MAAM,yBAAyB,CAAC;AAElF;8EAC8E;AAC9E,MAAM,WAAW,cAAe,SAAQ,qBAAqB;IAC3D,GAAG,EAAE,MAAM,CAAC;IACZ;;2EAEuE;IACvE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;6CAEyC;IACzC,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC;gGAC4F;IAC5F,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;8CAE8C;AAC9C,wBAAgB,SAAS,CAAC,UAAU,EAAE,IAAI,CAAC,cAAc,EAAE,KAAK,GAAG,YAAY,CAAC,GAAG,MAAM,CAExF;AAYD,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,KAAG,MAAiC,CAAC;AAE/E,sFAAsF;AACtF,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CAQjE;AAED;;;;;;;;;qEASqE;AACrE,wBAAsB,YAAY,CAChC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,qBAAqB,EAAE,SAAS,CAAC,EAAE,OAAO,KAAK,GAChF,OAAO,CAAC,cAAc,GAAG;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAyBvE;AAED;yEACyE;AACzE,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,KAAG,MAAmC,CAAC;AAEjF,sFAAsF;AACtF,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAMlE;AAED;oFACoF;AACpF,wBAAgB,cAAc,CAC5B,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,EAAE,SAAS,CAAC,EAAE,OAAO,KAAK,GAC9F;IAAE,KAAK,EAAE,YAAY,CAAC;IAAC,QAAQ,EAAE,aAAa,CAAC;IAAC,MAAM,EAAE,iBAAiB,CAAA;CAAE,CAI7E;AAED;kFACkF;AAClF,wBAAsB,YAAY,CAAC,QAAQ,EAAE,aAAa,EAAE,SAAS,SAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAK3F"}
1
+ {"version":3,"file":"cloud.d.ts","sourceRoot":"","sources":["../src/cloud.ts"],"names":[],"mappings":"AAQA,OAAO,EAEL,KAAK,qBAAqB,EAAE,KAAK,qBAAqB,EAAE,KAAK,aAAa,EAAE,KAAK,YAAY,EAC9F,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,iBAAiB,EAAwB,MAAM,yBAAyB,CAAC;AAGlF;8EAC8E;AAC9E,MAAM,WAAW,cAAe,SAAQ,qBAAqB;IAC3D,GAAG,EAAE,MAAM,CAAC;IACZ;;2EAEuE;IACvE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;6CAEyC;IACzC,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC;gGAC4F;IAC5F,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;8CAE8C;AAC9C,wBAAgB,SAAS,CAAC,UAAU,EAAE,IAAI,CAAC,cAAc,EAAE,KAAK,GAAG,YAAY,CAAC,GAAG,MAAM,CAExF;AAYD,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,KAAG,MAAiC,CAAC;AAE/E,sFAAsF;AACtF,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CAQjE;AAED;;;;;;;;;qEASqE;AACrE,wBAAsB,YAAY,CAChC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,qBAAqB,EAAE,SAAS,CAAC,EAAE,OAAO,KAAK,GAChF,OAAO,CAAC,cAAc,GAAG;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAyBvE;AAED;yEACyE;AACzE,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,KAAG,MAAmC,CAAC;AAEjF,sFAAsF;AACtF,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAMlE;AAED;oFACoF;AACpF,wBAAgB,cAAc,CAC5B,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,EAAE,SAAS,CAAC,EAAE,OAAO,KAAK,GAC9F;IAAE,KAAK,EAAE,YAAY,CAAC;IAAC,QAAQ,EAAE,aAAa,CAAC;IAAC,MAAM,EAAE,iBAAiB,CAAA;CAAE,CAQ7E;AAED;kFACkF;AAClF,wBAAsB,YAAY,CAAC,QAAQ,EAAE,aAAa,EAAE,SAAS,SAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAK3F"}
package/dist/cloud.js CHANGED
@@ -7,6 +7,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
7
7
  import { join } from "node:path";
8
8
  import { CloudEnrollmentError, completeCloudEnrollment, createCloudExporter, withCloudExporter, } from "@scopebond/gateway";
9
9
  import { SqliteCloudOutbox, loadOrCreateAttester } from "@scopebond/gateway/node";
10
+ import { LOSSLESS_OUTBOX } from "./delivery-report.js";
10
11
  /** The address records, observations and recovery go to: the enrollment's `ingest_url` when it
11
12
  * is a valid HTTPS origin (or localhost in development), otherwise the workspace URL. Sign-in,
12
13
  * policy and the portal always use `url`. */
@@ -98,7 +99,11 @@ export function retireAttesterKey(dir, kid) {
98
99
  /** Wrap a receipt store so every stored receipt is enqueued to a durable outbox and
99
100
  * exported to Cloud. Returns the wrapped store and the exporter (flush + stop). */
100
101
  export function attachExporter(outboxDbPath, connection, store, fetchImpl) {
101
- const outbox = new SqliteCloudOutbox(outboxDbPath);
102
+ // Lossless (SB275): no cap and no expiry. A record leaves the queue only when the workspace
103
+ // accepts it, or when a key change makes it undeliverable (`recover` then sends it). The
104
+ // gateway's defaults (10,000 records, 64 MiB, 7 days) dropped the newest records once a long
105
+ // outage filled the queue.
106
+ const outbox = new SqliteCloudOutbox(outboxDbPath, LOSSLESS_OUTBOX);
102
107
  const exporter = createCloudExporter({ url: ingestUrl(connection), credential: connection.credential, outbox, fetch: fetchImpl });
103
108
  return { store: withCloudExporter(store, exporter), exporter, outbox };
104
109
  }
package/dist/cloud.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cloud.js","sourceRoot":"","sources":["../src/cloud.ts"],"names":[],"mappings":"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","sourcesContent":["// scopebond-hook → Scopebond Cloud: enroll the machine's countersigning key with a\n// workspace and auto-export signed receipts to the hosted portal, so a connected\n// hook starts monitoring automatically. Reuses the gateway's enrollment and bounded\n// durable exporter (D40 — no new transport). The machine credential and the complete\n// receipt log stay local-first; export is best-effort and never blocks a tool call.\n\nimport { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport {\n CloudEnrollmentError, completeCloudEnrollment, createCloudExporter, withCloudExporter,\n type CloudEnrollmentBundle, type CloudEnrollmentResult, type CloudExporter, type ReceiptStore,\n} from \"@scopebond/gateway\";\nimport { SqliteCloudOutbox, loadOrCreateAttester } from \"@scopebond/gateway/node\";\n\n/** The persisted connection between this machine and a Cloud workspace. Holds the\n * scoped machine credential; treat cloud.json as a secret (written 0600). */\nexport interface HookConnection extends CloudEnrollmentResult {\n url: string;\n /** The workspace's installation id for this machine. An enrollment answer names it\n * `installation_id`, or (older answers) as `gateway_id`, which is the same identifier;\n * observations use `gateway_id` when the explicit field is absent. */\n installation_id?: string;\n /** The installation generation the enrollment created. Only the workspace can say what it\n * is, so there is no fallback: without it the hook reports observations unsupported\n * rather than guessing a generation. */\n installation_generation?: number;\n /** Where this machine sends its records: the workspace's regional ingest address, named by\n * the enrollment answer. Absent from older answers and connections; `url` is used then. */\n ingest_url?: string;\n}\n\n/** The address records, observations and recovery go to: the enrollment's `ingest_url` when it\n * is a valid HTTPS origin (or localhost in development), otherwise the workspace URL. Sign-in,\n * policy and the portal always use `url`. */\nexport function ingestUrl(connection: Pick<HookConnection, \"url\" | \"ingest_url\">): string {\n return safeIngestOrigin(connection.ingest_url) ?? connection.url;\n}\n\nfunction safeIngestOrigin(value: unknown): string | null {\n if (typeof value !== \"string\" || value.length > 2048) return null;\n try {\n const parsed = new URL(value);\n const local = parsed.hostname === \"localhost\" || parsed.hostname === \"127.0.0.1\";\n if ((parsed.protocol !== \"https:\" && !(local && parsed.protocol === \"http:\")) || parsed.username || parsed.password) return null;\n return parsed.origin;\n } catch { return null; }\n}\n\nexport const connectionPath = (dir: string): string => join(dir, \"cloud.json\");\n\n/** Read the persisted connection, or null when the hook is not connected to Cloud. */\nexport function loadConnection(dir: string): HookConnection | null {\n const path = connectionPath(dir);\n if (!existsSync(path)) return null;\n try {\n const parsed = JSON.parse(readFileSync(path, \"utf8\")) as Partial<HookConnection>;\n if (typeof parsed.url === \"string\" && typeof parsed.credential === \"string\") return parsed as HookConnection;\n } catch { /* fall through */ }\n return null;\n}\n\n/** Enroll the machine's attester with a workspace using the portal's one-use handoff,\n * then persist the scoped machine credential. The attester whose possession is proved\n * here is the same key the hook countersigns receipts with, so ingest accepts them.\n *\n * A workspace refuses a key it already knows — one revoked when the computer was\n * replaced or disconnected, or one already enrolled elsewhere — without spending the\n * token. Reconnecting then replaces the key and enrolls again with the same token, so a\n * second `login` always works; the old key is kept under retired-keys/, and queued\n * receipts it signed leave the delivery queue (they stay in the local log for\n * `recover`), because the new connection can never deliver them. */\nexport async function connectCloud(\n dir: string, url: string, bundle: CloudEnrollmentBundle, fetchImpl?: typeof fetch,\n): Promise<HookConnection & { rotatedFrom?: string; setAside?: number }> {\n let { attester } = loadOrCreateAttester({ file: join(dir, \"attester.key\") });\n const { attester: agent } = loadOrCreateAttester({ file: join(dir, \"agent.key\") });\n let rotatedFrom: string | undefined;\n let result;\n try {\n result = await completeCloudEnrollment({ url, bundle, attester, agent, fetch: fetchImpl });\n } catch (error) {\n if (!(error instanceof CloudEnrollmentError) || error.code !== \"gateway_key_conflict\") throw error;\n rotatedFrom = attester.kid;\n retireAttesterKey(dir, attester.kid);\n attester = loadOrCreateAttester({ file: join(dir, \"attester.key\") }).attester;\n result = await completeCloudEnrollment({ url, bundle, attester, agent, fetch: fetchImpl });\n }\n const { ingest_url: offered, ...enrolled } = result as typeof result & { ingest_url?: unknown };\n const ingest = safeIngestOrigin(offered);\n const connection: HookConnection = { url, ...enrolled, ...(ingest ? { ingest_url: ingest } : {}) };\n writeFileSync(connectionPath(dir), JSON.stringify(connection, null, 2) + \"\\n\", { mode: 0o600 });\n let setAside = 0;\n const outboxPath = join(dir, \"receipts.db.cloud-outbox.db\");\n if (existsSync(outboxPath)) {\n const outbox = new SqliteCloudOutbox(outboxPath);\n try { setAside = outbox.discardNotSignedBy(attester.kid); } finally { outbox.close(); }\n }\n return { ...connection, ...(rotatedFrom ? { rotatedFrom } : {}), ...(setAside ? { setAside } : {}) };\n}\n\n/** Where a replaced countersigning key is kept: receipts it signed stay verifiable, and\n * `recover` can still deliver the ones the workspace never received. */\nexport const retiredKeysDir = (dir: string): string => join(dir, \"retired-keys\");\n\n/** Move the current countersigning key aside so the next load creates a fresh one. */\nexport function retireAttesterKey(dir: string, kid: string): string {\n const target = join(retiredKeysDir(dir), `${kid.replace(/[^A-Za-z0-9_-]/g, \"-\")}.key`);\n const destination = existsSync(target) ? `${target}.${Date.now()}` : target;\n mkdirSync(retiredKeysDir(dir), { recursive: true });\n renameSync(join(dir, \"attester.key\"), destination);\n return destination;\n}\n\n/** Wrap a receipt store so every stored receipt is enqueued to a durable outbox and\n * exported to Cloud. Returns the wrapped store and the exporter (flush + stop). */\nexport function attachExporter(\n outboxDbPath: string, connection: HookConnection, store: ReceiptStore, fetchImpl?: typeof fetch,\n): { store: ReceiptStore; exporter: CloudExporter; outbox: SqliteCloudOutbox } {\n const outbox = new SqliteCloudOutbox(outboxDbPath);\n const exporter = createCloudExporter({ url: ingestUrl(connection), credential: connection.credential, outbox, fetch: fetchImpl });\n return { store: withCloudExporter(store, exporter), exporter, outbox };\n}\n\n/** Attempt delivery with a bounded timeout so a per-invocation hook never hangs the\n * agent; undelivered receipts stay in the durable outbox and flush next time. */\nexport async function flushBounded(exporter: CloudExporter, timeoutMs = 3000): Promise<void> {\n await Promise.race([\n exporter.flush().catch(() => {}),\n new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, timeoutMs)).unref?.()),\n ]);\n}\n"]}
1
+ {"version":3,"file":"cloud.js","sourceRoot":"","sources":["../src/cloud.ts"],"names":[],"mappings":"AAAA,mFAAmF;AACnF,iFAAiF;AACjF,oFAAoF;AACpF,qFAAqF;AACrF,oFAAoF;AAEpF,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,YAAY,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACzF,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EACL,oBAAoB,EAAE,uBAAuB,EAAE,mBAAmB,EAAE,iBAAiB,GAEtF,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAC;AAClF,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAmBvD;;8CAE8C;AAC9C,MAAM,UAAU,SAAS,CAAC,UAAsD;IAC9E,OAAO,gBAAgB,CAAC,UAAU,CAAC,UAAU,CAAC,IAAI,UAAU,CAAC,GAAG,CAAC;AACnE,CAAC;AAED,SAAS,gBAAgB,CAAC,KAAc;IACtC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,GAAG,IAAI;QAAE,OAAO,IAAI,CAAC;IAClE,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC;QAC9B,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,KAAK,WAAW,IAAI,MAAM,CAAC,QAAQ,KAAK,WAAW,CAAC;QACjF,IAAI,CAAC,MAAM,CAAC,QAAQ,KAAK,QAAQ,IAAI,CAAC,CAAC,KAAK,IAAI,MAAM,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,IAAI,MAAM,CAAC,QAAQ,IAAI,MAAM,CAAC,QAAQ;YAAE,OAAO,IAAI,CAAC;QACjI,OAAO,MAAM,CAAC,MAAM,CAAC;IACvB,CAAC;IAAC,MAAM,CAAC;QAAC,OAAO,IAAI,CAAC;IAAC,CAAC;AAC1B,CAAC;AAED,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,GAAW,EAAU,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,YAAY,CAAC,CAAC;AAE/E,sFAAsF;AACtF,MAAM,UAAU,cAAc,CAAC,GAAW;IACxC,MAAM,IAAI,GAAG,cAAc,CAAC,GAAG,CAAC,CAAC;IACjC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IACnC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAA4B,CAAC;QACjF,IAAI,OAAO,MAAM,CAAC,GAAG,KAAK,QAAQ,IAAI,OAAO,MAAM,CAAC,UAAU,KAAK,QAAQ;YAAE,OAAO,MAAwB,CAAC;IAC/G,CAAC;IAAC,MAAM,CAAC,CAAC,kBAAkB,CAAC,CAAC;IAC9B,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;;;qEASqE;AACrE,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,GAAW,EAAE,GAAW,EAAE,MAA6B,EAAE,SAAwB;IAEjF,IAAI,EAAE,QAAQ,EAAE,GAAG,oBAAoB,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,EAAE,cAAc,CAAC,EAAE,CAAC,CAAC;IAC7E,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,oBAAoB,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,EAAE,WAAW,CAAC,EAAE,CAAC,CAAC;IACnF,IAAI,WAA+B,CAAC;IACpC,IAAI,MAAM,CAAC;IACX,IAAI,CAAC;QACH,MAAM,GAAG,MAAM,uBAAuB,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC;IAC7F,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,CAAC,CAAC,KAAK,YAAY,oBAAoB,CAAC,IAAI,KAAK,CAAC,IAAI,KAAK,sBAAsB;YAAE,MAAM,KAAK,CAAC;QACnG,WAAW,GAAG,QAAQ,CAAC,GAAG,CAAC;QAC3B,iBAAiB,CAAC,GAAG,EAAE,QAAQ,CAAC,GAAG,CAAC,CAAC;QACrC,QAAQ,GAAG,oBAAoB,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,EAAE,cAAc,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC;QAC9E,MAAM,GAAG,MAAM,uBAAuB,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC;IAC7F,CAAC;IACD,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,GAAG,QAAQ,EAAE,GAAG,MAAkD,CAAC;IAChG,MAAM,MAAM,GAAG,gBAAgB,CAAC,OAAO,CAAC,CAAC;IACzC,MAAM,UAAU,GAAmB,EAAE,GAAG,EAAE,GAAG,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;IACnG,aAAa,CAAC,cAAc,CAAC,GAAG,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC,UAAU,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAChG,IAAI,QAAQ,GAAG,CAAC,CAAC;IACjB,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,6BAA6B,CAAC,CAAC;IAC5D,IAAI,UAAU,CAAC,UAAU,CAAC,EAAE,CAAC;QAC3B,MAAM,MAAM,GAAG,IAAI,iBAAiB,CAAC,UAAU,CAAC,CAAC;QACjD,IAAI,CAAC;YAAC,QAAQ,GAAG,MAAM,CAAC,kBAAkB,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;QAAC,CAAC;gBAAS,CAAC;YAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QAAC,CAAC;IACzF,CAAC;IACD,OAAO,EAAE,GAAG,UAAU,EAAE,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;AACvG,CAAC;AAED;yEACyE;AACzE,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,GAAW,EAAU,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,cAAc,CAAC,CAAC;AAEjF,sFAAsF;AACtF,MAAM,UAAU,iBAAiB,CAAC,GAAW,EAAE,GAAW;IACxD,MAAM,MAAM,GAAG,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,EAAE,GAAG,GAAG,CAAC,OAAO,CAAC,iBAAiB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACvF,MAAM,WAAW,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;IAC5E,SAAS,CAAC,cAAc,CAAC,GAAG,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACpD,UAAU,CAAC,IAAI,CAAC,GAAG,EAAE,cAAc,CAAC,EAAE,WAAW,CAAC,CAAC;IACnD,OAAO,WAAW,CAAC;AACrB,CAAC;AAED;oFACoF;AACpF,MAAM,UAAU,cAAc,CAC5B,YAAoB,EAAE,UAA0B,EAAE,KAAmB,EAAE,SAAwB;IAE/F,4FAA4F;IAC5F,yFAAyF;IACzF,6FAA6F;IAC7F,2BAA2B;IAC3B,MAAM,MAAM,GAAG,IAAI,iBAAiB,CAAC,YAAY,EAAE,eAAe,CAAC,CAAC;IACpE,MAAM,QAAQ,GAAG,mBAAmB,CAAC,EAAE,GAAG,EAAE,SAAS,CAAC,UAAU,CAAC,EAAE,UAAU,EAAE,UAAU,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC;IAClI,OAAO,EAAE,KAAK,EAAE,iBAAiB,CAAC,KAAK,EAAE,QAAQ,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;AACzE,CAAC;AAED;kFACkF;AAClF,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,QAAuB,EAAE,SAAS,GAAG,IAAI;IAC1E,MAAM,OAAO,CAAC,IAAI,CAAC;QACjB,QAAQ,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC;QAChC,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC;KACtF,CAAC,CAAC;AACL,CAAC","sourcesContent":["// scopebond-hook → Scopebond Cloud: enroll the machine's countersigning key with a\n// workspace and auto-export signed receipts to the hosted portal, so a connected\n// hook starts monitoring automatically. Reuses the gateway's enrollment and bounded\n// durable exporter (D40 — no new transport). The machine credential and the complete\n// receipt log stay local-first; export is best-effort and never blocks a tool call.\n\nimport { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport {\n CloudEnrollmentError, completeCloudEnrollment, createCloudExporter, withCloudExporter,\n type CloudEnrollmentBundle, type CloudEnrollmentResult, type CloudExporter, type ReceiptStore,\n} from \"@scopebond/gateway\";\nimport { SqliteCloudOutbox, loadOrCreateAttester } from \"@scopebond/gateway/node\";\nimport { LOSSLESS_OUTBOX } from \"./delivery-report.js\";\n\n/** The persisted connection between this machine and a Cloud workspace. Holds the\n * scoped machine credential; treat cloud.json as a secret (written 0600). */\nexport interface HookConnection extends CloudEnrollmentResult {\n url: string;\n /** The workspace's installation id for this machine. An enrollment answer names it\n * `installation_id`, or (older answers) as `gateway_id`, which is the same identifier;\n * observations use `gateway_id` when the explicit field is absent. */\n installation_id?: string;\n /** The installation generation the enrollment created. Only the workspace can say what it\n * is, so there is no fallback: without it the hook reports observations unsupported\n * rather than guessing a generation. */\n installation_generation?: number;\n /** Where this machine sends its records: the workspace's regional ingest address, named by\n * the enrollment answer. Absent from older answers and connections; `url` is used then. */\n ingest_url?: string;\n}\n\n/** The address records, observations and recovery go to: the enrollment's `ingest_url` when it\n * is a valid HTTPS origin (or localhost in development), otherwise the workspace URL. Sign-in,\n * policy and the portal always use `url`. */\nexport function ingestUrl(connection: Pick<HookConnection, \"url\" | \"ingest_url\">): string {\n return safeIngestOrigin(connection.ingest_url) ?? connection.url;\n}\n\nfunction safeIngestOrigin(value: unknown): string | null {\n if (typeof value !== \"string\" || value.length > 2048) return null;\n try {\n const parsed = new URL(value);\n const local = parsed.hostname === \"localhost\" || parsed.hostname === \"127.0.0.1\";\n if ((parsed.protocol !== \"https:\" && !(local && parsed.protocol === \"http:\")) || parsed.username || parsed.password) return null;\n return parsed.origin;\n } catch { return null; }\n}\n\nexport const connectionPath = (dir: string): string => join(dir, \"cloud.json\");\n\n/** Read the persisted connection, or null when the hook is not connected to Cloud. */\nexport function loadConnection(dir: string): HookConnection | null {\n const path = connectionPath(dir);\n if (!existsSync(path)) return null;\n try {\n const parsed = JSON.parse(readFileSync(path, \"utf8\")) as Partial<HookConnection>;\n if (typeof parsed.url === \"string\" && typeof parsed.credential === \"string\") return parsed as HookConnection;\n } catch { /* fall through */ }\n return null;\n}\n\n/** Enroll the machine's attester with a workspace using the portal's one-use handoff,\n * then persist the scoped machine credential. The attester whose possession is proved\n * here is the same key the hook countersigns receipts with, so ingest accepts them.\n *\n * A workspace refuses a key it already knows — one revoked when the computer was\n * replaced or disconnected, or one already enrolled elsewhere — without spending the\n * token. Reconnecting then replaces the key and enrolls again with the same token, so a\n * second `login` always works; the old key is kept under retired-keys/, and queued\n * receipts it signed leave the delivery queue (they stay in the local log for\n * `recover`), because the new connection can never deliver them. */\nexport async function connectCloud(\n dir: string, url: string, bundle: CloudEnrollmentBundle, fetchImpl?: typeof fetch,\n): Promise<HookConnection & { rotatedFrom?: string; setAside?: number }> {\n let { attester } = loadOrCreateAttester({ file: join(dir, \"attester.key\") });\n const { attester: agent } = loadOrCreateAttester({ file: join(dir, \"agent.key\") });\n let rotatedFrom: string | undefined;\n let result;\n try {\n result = await completeCloudEnrollment({ url, bundle, attester, agent, fetch: fetchImpl });\n } catch (error) {\n if (!(error instanceof CloudEnrollmentError) || error.code !== \"gateway_key_conflict\") throw error;\n rotatedFrom = attester.kid;\n retireAttesterKey(dir, attester.kid);\n attester = loadOrCreateAttester({ file: join(dir, \"attester.key\") }).attester;\n result = await completeCloudEnrollment({ url, bundle, attester, agent, fetch: fetchImpl });\n }\n const { ingest_url: offered, ...enrolled } = result as typeof result & { ingest_url?: unknown };\n const ingest = safeIngestOrigin(offered);\n const connection: HookConnection = { url, ...enrolled, ...(ingest ? { ingest_url: ingest } : {}) };\n writeFileSync(connectionPath(dir), JSON.stringify(connection, null, 2) + \"\\n\", { mode: 0o600 });\n let setAside = 0;\n const outboxPath = join(dir, \"receipts.db.cloud-outbox.db\");\n if (existsSync(outboxPath)) {\n const outbox = new SqliteCloudOutbox(outboxPath);\n try { setAside = outbox.discardNotSignedBy(attester.kid); } finally { outbox.close(); }\n }\n return { ...connection, ...(rotatedFrom ? { rotatedFrom } : {}), ...(setAside ? { setAside } : {}) };\n}\n\n/** Where a replaced countersigning key is kept: receipts it signed stay verifiable, and\n * `recover` can still deliver the ones the workspace never received. */\nexport const retiredKeysDir = (dir: string): string => join(dir, \"retired-keys\");\n\n/** Move the current countersigning key aside so the next load creates a fresh one. */\nexport function retireAttesterKey(dir: string, kid: string): string {\n const target = join(retiredKeysDir(dir), `${kid.replace(/[^A-Za-z0-9_-]/g, \"-\")}.key`);\n const destination = existsSync(target) ? `${target}.${Date.now()}` : target;\n mkdirSync(retiredKeysDir(dir), { recursive: true });\n renameSync(join(dir, \"attester.key\"), destination);\n return destination;\n}\n\n/** Wrap a receipt store so every stored receipt is enqueued to a durable outbox and\n * exported to Cloud. Returns the wrapped store and the exporter (flush + stop). */\nexport function attachExporter(\n outboxDbPath: string, connection: HookConnection, store: ReceiptStore, fetchImpl?: typeof fetch,\n): { store: ReceiptStore; exporter: CloudExporter; outbox: SqliteCloudOutbox } {\n // Lossless (SB275): no cap and no expiry. A record leaves the queue only when the workspace\n // accepts it, or when a key change makes it undeliverable (`recover` then sends it). The\n // gateway's defaults (10,000 records, 64 MiB, 7 days) dropped the newest records once a long\n // outage filled the queue.\n const outbox = new SqliteCloudOutbox(outboxDbPath, LOSSLESS_OUTBOX);\n const exporter = createCloudExporter({ url: ingestUrl(connection), credential: connection.credential, outbox, fetch: fetchImpl });\n return { store: withCloudExporter(store, exporter), exporter, outbox };\n}\n\n/** Attempt delivery with a bounded timeout so a per-invocation hook never hangs the\n * agent; undelivered receipts stay in the durable outbox and flush next time. */\nexport async function flushBounded(exporter: CloudExporter, timeoutMs = 3000): Promise<void> {\n await Promise.race([\n exporter.flush().catch(() => {}),\n new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, timeoutMs)).unref?.()),\n ]);\n}\n"]}
@@ -0,0 +1,11 @@
1
+ import { type HookConnection } from "./cloud.js";
2
+ export declare const REFRESH_WINDOW_MS: number;
3
+ export declare function refreshProof(credentialId: string, gatewayId: string): string;
4
+ export type RefreshOutcome = "not_due" | "renewed" | "unchanged" | "refused" | "unavailable";
5
+ /** Renew the credential when it expires within 30 days. Never throws. */
6
+ export declare function refreshIfDue(dir: string, connection: HookConnection, options?: {
7
+ fetchImpl?: typeof fetch;
8
+ now?: number;
9
+ timeoutMs?: number;
10
+ }): Promise<RefreshOutcome>;
11
+ //# sourceMappingURL=credential-refresh.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credential-refresh.d.ts","sourceRoot":"","sources":["../src/credential-refresh.ts"],"names":[],"mappings":"AASA,OAAO,EAAkB,KAAK,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjE,eAAO,MAAM,iBAAiB,QAA2B,CAAC;AAE1D,wBAAgB,YAAY,CAAC,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAE5E;AAED,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,SAAS,GAAG,WAAW,GAAG,SAAS,GAAG,aAAa,CAAC;AAE7F,yEAAyE;AACzE,wBAAsB,YAAY,CAChC,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,cAAc,EACvC,OAAO,GAAE;IAAE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAC3E,OAAO,CAAC,cAAc,CAAC,CAgCzB"}
@@ -0,0 +1,55 @@
1
+ // A machine credential lasts 90 days. In its last 30 the hook renews it during the rules check,
2
+ // proving it still holds the signing key it enrolled with, so a computer never stops delivering
3
+ // because nobody signed it in again. The workspace keeps the old credential valid for a day, so
4
+ // an answer lost on the way back is harmless: the next check tries again.
5
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { canonical } from "@scopebond/gateway";
8
+ import { loadOrCreateAttester } from "@scopebond/gateway/node";
9
+ import { connectionPath } from "./cloud.js";
10
+ export const REFRESH_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
11
+ export function refreshProof(credentialId, gatewayId) {
12
+ return canonical({ type: "scopebond:credential-refresh", version: 1, credential_id: credentialId, gateway_id: gatewayId });
13
+ }
14
+ /** Renew the credential when it expires within 30 days. Never throws. */
15
+ export async function refreshIfDue(dir, connection, options = {}) {
16
+ try {
17
+ const now = options.now ?? Date.now();
18
+ const expires = Date.parse(connection.expires_at ?? "");
19
+ if (!Number.isFinite(expires) || expires - now > REFRESH_WINDOW_MS)
20
+ return "not_due";
21
+ const keyFile = join(dir, "attester.key");
22
+ if (!existsSync(keyFile))
23
+ return "refused";
24
+ const { attester } = loadOrCreateAttester({ file: keyFile });
25
+ const signature = await attester.sign(refreshProof(connection.credential_id, connection.gateway_id));
26
+ const response = await (options.fetchImpl ?? fetch)(new URL("/v1/credential/refresh", connection.url).toString(), {
27
+ method: "POST",
28
+ headers: { authorization: `Bearer ${connection.credential}`, "content-type": "application/json" },
29
+ body: JSON.stringify({ signature }),
30
+ redirect: "error",
31
+ signal: AbortSignal.timeout(options.timeoutMs ?? 5_000),
32
+ });
33
+ if (!response.ok)
34
+ return response.status === 401 || response.status === 409 ? "refused" : "unavailable";
35
+ const body = await response.json();
36
+ if (!body.refreshed)
37
+ return "unchanged";
38
+ if (typeof body.credential !== "string" || !body.credential.startsWith("sbm_") || typeof body.id !== "string" || typeof body.expires_at !== "string")
39
+ return "unavailable";
40
+ // Re-read before writing: another hook process may have renewed it in the meantime.
41
+ const path = connectionPath(dir);
42
+ const current = JSON.parse(readFileSync(path, "utf8"));
43
+ if (current.credential !== connection.credential)
44
+ return "unchanged";
45
+ const next = { ...current, credential: body.credential, credential_id: body.id, expires_at: body.expires_at };
46
+ const temp = `${path}.${process.pid}.tmp`;
47
+ writeFileSync(temp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
48
+ renameSync(temp, path);
49
+ return "renewed";
50
+ }
51
+ catch {
52
+ return "unavailable";
53
+ }
54
+ }
55
+ //# sourceMappingURL=credential-refresh.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credential-refresh.js","sourceRoot":"","sources":["../src/credential-refresh.ts"],"names":[],"mappings":"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","sourcesContent":["// A machine credential lasts 90 days. In its last 30 the hook renews it during the rules check,\n// proving it still holds the signing key it enrolled with, so a computer never stops delivering\n// because nobody signed it in again. The workspace keeps the old credential valid for a day, so\n// an answer lost on the way back is harmless: the next check tries again.\n\nimport { existsSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { canonical } from \"@scopebond/gateway\";\nimport { loadOrCreateAttester } from \"@scopebond/gateway/node\";\nimport { connectionPath, type HookConnection } from \"./cloud.js\";\n\nexport const REFRESH_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;\n\nexport function refreshProof(credentialId: string, gatewayId: string): string {\n return canonical({ type: \"scopebond:credential-refresh\", version: 1, credential_id: credentialId, gateway_id: gatewayId });\n}\n\nexport type RefreshOutcome = \"not_due\" | \"renewed\" | \"unchanged\" | \"refused\" | \"unavailable\";\n\n/** Renew the credential when it expires within 30 days. Never throws. */\nexport async function refreshIfDue(\n dir: string, connection: HookConnection,\n options: { fetchImpl?: typeof fetch; now?: number; timeoutMs?: number } = {},\n): Promise<RefreshOutcome> {\n try {\n const now = options.now ?? Date.now();\n const expires = Date.parse(connection.expires_at ?? \"\");\n if (!Number.isFinite(expires) || expires - now > REFRESH_WINDOW_MS) return \"not_due\";\n const keyFile = join(dir, \"attester.key\");\n if (!existsSync(keyFile)) return \"refused\";\n const { attester } = loadOrCreateAttester({ file: keyFile });\n const signature = await attester.sign(refreshProof(connection.credential_id, connection.gateway_id));\n const response = await (options.fetchImpl ?? fetch)(new URL(\"/v1/credential/refresh\", connection.url).toString(), {\n method: \"POST\",\n headers: { authorization: `Bearer ${connection.credential}`, \"content-type\": \"application/json\" },\n body: JSON.stringify({ signature }),\n redirect: \"error\",\n signal: AbortSignal.timeout(options.timeoutMs ?? 5_000),\n });\n if (!response.ok) return response.status === 401 || response.status === 409 ? \"refused\" : \"unavailable\";\n const body = await response.json() as { refreshed?: boolean; id?: string; credential?: string; expires_at?: string };\n if (!body.refreshed) return \"unchanged\";\n if (typeof body.credential !== \"string\" || !body.credential.startsWith(\"sbm_\") || typeof body.id !== \"string\" || typeof body.expires_at !== \"string\") return \"unavailable\";\n // Re-read before writing: another hook process may have renewed it in the meantime.\n const path = connectionPath(dir);\n const current = JSON.parse(readFileSync(path, \"utf8\")) as HookConnection;\n if (current.credential !== connection.credential) return \"unchanged\";\n const next: HookConnection = { ...current, credential: body.credential, credential_id: body.id, expires_at: body.expires_at };\n const temp = `${path}.${process.pid}.tmp`;\n writeFileSync(temp, JSON.stringify(next, null, 2) + \"\\n\", { mode: 0o600 });\n renameSync(temp, path);\n return \"renewed\";\n } catch {\n return \"unavailable\";\n }\n}\n"]}
@@ -0,0 +1,21 @@
1
+ import type { HookConnection } from "./cloud.js";
2
+ export declare const OUTBOX_FILE = "receipts.db.cloud-outbox.db";
3
+ /** The queue opened without limits, so reading it never expires or drops anything. */
4
+ export declare const LOSSLESS_OUTBOX: {
5
+ readonly maxPending: number;
6
+ readonly maxBytes: number;
7
+ readonly maxAgeMs: number;
8
+ };
9
+ /** How many records wait to send and since when, read without changing the queue. */
10
+ export declare function queueStatus(dir: string): {
11
+ pending: number;
12
+ oldest: number | null;
13
+ };
14
+ export interface DeliveryReport {
15
+ lines: string[];
16
+ problems: string[];
17
+ /** The one command that fixes a refused connection, when the connection was refused. */
18
+ fix: string | null;
19
+ }
20
+ export declare function describeDelivery(dir: string, connection: Pick<HookConnection, "url">, now?: number): DeliveryReport;
21
+ //# sourceMappingURL=delivery-report.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delivery-report.d.ts","sourceRoot":"","sources":["../src/delivery-report.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAIjD,eAAO,MAAM,WAAW,gCAAgC,CAAC;AAEzD,sFAAsF;AACtF,eAAO,MAAM,eAAe;;;;CAAyH,CAAC;AAEtJ,qFAAqF;AACrF,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAUnF;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,wFAAwF;IACxF,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CACpB;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,CAAC,cAAc,EAAE,KAAK,CAAC,EAAE,GAAG,SAAa,GAAG,cAAc,CAuBvH"}
@@ -0,0 +1,54 @@
1
+ // SB273/SB274: what delivery to the workspace looks like from this computer — the persisted
2
+ // delivery state plus the queue — in the lines `status` prints and the problems `doctor` reports.
3
+ import { existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { SqliteCloudOutbox } from "@scopebond/gateway/node";
6
+ import { ago, readDeliveryState } from "./delivery-state.js";
7
+ import { cliCommand } from "./version.js";
8
+ export const OUTBOX_FILE = "receipts.db.cloud-outbox.db";
9
+ /** The queue opened without limits, so reading it never expires or drops anything. */
10
+ export const LOSSLESS_OUTBOX = { maxPending: Number.MAX_SAFE_INTEGER, maxBytes: Number.MAX_SAFE_INTEGER, maxAgeMs: Number.MAX_SAFE_INTEGER };
11
+ /** How many records wait to send and since when, read without changing the queue. */
12
+ export function queueStatus(dir) {
13
+ const outboxPath = join(dir, OUTBOX_FILE);
14
+ if (!existsSync(outboxPath))
15
+ return { pending: 0, oldest: null };
16
+ try {
17
+ const outbox = new SqliteCloudOutbox(outboxPath, LOSSLESS_OUTBOX);
18
+ try {
19
+ const status = outbox.status();
20
+ return { pending: status.pending, oldest: status.oldestEnqueuedAt };
21
+ }
22
+ finally {
23
+ outbox.close();
24
+ }
25
+ }
26
+ catch {
27
+ return { pending: 0, oldest: null };
28
+ }
29
+ }
30
+ export function describeDelivery(dir, connection, now = Date.now()) {
31
+ const state = readDeliveryState(dir);
32
+ const lines = [];
33
+ const problems = [];
34
+ const { pending, oldest } = queueStatus(dir);
35
+ let fix = null;
36
+ if (state.invalid_since !== null) {
37
+ fix = cliCommand(`login ${connection.url}`);
38
+ lines.push(`NOT DELIVERING since ${new Date(state.invalid_since).toISOString()}: the workspace refused this computer's connection (it was revoked, replaced or removed).`);
39
+ lines.push(`fix: run ${fix}`);
40
+ problems.push(`the workspace no longer accepts this computer's connection; run ${fix}`);
41
+ }
42
+ const tried = state.last_attempt_at !== null ? `; last tried ${ago(state.last_attempt_at, now)}` : "";
43
+ lines.push(`last delivered ${ago(state.last_success_at, now)}${tried}`);
44
+ const age = oldest !== null ? `, oldest from ${ago(oldest, now)}` : "";
45
+ lines.push(`waiting to send ${pending} record(s)${age}`);
46
+ if (state.last_error && state.invalid_since === null) {
47
+ lines.push(`last problem ${state.last_error}`);
48
+ if (pending > 0 && oldest !== null && now - oldest > 60 * 60 * 1000) {
49
+ problems.push(`${pending} record(s) have waited more than an hour to send (${state.last_error}); they stay queued and send once the workspace accepts them`);
50
+ }
51
+ }
52
+ return { lines, problems, fix };
53
+ }
54
+ //# sourceMappingURL=delivery-report.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delivery-report.js","sourceRoot":"","sources":["../src/delivery-report.ts"],"names":[],"mappings":"AAAA,4FAA4F;AAC5F,kGAAkG;AAElG,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAE5D,OAAO,EAAE,GAAG,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE1C,MAAM,CAAC,MAAM,WAAW,GAAG,6BAA6B,CAAC;AAEzD,sFAAsF;AACtF,MAAM,CAAC,MAAM,eAAe,GAAG,EAAE,UAAU,EAAE,MAAM,CAAC,gBAAgB,EAAE,QAAQ,EAAE,MAAM,CAAC,gBAAgB,EAAE,QAAQ,EAAE,MAAM,CAAC,gBAAgB,EAAW,CAAC;AAEtJ,qFAAqF;AACrF,MAAM,UAAU,WAAW,CAAC,GAAW;IACrC,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,WAAW,CAAC,CAAC;IAC1C,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC;QAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;IACjE,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,iBAAiB,CAAC,UAAU,EAAE,eAAe,CAAC,CAAC;QAClE,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;YAC/B,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,gBAAgB,EAAE,CAAC;QACtE,CAAC;gBAAS,CAAC;YAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QAAC,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;IAAC,CAAC;AAClD,CAAC;AASD,MAAM,UAAU,gBAAgB,CAAC,GAAW,EAAE,UAAuC,EAAE,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE;IACrG,MAAM,KAAK,GAAG,iBAAiB,CAAC,GAAG,CAAC,CAAC;IACrC,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC;IAC7C,IAAI,GAAG,GAAkB,IAAI,CAAC;IAC9B,IAAI,KAAK,CAAC,aAAa,KAAK,IAAI,EAAE,CAAC;QACjC,GAAG,GAAG,UAAU,CAAC,SAAS,UAAU,CAAC,GAAG,EAAE,CAAC,CAAC;QAC5C,KAAK,CAAC,IAAI,CAAC,wBAAwB,IAAI,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC,WAAW,EAAE,2FAA2F,CAAC,CAAC;QAC3K,KAAK,CAAC,IAAI,CAAC,YAAY,GAAG,EAAE,CAAC,CAAC;QAC9B,QAAQ,CAAC,IAAI,CAAC,mEAAmE,GAAG,EAAE,CAAC,CAAC;IAC1F,CAAC;IACD,MAAM,KAAK,GAAG,KAAK,CAAC,eAAe,KAAK,IAAI,CAAC,CAAC,CAAC,gBAAgB,GAAG,CAAC,KAAK,CAAC,eAAe,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IACtG,KAAK,CAAC,IAAI,CAAC,oBAAoB,GAAG,CAAC,KAAK,CAAC,eAAe,EAAE,GAAG,CAAC,GAAG,KAAK,EAAE,CAAC,CAAC;IAC1E,MAAM,GAAG,GAAG,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,iBAAiB,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IACvE,KAAK,CAAC,IAAI,CAAC,oBAAoB,OAAO,aAAa,GAAG,EAAE,CAAC,CAAC;IAC1D,IAAI,KAAK,CAAC,UAAU,IAAI,KAAK,CAAC,aAAa,KAAK,IAAI,EAAE,CAAC;QACrD,KAAK,CAAC,IAAI,CAAC,oBAAoB,KAAK,CAAC,UAAU,EAAE,CAAC,CAAC;QACnD,IAAI,OAAO,GAAG,CAAC,IAAI,MAAM,KAAK,IAAI,IAAI,GAAG,GAAG,MAAM,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,EAAE,CAAC;YACpE,QAAQ,CAAC,IAAI,CAAC,GAAG,OAAO,qDAAqD,KAAK,CAAC,UAAU,8DAA8D,CAAC,CAAC;QAC/J,CAAC;IACH,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,EAAE,CAAC;AAClC,CAAC","sourcesContent":["// SB273/SB274: what delivery to the workspace looks like from this computer — the persisted\n// delivery state plus the queue — in the lines `status` prints and the problems `doctor` reports.\n\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { SqliteCloudOutbox } from \"@scopebond/gateway/node\";\nimport type { HookConnection } from \"./cloud.js\";\nimport { ago, readDeliveryState } from \"./delivery-state.js\";\nimport { cliCommand } from \"./version.js\";\n\nexport const OUTBOX_FILE = \"receipts.db.cloud-outbox.db\";\n\n/** The queue opened without limits, so reading it never expires or drops anything. */\nexport const LOSSLESS_OUTBOX = { maxPending: Number.MAX_SAFE_INTEGER, maxBytes: Number.MAX_SAFE_INTEGER, maxAgeMs: Number.MAX_SAFE_INTEGER } as const;\n\n/** How many records wait to send and since when, read without changing the queue. */\nexport function queueStatus(dir: string): { pending: number; oldest: number | null } {\n const outboxPath = join(dir, OUTBOX_FILE);\n if (!existsSync(outboxPath)) return { pending: 0, oldest: null };\n try {\n const outbox = new SqliteCloudOutbox(outboxPath, LOSSLESS_OUTBOX);\n try {\n const status = outbox.status();\n return { pending: status.pending, oldest: status.oldestEnqueuedAt };\n } finally { outbox.close(); }\n } catch { return { pending: 0, oldest: null }; }\n}\n\nexport interface DeliveryReport {\n lines: string[];\n problems: string[];\n /** The one command that fixes a refused connection, when the connection was refused. */\n fix: string | null;\n}\n\nexport function describeDelivery(dir: string, connection: Pick<HookConnection, \"url\">, now = Date.now()): DeliveryReport {\n const state = readDeliveryState(dir);\n const lines: string[] = [];\n const problems: string[] = [];\n const { pending, oldest } = queueStatus(dir);\n let fix: string | null = null;\n if (state.invalid_since !== null) {\n fix = cliCommand(`login ${connection.url}`);\n lines.push(`NOT DELIVERING since ${new Date(state.invalid_since).toISOString()}: the workspace refused this computer's connection (it was revoked, replaced or removed).`);\n lines.push(`fix: run ${fix}`);\n problems.push(`the workspace no longer accepts this computer's connection; run ${fix}`);\n }\n const tried = state.last_attempt_at !== null ? `; last tried ${ago(state.last_attempt_at, now)}` : \"\";\n lines.push(`last delivered ${ago(state.last_success_at, now)}${tried}`);\n const age = oldest !== null ? `, oldest from ${ago(oldest, now)}` : \"\";\n lines.push(`waiting to send ${pending} record(s)${age}`);\n if (state.last_error && state.invalid_since === null) {\n lines.push(`last problem ${state.last_error}`);\n if (pending > 0 && oldest !== null && now - oldest > 60 * 60 * 1000) {\n problems.push(`${pending} record(s) have waited more than an hour to send (${state.last_error}); they stay queued and send once the workspace accepts them`);\n }\n }\n return { lines, problems, fix };\n}\n"]}
@@ -0,0 +1,30 @@
1
+ import type { CloudExporterStatus } from "@scopebond/gateway";
2
+ export declare const DELIVERY_STATE_FILE = "delivery.json";
3
+ export interface DeliveryState {
4
+ /** When this computer last tried to deliver records (ms since epoch). */
5
+ last_attempt_at: number | null;
6
+ /** When the workspace last accepted records from this computer. */
7
+ last_success_at: number | null;
8
+ /** The last delivery failure, in plain words, and its HTTP status when there was one. */
9
+ last_error: string | null;
10
+ last_status: number | null;
11
+ /** Set when the workspace refused this computer's credential (HTTP 401): the connection
12
+ * was revoked, replaced or removed, and only signing in again fixes it. */
13
+ invalid_since: number | null;
14
+ /** What saw the refusal: record delivery or the rules check. */
15
+ invalid_source: "delivery" | "rules" | null;
16
+ }
17
+ export declare function readDeliveryState(dir: string): DeliveryState;
18
+ /** Merge and save. Never throws: delivery state is diagnostic and must not affect a decision. */
19
+ export declare function writeDeliveryState(dir: string, patch: Partial<DeliveryState>): DeliveryState;
20
+ /** The HTTP status in an exporter error ("ingest failed: HTTP 401"), if any. */
21
+ export declare function httpStatusOf(message: string | null | undefined): number | null;
22
+ /** Record the outcome of one delivery attempt from the exporter's in-process status.
23
+ * `before` is the exporter's last success time before this attempt, so an attempt with
24
+ * nothing to send does not count as a delivery. */
25
+ export declare function recordDeliveryAttempt(dir: string, status: Pick<CloudExporterStatus, "lastSuccessAt" | "lastError" | "pending">, at: number, before?: number | null): DeliveryState;
26
+ /** The rules check saw a 401 (or a working connection again). */
27
+ export declare function recordRulesCredential(dir: string, valid: boolean, at: number): void;
28
+ /** "3 minutes ago", "2 hours ago", "4 days ago". */
29
+ export declare function ago(ms: number | null, now?: number): string;
30
+ //# sourceMappingURL=delivery-state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delivery-state.d.ts","sourceRoot":"","sources":["../src/delivery-state.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAE9D,eAAO,MAAM,mBAAmB,kBAAkB,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC5B,yEAAyE;IACzE,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,mEAAmE;IACnE,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,yFAAyF;IACzF,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B;gFAC4E;IAC5E,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,gEAAgE;IAChE,cAAc,EAAE,UAAU,GAAG,OAAO,GAAG,IAAI,CAAC;CAC7C;AAMD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,aAAa,CAO5D;AAED,iGAAiG;AACjG,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC,aAAa,CAAC,GAAG,aAAa,CAS5F;AAED,gFAAgF;AAChF,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAG9E;AAED;;oDAEoD;AACpD,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,mBAAmB,EAAE,eAAe,GAAG,WAAW,GAAG,SAAS,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,GAAE,MAAM,GAAG,IAAW,GAAG,aAAa,CAgBxL;AAED,iEAAiE;AACjE,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,GAAG,IAAI,CAOnF;AAED,oDAAoD;AACpD,wBAAgB,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,EAAE,GAAG,GAAE,MAAmB,GAAG,MAAM,CASvE"}
@@ -0,0 +1,86 @@
1
+ // Delivery state that outlives the process. The hook runs once per tool call, so the
2
+ // exporter's own counters (last success, last error) vanish when it exits; without this
3
+ // file `status` could only say "connected" while every upload was being refused. Written
4
+ // after each delivery attempt and by policy sync; read by `status` and `doctor`.
5
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ export const DELIVERY_STATE_FILE = "delivery.json";
8
+ const EMPTY = {
9
+ last_attempt_at: null, last_success_at: null, last_error: null, last_status: null, invalid_since: null, invalid_source: null,
10
+ };
11
+ export function readDeliveryState(dir) {
12
+ const file = join(dir, DELIVERY_STATE_FILE);
13
+ if (!existsSync(file))
14
+ return { ...EMPTY };
15
+ try {
16
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
17
+ return { ...EMPTY, ...parsed };
18
+ }
19
+ catch {
20
+ return { ...EMPTY };
21
+ }
22
+ }
23
+ /** Merge and save. Never throws: delivery state is diagnostic and must not affect a decision. */
24
+ export function writeDeliveryState(dir, patch) {
25
+ const next = { ...readDeliveryState(dir), ...patch };
26
+ try {
27
+ const file = join(dir, DELIVERY_STATE_FILE);
28
+ const temp = `${file}.${process.pid}.tmp`;
29
+ writeFileSync(temp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
30
+ renameSync(temp, file);
31
+ }
32
+ catch { /* best effort */ }
33
+ return next;
34
+ }
35
+ /** The HTTP status in an exporter error ("ingest failed: HTTP 401"), if any. */
36
+ export function httpStatusOf(message) {
37
+ const match = /HTTP (\d{3})/.exec(message ?? "");
38
+ return match ? Number(match[1]) : null;
39
+ }
40
+ /** Record the outcome of one delivery attempt from the exporter's in-process status.
41
+ * `before` is the exporter's last success time before this attempt, so an attempt with
42
+ * nothing to send does not count as a delivery. */
43
+ export function recordDeliveryAttempt(dir, status, at, before = null) {
44
+ const patch = { last_attempt_at: at };
45
+ if (status.lastError) {
46
+ const code = httpStatusOf(status.lastError);
47
+ patch.last_error = status.lastError;
48
+ patch.last_status = code;
49
+ if (code === 401) {
50
+ const current = readDeliveryState(dir);
51
+ patch.invalid_since = current.invalid_since ?? at;
52
+ patch.invalid_source = current.invalid_source ?? "delivery";
53
+ }
54
+ }
55
+ else if (status.lastSuccessAt !== null && status.lastSuccessAt !== before) {
56
+ // Accepted: the connection works, whatever an earlier run saw.
57
+ Object.assign(patch, { last_success_at: status.lastSuccessAt, last_error: null, last_status: null, invalid_since: null, invalid_source: null });
58
+ }
59
+ return writeDeliveryState(dir, patch);
60
+ }
61
+ /** The rules check saw a 401 (or a working connection again). */
62
+ export function recordRulesCredential(dir, valid, at) {
63
+ const current = readDeliveryState(dir);
64
+ if (valid) {
65
+ if (current.invalid_since !== null && current.invalid_source === "rules")
66
+ writeDeliveryState(dir, { invalid_since: null, invalid_source: null });
67
+ return;
68
+ }
69
+ writeDeliveryState(dir, { invalid_since: current.invalid_since ?? at, invalid_source: current.invalid_source ?? "rules" });
70
+ }
71
+ /** "3 minutes ago", "2 hours ago", "4 days ago". */
72
+ export function ago(ms, now = Date.now()) {
73
+ if (ms === null)
74
+ return "never";
75
+ const seconds = Math.max(0, Math.round((now - ms) / 1000));
76
+ if (seconds < 90)
77
+ return `${seconds} second${seconds === 1 ? "" : "s"} ago`;
78
+ const minutes = Math.round(seconds / 60);
79
+ if (minutes < 90)
80
+ return `${minutes} minutes ago`;
81
+ const hours = Math.round(minutes / 60);
82
+ if (hours < 36)
83
+ return `${hours} hours ago`;
84
+ return `${Math.round(hours / 24)} days ago`;
85
+ }
86
+ //# sourceMappingURL=delivery-state.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delivery-state.js","sourceRoot":"","sources":["../src/delivery-state.ts"],"names":[],"mappings":"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","sourcesContent":["// Delivery state that outlives the process. The hook runs once per tool call, so the\n// exporter's own counters (last success, last error) vanish when it exits; without this\n// file `status` could only say \"connected\" while every upload was being refused. Written\n// after each delivery attempt and by policy sync; read by `status` and `doctor`.\n\nimport { existsSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { CloudExporterStatus } from \"@scopebond/gateway\";\n\nexport const DELIVERY_STATE_FILE = \"delivery.json\";\n\nexport interface DeliveryState {\n /** When this computer last tried to deliver records (ms since epoch). */\n last_attempt_at: number | null;\n /** When the workspace last accepted records from this computer. */\n last_success_at: number | null;\n /** The last delivery failure, in plain words, and its HTTP status when there was one. */\n last_error: string | null;\n last_status: number | null;\n /** Set when the workspace refused this computer's credential (HTTP 401): the connection\n * was revoked, replaced or removed, and only signing in again fixes it. */\n invalid_since: number | null;\n /** What saw the refusal: record delivery or the rules check. */\n invalid_source: \"delivery\" | \"rules\" | null;\n}\n\nconst EMPTY: DeliveryState = {\n last_attempt_at: null, last_success_at: null, last_error: null, last_status: null, invalid_since: null, invalid_source: null,\n};\n\nexport function readDeliveryState(dir: string): DeliveryState {\n const file = join(dir, DELIVERY_STATE_FILE);\n if (!existsSync(file)) return { ...EMPTY };\n try {\n const parsed = JSON.parse(readFileSync(file, \"utf8\")) as Partial<DeliveryState>;\n return { ...EMPTY, ...parsed };\n } catch { return { ...EMPTY }; }\n}\n\n/** Merge and save. Never throws: delivery state is diagnostic and must not affect a decision. */\nexport function writeDeliveryState(dir: string, patch: Partial<DeliveryState>): DeliveryState {\n const next = { ...readDeliveryState(dir), ...patch };\n try {\n const file = join(dir, DELIVERY_STATE_FILE);\n const temp = `${file}.${process.pid}.tmp`;\n writeFileSync(temp, JSON.stringify(next, null, 2) + \"\\n\", { mode: 0o600 });\n renameSync(temp, file);\n } catch { /* best effort */ }\n return next;\n}\n\n/** The HTTP status in an exporter error (\"ingest failed: HTTP 401\"), if any. */\nexport function httpStatusOf(message: string | null | undefined): number | null {\n const match = /HTTP (\\d{3})/.exec(message ?? \"\");\n return match ? Number(match[1]) : null;\n}\n\n/** Record the outcome of one delivery attempt from the exporter's in-process status.\n * `before` is the exporter's last success time before this attempt, so an attempt with\n * nothing to send does not count as a delivery. */\nexport function recordDeliveryAttempt(dir: string, status: Pick<CloudExporterStatus, \"lastSuccessAt\" | \"lastError\" | \"pending\">, at: number, before: number | null = null): DeliveryState {\n const patch: Partial<DeliveryState> = { last_attempt_at: at };\n if (status.lastError) {\n const code = httpStatusOf(status.lastError);\n patch.last_error = status.lastError;\n patch.last_status = code;\n if (code === 401) {\n const current = readDeliveryState(dir);\n patch.invalid_since = current.invalid_since ?? at;\n patch.invalid_source = current.invalid_source ?? \"delivery\";\n }\n } else if (status.lastSuccessAt !== null && status.lastSuccessAt !== before) {\n // Accepted: the connection works, whatever an earlier run saw.\n Object.assign(patch, { last_success_at: status.lastSuccessAt, last_error: null, last_status: null, invalid_since: null, invalid_source: null });\n }\n return writeDeliveryState(dir, patch);\n}\n\n/** The rules check saw a 401 (or a working connection again). */\nexport function recordRulesCredential(dir: string, valid: boolean, at: number): void {\n const current = readDeliveryState(dir);\n if (valid) {\n if (current.invalid_since !== null && current.invalid_source === \"rules\") writeDeliveryState(dir, { invalid_since: null, invalid_source: null });\n return;\n }\n writeDeliveryState(dir, { invalid_since: current.invalid_since ?? at, invalid_source: current.invalid_source ?? \"rules\" });\n}\n\n/** \"3 minutes ago\", \"2 hours ago\", \"4 days ago\". */\nexport function ago(ms: number | null, now: number = Date.now()): string {\n if (ms === null) return \"never\";\n const seconds = Math.max(0, Math.round((now - ms) / 1000));\n if (seconds < 90) return `${seconds} second${seconds === 1 ? \"\" : \"s\"} ago`;\n const minutes = Math.round(seconds / 60);\n if (minutes < 90) return `${minutes} minutes ago`;\n const hours = Math.round(minutes / 60);\n if (hours < 36) return `${hours} hours ago`;\n return `${Math.round(hours / 24)} days ago`;\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../src/install.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../src/install.ts"],"names":[],"mappings":"AAcA,MAAM,MAAM,OAAO,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEpD,gFAAgF;AAChF,wBAAgB,QAAQ,IAAI,MAAM,CAEjC;AAED;qEACqE;AACrE,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAIxD;AAED;gEACgE;AAChE,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,GAAE,MAAsB,GAAG,MAAM,CAIxF;AAED;;;mDAGmD;AACnD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,GAAE,MAAsB,GAAG,MAAM,GAAG,IAAI,CAE7F;AAED;;;;2EAI2E;AAC3E,MAAM,WAAW,aAAa;IAAG,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE;AAEpG,wBAAgB,aAAa,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,GAAE,MAAsB,GAAG,aAAa,CAS1F;AAED,gEAAgE;AAChE,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,aAAa,GAAG,MAAM,CAW/D;AAED;iFACiF;AACjF,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAIjE;AAED;;;;;mDAKmD;AACnD,MAAM,MAAM,QAAQ,GAAG,SAAS,GAAG,SAAS,GAAG,WAAW,GAAG,MAAM,CAAC;AAEpE,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,CAepD;AAED;;0BAE0B;AAC1B,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAkBpD;AAED;4DAC4D;AAC5D,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,GAAG,MAAM,CAqB9F;AAED;;sCAEsC;AACtC,wBAAgB,mBAAmB,IAAI,MAAM,CAE5C;AAYD;uFACuF;AACvF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAQtD;AAED,wEAAwE;AACxE,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAGrD;AAED;;;;;iFAKiF;AACjF,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAavE;AAED,0FAA0F;AAC1F,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAIpF;AAyBD,eAAO,MAAM,sBAAsB,GAAI,KAAK,OAAO,KAAG,OAgBrD,CAAC;AAGF;kDACkD;AAClD,eAAO,MAAM,mBAAmB,GAAI,GAAG,OAAO,KAAG,OACkF,CAAC;AAGpI;;iFAEiF;AACjF,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAUvE;AAED;;;;;;;4EAO4E;AAC5E,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAK/D;AAED;4FAC4F;AAC5F,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CA8B1F;AAED;gDACgD;AAChD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAUzD;AAED,4DAA4D;AAC5D,wBAAgB,cAAc,IAAI,OAAO,CAExC;AAED,2DAA2D;AAC3D,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAED;;;;;;;;iEAQiE;AACjE,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,CAG7E;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAOzD;AAED;;sEAEsE;AACtE,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAc7D;AAED;;;sFAGsF;AACtF,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAa5D;AAED,2DAA2D;AAC3D,wBAAgB,SAAS,IAAI,IAAI,CAEhC;AAED,oFAAoF;AACpF,eAAO,MAAM,gBAAgB,8EAA+E,CAAC;AAE7G;;;+EAG+E;AAC/E,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAexE;AAED;uDACuD;AACvD,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAgBzD"}
package/dist/install.js CHANGED
@@ -318,8 +318,7 @@ export function backupHarnessConfig(file) {
318
318
  * Shared by the project installer (npx command) and the user installer (absolute path). */
319
319
  export function writeHarnessConfig(file, harness, command) {
320
320
  const config = readHarnessConfig(file);
321
- backupHarnessConfig(file);
322
- mkdirSync(dirname(file), { recursive: true });
321
+ const before = existsSync(file) ? JSON.stringify(config) : null;
323
322
  const hooks = isRecord(config.hooks) ? config.hooks : (config.hooks = {});
324
323
  if (harness === "cursor") {
325
324
  config.version = config.version ?? 1;
@@ -346,6 +345,13 @@ export function writeHarnessConfig(file, harness, command) {
346
345
  else
347
346
  list.push(entry);
348
347
  }
348
+ // SB276: a settings file that already holds exactly this entry is left byte-for-byte
349
+ // untouched — no rewrite, no backup. A running agent session watches this file, and
350
+ // rewriting it (even with the same content) is what disturbed a live session on re-login.
351
+ if (before !== null && JSON.stringify(config) === before)
352
+ return file;
353
+ backupHarnessConfig(file);
354
+ mkdirSync(dirname(file), { recursive: true });
349
355
  writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
350
356
  return file;
351
357
  }