mixdog 0.9.25 → 0.9.26

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 (68) hide show
  1. package/package.json +1 -1
  2. package/scripts/steering-fold-provenance-test.mjs +71 -0
  3. package/scripts/webhook-smoke.mjs +46 -53
  4. package/src/defaults/skills/setup/SKILL.md +6 -1
  5. package/src/rules/shared/01-tool.md +8 -4
  6. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
  7. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
  8. package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
  9. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
  10. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
  11. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
  12. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
  13. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
  14. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +45 -0
  15. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
  16. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
  17. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
  18. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
  19. package/src/runtime/channels/lib/config.mjs +13 -11
  20. package/src/runtime/channels/lib/memory-client.mjs +22 -2
  21. package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
  22. package/src/runtime/channels/lib/scheduler.mjs +226 -208
  23. package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
  24. package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
  25. package/src/runtime/channels/lib/webhook.mjs +98 -150
  26. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  27. package/src/runtime/memory/index.mjs +50 -25
  28. package/src/runtime/memory/lib/cycle-scheduler.mjs +3 -1
  29. package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
  30. package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
  31. package/src/runtime/memory/lib/pg/process.mjs +19 -1
  32. package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
  33. package/src/runtime/memory/lib/query-handlers.mjs +102 -0
  34. package/src/runtime/memory/lib/recall-format.mjs +60 -22
  35. package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
  36. package/src/runtime/memory/tool-defs.mjs +1 -1
  37. package/src/runtime/shared/child-guardian.mjs +2 -2
  38. package/src/runtime/shared/open-url.mjs +2 -1
  39. package/src/runtime/shared/schedules-db.mjs +350 -0
  40. package/src/runtime/shared/service-discovery.mjs +169 -0
  41. package/src/runtime/shared/spawn-flags.mjs +27 -0
  42. package/src/runtime/shared/tool-primitives.mjs +3 -1
  43. package/src/runtime/shared/tool-surface.mjs +19 -3
  44. package/src/runtime/shared/update-checker.mjs +54 -10
  45. package/src/runtime/shared/webhooks-db.mjs +405 -0
  46. package/src/session-runtime/channel-config-api.mjs +13 -13
  47. package/src/session-runtime/lifecycle-api.mjs +14 -0
  48. package/src/session-runtime/runtime-core.mjs +39 -20
  49. package/src/standalone/agent-tool.mjs +42 -11
  50. package/src/standalone/channel-admin.mjs +173 -121
  51. package/src/standalone/channel-worker.mjs +2 -2
  52. package/src/standalone/memory-runtime-proxy.mjs +10 -5
  53. package/src/tui/App.jsx +32 -10
  54. package/src/tui/app/channel-pickers.mjs +9 -9
  55. package/src/tui/app/clipboard.mjs +14 -0
  56. package/src/tui/app/doctor.mjs +1 -1
  57. package/src/tui/app/maintenance-pickers.mjs +17 -7
  58. package/src/tui/app/settings-picker.mjs +2 -2
  59. package/src/tui/app/transcript-window.mjs +37 -1
  60. package/src/tui/app/use-mouse-input.mjs +19 -6
  61. package/src/tui/components/ToolExecution.jsx +12 -4
  62. package/src/tui/display-width.mjs +10 -4
  63. package/src/tui/dist/index.mjs +129 -55
  64. package/src/tui/engine/session-api-ext.mjs +12 -12
  65. package/src/tui/index.jsx +12 -2
  66. package/src/ui/statusline-segments.mjs +12 -1
  67. package/vendor/ink/build/display-width.js +5 -4
  68. package/src/runtime/shared/schedules-store.mjs +0 -82
@@ -13,11 +13,12 @@
13
13
  * the child exits, reporting the resolved version on success.
14
14
  */
15
15
 
16
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
17
  import { dirname, join } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { spawn } from 'node:child_process';
20
20
  import { resolvePluginData } from './plugin-paths.mjs';
21
+ import { detachedSpawnOpts } from './spawn-flags.mjs';
21
22
 
22
23
  const PACKAGE_NAME = 'mixdog';
23
24
  const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
@@ -29,6 +30,22 @@ const CACHE_FILE_NAME = 'update-check-cache.json';
29
30
  // src/runtime/shared/).
30
31
  const _MODULE_DIR = dirname(fileURLToPath(import.meta.url));
31
32
  const _PACKAGE_JSON_PATH = join(_MODULE_DIR, '..', '..', '..', 'package.json');
33
+ const _PACKAGE_ROOT = dirname(_PACKAGE_JSON_PATH);
34
+
35
+ /**
36
+ * isDevInstall() — true when the running mixdog is a git checkout / clone (or
37
+ * otherwise not a normal npm install), so auto-update must be skipped: an
38
+ * `npm install -g mixdog@latest` would fight a linked/working-tree package.
39
+ * Heuristics: a `.git` entry at the package root, OR the package directory not
40
+ * living under any `node_modules/` path (global & local installs always do).
41
+ */
42
+ export function isDevInstall() {
43
+ try {
44
+ if (existsSync(join(_PACKAGE_ROOT, '.git'))) return true;
45
+ } catch { /* fall through to path heuristic */ }
46
+ const norm = _PACKAGE_ROOT.replace(/\\/g, '/').toLowerCase();
47
+ return !/\/node_modules\//.test(`/${norm}/`);
48
+ }
32
49
 
33
50
  let _localVersionCache = null;
34
51
  export function localPackageVersion() {
@@ -175,23 +192,50 @@ export async function checkLatestVersion({ force = false, dataDir } = {}) {
175
192
  };
176
193
  }
177
194
 
195
+ /**
196
+ * Resolve npm's JS entrypoint (npm-cli.js) next to the running node binary.
197
+ * Running `node npm-cli.js …` directly avoids cmd.exe/PowerShell entirely on
198
+ * Windows: no shell process means no console window can appear (and no
199
+ * `-WindowStyle Hidden` style flags that antivirus heuristics flag).
200
+ */
201
+ function npmCliJsPath() {
202
+ const execDir = dirname(process.execPath);
203
+ const candidates = [
204
+ // Windows: npm ships beside node.exe.
205
+ join(execDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
206
+ // Unix layout: <prefix>/bin/node → <prefix>/lib/node_modules/npm.
207
+ join(execDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
208
+ ];
209
+ // When launched via npm itself, npm_execpath is authoritative.
210
+ const envPath = process.env.npm_execpath;
211
+ if (envPath && /npm-cli\.js$/i.test(envPath)) candidates.unshift(envPath);
212
+ for (const candidate of candidates) {
213
+ try { if (existsSync(candidate)) return candidate; } catch { /* keep looking */ }
214
+ }
215
+ return null;
216
+ }
217
+
178
218
  /**
179
219
  * runGlobalUpdate() — `npm install -g mixdog@latest` in a background child
180
- * process (windowsHide so no console flash on Windows). Resolves once the
181
- * child exits; never throws failures come back as {ok:false, error}.
220
+ * process. Prefers spawning node.exe with npm-cli.js directly (shell-less, so
221
+ * Windows never opens a console window); falls back to npm.cmd via shell when
222
+ * npm-cli.js cannot be located. Resolves once the child exits; never throws —
223
+ * failures come back as {ok:false, error}.
182
224
  */
183
225
  export function runGlobalUpdate() {
184
226
  return new Promise((resolvePromise) => {
185
227
  let child;
186
228
  try {
187
- const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
188
- child = spawn(npmCmd, ['install', '-g', `${PACKAGE_NAME}@latest`], {
229
+ const installArgs = ['install', '-g', `${PACKAGE_NAME}@latest`];
230
+ const cliJs = npmCliJsPath();
231
+ const isWin = process.platform === 'win32';
232
+ const [cmd, args, useShell] = cliJs
233
+ ? [process.execPath, [cliJs, ...installArgs], false]
234
+ : [isWin ? 'npm.cmd' : 'npm', installArgs, isWin];
235
+ child = spawn(cmd, args, {
189
236
  stdio: 'ignore',
190
- windowsHide: true,
191
- // detached: survive parent exit — quitting the TUI mid-install must
192
- // not kill npm halfway and leave the global install corrupted.
193
- detached: true,
194
- shell: process.platform === 'win32',
237
+ shell: useShell,
238
+ ...detachedSpawnOpts,
195
239
  });
196
240
  } catch (err) {
197
241
  resolvePromise({ ok: false, error: err?.message || String(err) });
@@ -0,0 +1,405 @@
1
+ /**
2
+ * PG-backed webhook store — the single source of truth for webhook endpoint
3
+ * definitions (schema `webhooks`, table `webhooks.endpoints`) and the
4
+ * delivery dedup log (`webhooks.deliveries`).
5
+ *
6
+ * This module mirrors schedules-db.mjs: a lazy connection keyed per resolved
7
+ * dataDir runs idempotent DDL exactly once per process, serialized across
8
+ * concurrent first-boot processes on the adapter's schema-bootstrap advisory
9
+ * lock. It covers the file-store API surface currently in
10
+ * channels/lib/webhook/deliveries.mjs (loadEndpointConfig, appendDelivery,
11
+ * deliveryExists, _readEndpointSecret) so call sites can migrate off the
12
+ * per-endpoint WEBHOOK.md + deliveries.jsonl files later. No call-site
13
+ * changes are made in this scope.
14
+ *
15
+ * All queries fully-qualify their tables so they are correct regardless of
16
+ * the connection search_path.
17
+ */
18
+
19
+ import { ensurePgInstance, withSchemaBootstrapLock } from '../memory/lib/pg/adapter.mjs';
20
+ import { resolvePluginData } from './plugin-paths.mjs';
21
+ import { readdirSync, readFileSync, rmSync, rmdirSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { readMarkdownDocument } from './markdown-frontmatter.mjs';
24
+
25
+ const SCHEMA = 'webhooks';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Lazy connection + one-shot idempotent DDL (keyed per resolved dataDir).
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const _ready = new Map(); // dataDir → Promise<db>
32
+
33
+ const DDL = `
34
+ CREATE SCHEMA IF NOT EXISTS webhooks;
35
+ CREATE TABLE IF NOT EXISTS webhooks.endpoints (
36
+ name text PRIMARY KEY,
37
+ description text NOT NULL DEFAULT '',
38
+ channel_id text,
39
+ role text NOT NULL DEFAULT 'webhook-handler',
40
+ model text,
41
+ parser text,
42
+ secret text,
43
+ instructions text NOT NULL DEFAULT '',
44
+ enabled boolean NOT NULL DEFAULT true,
45
+ created_at timestamptz NOT NULL DEFAULT now(),
46
+ updated_at timestamptz NOT NULL DEFAULT now()
47
+ );
48
+ CREATE TABLE IF NOT EXISTS webhooks.deliveries (
49
+ endpoint text NOT NULL,
50
+ delivery_id text NOT NULL,
51
+ status text NOT NULL DEFAULT 'pending',
52
+ event text,
53
+ error text,
54
+ headers_summary jsonb,
55
+ payload_preview text,
56
+ created_at timestamptz NOT NULL DEFAULT now(),
57
+ updated_at timestamptz NOT NULL DEFAULT now(),
58
+ CONSTRAINT deliveries_endpoint_id_uniq UNIQUE (endpoint, delivery_id)
59
+ );
60
+ `;
61
+
62
+ async function getDb(dataDir = resolvePluginData()) {
63
+ if (_ready.has(dataDir)) return _ready.get(dataDir);
64
+ const p = (async () => {
65
+ const { db, pool } = await ensurePgInstance(dataDir, { schema: SCHEMA });
66
+ // Serialize the CREATE TABLE across concurrent first-boot processes on the
67
+ // same cluster-global advisory lock the adapter uses for schema bootstrap,
68
+ // so racing first calls can't run the DDL simultaneously.
69
+ await withSchemaBootstrapLock(pool, () => db.exec(DDL));
70
+ await migrateLegacyWebhooks(db, dataDir);
71
+ return db;
72
+ })();
73
+ _ready.set(dataDir, p);
74
+ try {
75
+ return await p;
76
+ } catch (err) {
77
+ _ready.delete(dataDir); // let the next call retry DDL after a transient failure
78
+ throw err;
79
+ }
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // One-time legacy WEBHOOK.md migration (additive; runs once per dataDir on
84
+ // first getDb, right after DDL). Imports every `<dataDir>/webhooks/<name>/
85
+ // WEBHOOK.md` (+ its `secret` side file) not already present in the table (by
86
+ // name), then DELETES the webhooks/ directory. The legacy file reading is
87
+ // inlined here on purpose so this migration never depends on the file-store
88
+ // deliveries.mjs module (which another scope may remove). Old per-endpoint
89
+ // deliveries files are intentionally NOT imported — dedup history resets,
90
+ // which is acceptable. Partial failure keeps the directory in place and logs,
91
+ // so the next boot retries the un-imported entries idempotently.
92
+ // ---------------------------------------------------------------------------
93
+ async function migrateLegacyWebhooks(db, dataDir) {
94
+ const dir = join(dataDir, 'webhooks');
95
+ let entries;
96
+ try {
97
+ entries = readdirSync(dir, { withFileTypes: true });
98
+ } catch {
99
+ return; // no legacy webhooks/ dir -> nothing to migrate
100
+ }
101
+ let imported = 0;
102
+ let skipped = 0;
103
+ const failed = [];
104
+ // Per-endpoint dirs safe to delete this run: those imported now, or already
105
+ // present in the table (a prior run imported them). Anything unrecognized —
106
+ // a dir with no WEBHOOK.md, a stray file, a failed import — is left in place.
107
+ const removable = [];
108
+ for (const ent of entries) {
109
+ if (!ent.isDirectory()) continue;
110
+ const name = ent.name;
111
+ try {
112
+ const { rows } = await db.query('SELECT 1 FROM webhooks.endpoints WHERE name = $1', [name]);
113
+ if (rows.length) { skipped++; removable.push(name); continue; }
114
+ let md;
115
+ try { md = readFileSync(join(dir, name, 'WEBHOOK.md'), 'utf8'); }
116
+ catch { skipped++; continue; }
117
+ const { frontmatter, body } = readMarkdownDocument(md);
118
+ let secret = null;
119
+ try { secret = String(readFileSync(join(dir, name, 'secret'), 'utf8')).trim() || null; }
120
+ catch { secret = null; }
121
+ const channel = String(frontmatter.channel || '').trim();
122
+ const enabled = frontmatter.enabled !== 'false' && frontmatter.enabled !== false;
123
+ await db.query(
124
+ `INSERT INTO webhooks.endpoints
125
+ (name, description, channel_id, role, model, parser, secret, instructions, enabled)
126
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
127
+ ON CONFLICT (name) DO NOTHING`,
128
+ [
129
+ name,
130
+ String(frontmatter.description || '').trim(),
131
+ channel || null,
132
+ 'webhook-handler',
133
+ frontmatter.model ? String(frontmatter.model).trim() : null,
134
+ frontmatter.parser ? String(frontmatter.parser).trim() : null,
135
+ secret,
136
+ String(body || '').trim(),
137
+ enabled,
138
+ ],
139
+ );
140
+ imported++;
141
+ removable.push(name);
142
+ } catch (err) {
143
+ failed.push(name);
144
+ console.error(`[webhooks] migration failed for "${name}": ${err?.message || err}`);
145
+ }
146
+ }
147
+ // User chose deletion over a `.migrated` rename. Delete only the recognized
148
+ // per-endpoint dirs (imported now or already in the table); never blow away
149
+ // unimported/unrelated entries. Failed imports keep their dir for the next
150
+ // boot to retry (idempotent via the name check above).
151
+ for (const name of removable) {
152
+ try { rmSync(join(dir, name), { recursive: true, force: true }); }
153
+ catch (err) { console.error(`[webhooks] could not delete migrated dir "${name}": ${err?.message || err}`); }
154
+ }
155
+ // Remove the parent webhooks/ only once it is empty — anything unrecognized
156
+ // (stray files, WEBHOOK.md-less dirs, failed imports) keeps it alive.
157
+ let parentGone = false;
158
+ try {
159
+ if (readdirSync(dir).length === 0) { rmdirSync(dir); parentGone = true; }
160
+ } catch (err) {
161
+ console.error(`[webhooks] could not remove empty webhooks/: ${err?.message || err}`);
162
+ }
163
+ const tail = failed.length ? `${failed.length} failed (${failed.join(', ')}); ` : '';
164
+ console.error(`[webhooks] migrated ${imported} legacy webhook(s) (${skipped} skipped); ${tail}${parentGone ? 'removed webhooks/' : 'kept webhooks/ (non-empty)'}`);
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Row <-> def mapping
169
+ // ---------------------------------------------------------------------------
170
+
171
+ const ENDPOINT_COLS = 'name, description, channel_id, role, model, parser, secret, instructions, enabled, created_at, updated_at';
172
+
173
+ function rowToEndpoint(row) {
174
+ if (!row) return null;
175
+ return {
176
+ name: row.name,
177
+ description: row.description,
178
+ channelId: row.channel_id,
179
+ role: row.role,
180
+ model: row.model,
181
+ parser: row.parser,
182
+ // Never project the plaintext secret through list/load config paths;
183
+ // callers get a presence flag and must fetch the value via
184
+ // readEndpointSecret (the single, explicit secret-read path).
185
+ secretSet: Boolean(row.secret && String(row.secret).trim()),
186
+ instructions: row.instructions,
187
+ enabled: row.enabled,
188
+ createdAt: row.created_at,
189
+ updatedAt: row.updated_at,
190
+ };
191
+ }
192
+
193
+ const DELIVERY_COLS = 'endpoint, delivery_id, status, event, error, headers_summary, payload_preview, created_at, updated_at';
194
+
195
+ function rowToDelivery(row) {
196
+ if (!row) return null;
197
+ return {
198
+ endpoint: row.endpoint,
199
+ deliveryId: row.delivery_id,
200
+ status: row.status,
201
+ event: row.event,
202
+ error: row.error,
203
+ headersSummary: row.headers_summary,
204
+ payloadPreview: row.payload_preview,
205
+ createdAt: row.created_at,
206
+ updatedAt: row.updated_at,
207
+ };
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Endpoint definitions
212
+ // ---------------------------------------------------------------------------
213
+
214
+ export async function listEndpoints({ dataDir } = {}) {
215
+ const db = await getDb(dataDir);
216
+ const { rows } = await db.query(`SELECT ${ENDPOINT_COLS} FROM webhooks.endpoints ORDER BY name`);
217
+ return rows.map(rowToEndpoint);
218
+ }
219
+
220
+ /**
221
+ * Full endpoint config by name (replaces loadEndpointConfig's file read):
222
+ * the frontmatter fields as one object, or null when absent.
223
+ */
224
+ export async function loadEndpointConfig(name, { dataDir } = {}) {
225
+ if (!name) return null;
226
+ const db = await getDb(dataDir);
227
+ const { rows } = await db.query(`SELECT ${ENDPOINT_COLS} FROM webhooks.endpoints WHERE name = $1`, [name]);
228
+ return rowToEndpoint(rows[0]);
229
+ }
230
+
231
+ /**
232
+ * Per-endpoint HMAC secret only (replaces _readEndpointSecret's side-file
233
+ * read). Returns the trimmed secret or null.
234
+ */
235
+ export async function readEndpointSecret(name, { dataDir } = {}) {
236
+ if (!name) return null;
237
+ const db = await getDb(dataDir);
238
+ const { rows } = await db.query('SELECT secret FROM webhooks.endpoints WHERE name = $1', [name]);
239
+ const s = rows[0]?.secret;
240
+ return s ? String(s).trim() || null : null;
241
+ }
242
+
243
+ /**
244
+ * Insert-or-replace an endpoint by name. On re-upsert every column is reset
245
+ * from the incoming def (enabled and other stale fields included) so a
246
+ * re-registration is a clean redefinition, mirroring upsertSchedule.
247
+ */
248
+ export async function upsertEndpoint(def, { dataDir } = {}) {
249
+ if (!def || !def.name) throw new Error('upsertEndpoint: def.name is required');
250
+ const db = await getDb(dataDir);
251
+ const params = [
252
+ def.name,
253
+ def.description ?? '',
254
+ def.channelId ?? null,
255
+ def.role ?? 'webhook-handler',
256
+ def.model ?? null,
257
+ def.parser ?? null,
258
+ def.secret ?? null,
259
+ def.instructions ?? '',
260
+ def.enabled ?? true,
261
+ ];
262
+ const { rows } = await db.query(
263
+ `INSERT INTO webhooks.endpoints
264
+ (name, description, channel_id, role, model, parser, secret, instructions, enabled)
265
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
266
+ ON CONFLICT (name) DO UPDATE SET
267
+ description = EXCLUDED.description,
268
+ channel_id = EXCLUDED.channel_id,
269
+ role = EXCLUDED.role,
270
+ model = EXCLUDED.model,
271
+ parser = EXCLUDED.parser,
272
+ secret = EXCLUDED.secret,
273
+ instructions = EXCLUDED.instructions,
274
+ enabled = EXCLUDED.enabled,
275
+ updated_at = now()
276
+ RETURNING ${ENDPOINT_COLS}`,
277
+ params,
278
+ );
279
+ return rowToEndpoint(rows[0]);
280
+ }
281
+
282
+ export async function setEndpointEnabled(name, enabled, { dataDir } = {}) {
283
+ const db = await getDb(dataDir);
284
+ const { rows } = await db.query(
285
+ `UPDATE webhooks.endpoints SET enabled = $2, updated_at = now() WHERE name = $1 RETURNING ${ENDPOINT_COLS}`,
286
+ [name, !!enabled],
287
+ );
288
+ return rowToEndpoint(rows[0]);
289
+ }
290
+
291
+ export async function deleteEndpoint(name, { dataDir } = {}) {
292
+ const db = await getDb(dataDir);
293
+ // Atomically drop the endpoint AND its delivery log in one statement: a
294
+ // data-modifying CTE deletes the deliveries, the outer DELETE removes the
295
+ // endpoint row, so no orphan deliveries can survive a removed endpoint.
296
+ const { rowCount } = await db.query(
297
+ `WITH del AS (DELETE FROM webhooks.deliveries WHERE endpoint = $1 RETURNING 1)
298
+ DELETE FROM webhooks.endpoints WHERE name = $1`,
299
+ [name],
300
+ );
301
+ return rowCount > 0;
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // Delivery dedup log
306
+ // ---------------------------------------------------------------------------
307
+
308
+ /**
309
+ * Atomically claim a delivery id for an endpoint. INSERT ... ON CONFLICT DO
310
+ * NOTHING makes the claim a single indivisible step, so two concurrent POSTs
311
+ * of the same id cannot both win: exactly one insert affects a row.
312
+ * Returns { claimed: true, row } for the winner and { claimed: false,
313
+ * duplicate: true, row } for the loser (row = the existing claim).
314
+ */
315
+ export async function claimDelivery(endpoint, deliveryId, fields = {}, { dataDir } = {}) {
316
+ if (!endpoint || !deliveryId) throw new Error('claimDelivery: endpoint and deliveryId are required');
317
+ const db = await getDb(dataDir);
318
+ const params = [
319
+ endpoint,
320
+ deliveryId,
321
+ fields.status ?? 'pending',
322
+ fields.event ?? null,
323
+ fields.headersSummary ?? null,
324
+ fields.payloadPreview ?? null,
325
+ ];
326
+ const { rows } = await db.query(
327
+ `INSERT INTO webhooks.deliveries
328
+ (endpoint, delivery_id, status, event, headers_summary, payload_preview)
329
+ VALUES ($1,$2,$3,$4,$5,$6)
330
+ ON CONFLICT (endpoint, delivery_id) DO NOTHING
331
+ RETURNING ${DELIVERY_COLS}`,
332
+ params,
333
+ );
334
+ if (rows[0]) return { claimed: true, duplicate: false, row: rowToDelivery(rows[0]) };
335
+ // Lost the race (or a prior claim exists): return the existing row.
336
+ const { rows: existing } = await db.query(
337
+ `SELECT ${DELIVERY_COLS} FROM webhooks.deliveries WHERE endpoint = $1 AND delivery_id = $2`,
338
+ [endpoint, deliveryId],
339
+ );
340
+ return { claimed: false, duplicate: true, row: rowToDelivery(existing[0]) };
341
+ }
342
+
343
+ /**
344
+ * True when a delivery id has already been recorded for the endpoint (any
345
+ * status). Callers wanting terminal-vs-inflight semantics inspect the row via
346
+ * getDelivery; claimDelivery is the race-safe gate.
347
+ */
348
+ export async function deliveryExists(endpoint, deliveryId, { dataDir } = {}) {
349
+ if (!endpoint || !deliveryId) return false;
350
+ const db = await getDb(dataDir);
351
+ const { rows } = await db.query(
352
+ 'SELECT 1 FROM webhooks.deliveries WHERE endpoint = $1 AND delivery_id = $2',
353
+ [endpoint, deliveryId],
354
+ );
355
+ return rows.length > 0;
356
+ }
357
+
358
+ export async function getDelivery(endpoint, deliveryId, { dataDir } = {}) {
359
+ const db = await getDb(dataDir);
360
+ const { rows } = await db.query(
361
+ `SELECT ${DELIVERY_COLS} FROM webhooks.deliveries WHERE endpoint = $1 AND delivery_id = $2`,
362
+ [endpoint, deliveryId],
363
+ );
364
+ return rowToDelivery(rows[0]);
365
+ }
366
+
367
+ /**
368
+ * Update the status (and optional event/error) of an existing delivery,
369
+ * keyed by (endpoint, delivery_id). Returns the updated row or null.
370
+ */
371
+ export async function updateDeliveryStatus(endpoint, deliveryId, status, fields = {}, { dataDir } = {}) {
372
+ if (!endpoint || !deliveryId) throw new Error('updateDeliveryStatus: endpoint and deliveryId are required');
373
+ const db = await getDb(dataDir);
374
+ const { rows } = await db.query(
375
+ `UPDATE webhooks.deliveries
376
+ SET status = $3,
377
+ event = COALESCE($4, event),
378
+ error = COALESCE($5, error),
379
+ updated_at = now()
380
+ WHERE endpoint = $1 AND delivery_id = $2
381
+ RETURNING ${DELIVERY_COLS}`,
382
+ [endpoint, deliveryId, status, fields.event ?? null, fields.error ?? null],
383
+ );
384
+ return rowToDelivery(rows[0]);
385
+ }
386
+
387
+ /**
388
+ * Append a delivery record (claim-or-update convenience covering the old
389
+ * appendDelivery). First write for an id claims it; a later write with the
390
+ * same id updates its status/fields latest-wins.
391
+ *
392
+ * Returns the claim outcome — { claimed, duplicate, row } — NOT a bare row,
393
+ * so a pre-dispatch caller can detect a concurrent duplicate (claimed:false,
394
+ * duplicate:true) and skip re-dispatching an in-flight delivery. `row` is the
395
+ * freshly claimed row when claimed, else the existing row after the
396
+ * latest-wins status/fields update.
397
+ */
398
+ export async function appendDelivery(endpoint, entry = {}, { dataDir } = {}) {
399
+ const deliveryId = entry.deliveryId ?? entry.id;
400
+ if (!endpoint || !deliveryId) throw new Error('appendDelivery: endpoint and deliveryId are required');
401
+ const claim = await claimDelivery(endpoint, deliveryId, entry, { dataDir });
402
+ if (claim.claimed) return claim;
403
+ const row = await updateDeliveryStatus(endpoint, deliveryId, entry.status ?? claim.row?.status, entry, { dataDir });
404
+ return { claimed: false, duplicate: true, row };
405
+ }
@@ -16,7 +16,7 @@ import {
16
16
  // worker handle, soft reload).
17
17
  export function createChannelConfigApi({ flushBackendSave, channels, reloadChannelsSoon }) {
18
18
  return {
19
- getChannelSetup() {
19
+ async getChannelSetup() {
20
20
  // Flush a pending debounced backend switch first so setup readers
21
21
  // (Settings → Channel Setting, remote toggles) never observe the
22
22
  // previous backend during the 150ms debounce window.
@@ -36,33 +36,33 @@ export function createChannelConfigApi({ flushBackendSave, channels, reloadChann
36
36
  reloadChannelsSoon();
37
37
  return result;
38
38
  },
39
- saveSchedule(entry) {
40
- const result = saveSchedule(entry);
39
+ async saveSchedule(entry) {
40
+ const result = await saveSchedule(entry);
41
41
  reloadChannelsSoon();
42
42
  return result;
43
43
  },
44
- deleteSchedule(name) {
45
- const result = deleteSchedule(name);
44
+ async deleteSchedule(name) {
45
+ const result = await deleteSchedule(name);
46
46
  reloadChannelsSoon();
47
47
  return result;
48
48
  },
49
- setScheduleEnabled(name, enabled) {
50
- const result = setScheduleEnabled(name, enabled);
49
+ async setScheduleEnabled(name, enabled) {
50
+ const result = await setScheduleEnabled(name, enabled);
51
51
  reloadChannelsSoon();
52
52
  return result;
53
53
  },
54
- saveWebhook(entry) {
55
- const result = saveWebhook(entry);
54
+ async saveWebhook(entry) {
55
+ const result = await saveWebhook(entry);
56
56
  reloadChannelsSoon();
57
57
  return result;
58
58
  },
59
- deleteWebhook(name) {
60
- const result = deleteWebhook(name);
59
+ async deleteWebhook(name) {
60
+ const result = await deleteWebhook(name);
61
61
  reloadChannelsSoon();
62
62
  return result;
63
63
  },
64
- setWebhookEnabled(name, enabled) {
65
- const result = setWebhookEnabled(name, enabled);
64
+ async setWebhookEnabled(name, enabled) {
65
+ const result = await setWebhookEnabled(name, enabled);
66
66
  reloadChannelsSoon();
67
67
  return result;
68
68
  },
@@ -29,11 +29,21 @@ export function createLifecycleApi(deps) {
29
29
  invalidateContextStatusCache, invalidatePreSessionToolSurface,
30
30
  applyResolvedCwd, resolveRoute, applyDeferredToolSurface, standaloneTools,
31
31
  pushTranscriptRebind,
32
+ flushPendingUpdate,
32
33
  } = deps;
33
34
  return {
34
35
  async close(reason = 'cli-exit', options = {}) {
35
36
  const detach = options?.detach === true || options?.wait === false || options?.waitForExit === false;
36
37
  setCloseRequested(true);
38
+ // Deferred self-update: arm-at-boot, install-on-quit. Only a real
39
+ // process exit (not a /clear session reset) triggers the npm install;
40
+ // the spawn itself is deferred to AFTER teardown below so npm overwrites
41
+ // the install once this process has released its file handles.
42
+ const isProcessExit = /exit|quit|shutdown|sighup|sigint|sigterm/.test(String(reason || '').toLowerCase());
43
+ const flushUpdateOnExit = () => {
44
+ if (!isProcessExit) return;
45
+ try { flushPendingUpdate?.(); } catch { /* exit must never wedge on the update spawn */ }
46
+ };
37
47
  // SessionEnd: bridge teardown to the standard hook bus. reason mapped to
38
48
  // standard values ('clear'/'exit' where applicable, else 'other'). Short
39
49
  // await guard so a slow hook cannot wedge teardown; best-effort.
@@ -140,6 +150,7 @@ export function createLifecycleApi(deps) {
140
150
  for (const stop of [mcpStop, openaiWsStop, patchStop]) {
141
151
  Promise.resolve(stop).catch(() => {});
142
152
  }
153
+ flushUpdateOnExit();
143
154
  return ok;
144
155
  }
145
156
  await Promise.allSettled([
@@ -151,6 +162,9 @@ export function createLifecycleApi(deps) {
151
162
  withTeardownDeadline(shellJobsStop, 1500, false),
152
163
  withTeardownDeadline(bashSessionsStop, 1500, false),
153
164
  ]);
165
+ // After teardown: file handles (channels/mcp/memory/shell/patch) are
166
+ // released, so npm can now overwrite the install without TAR_ENTRY_ERROR.
167
+ flushUpdateOnExit();
154
168
  return ok;
155
169
  },
156
170
  abort(reason = 'cli-abort') {