mixdog 0.9.27 → 0.9.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.27",
3
+ "version": "0.9.29",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -29,6 +29,7 @@
29
29
  "!scripts/bench/",
30
30
  "!scripts/recall-bench-*.txt",
31
31
  "src/",
32
+ "!src/tui/dev",
32
33
  "vendor/"
33
34
  ],
34
35
  "scripts": {
@@ -62,7 +63,10 @@
62
63
  "bench:recall": "node scripts/recall-bench.mjs",
63
64
  "audit:models": "node scripts/model-catalog-audit.mjs",
64
65
  "patch:replay": "node scripts/patch-replay.mjs",
65
- "build:tui": "node scripts/build-tui.mjs"
66
+ "build:tui": "node scripts/build-tui.mjs",
67
+ "release:patch": "npm run smoke && npm version patch -m \"mixdog v%s\" && git push --follow-tags origin main",
68
+ "release:minor": "npm run smoke && npm version minor -m \"mixdog v%s\" && git push --follow-tags origin main",
69
+ "release:major": "npm run smoke && npm version major -m \"mixdog v%s\" && git push --follow-tags origin main"
66
70
  },
67
71
  "engines": {
68
72
  "node": ">=22.0.0"
package/src/app.mjs CHANGED
@@ -166,6 +166,20 @@ export async function run(argv = []) {
166
166
  }
167
167
 
168
168
  // Default: the canonical React/Ink TUI over the mixdog session runtime.
169
+ // DEV convenience (opt-in via MIXDOG_TUI_DEV): rebuild the JSX bundle from
170
+ // source before launch so local edits reflect without a manual build. The
171
+ // dev module is excluded from the published package ("files" negation) and
172
+ // esbuild is a devDependency, so this is a no-op fallback on any install.
173
+ if (process.env.MIXDOG_TUI_DEV) {
174
+ try {
175
+ const { rebuildTuiFromSource } = await import('./tui/dev/jit-rebuild.mjs');
176
+ await rebuildTuiFromSource();
177
+ } catch (err) {
178
+ if (process.env.MIXDOG_TUI_DEV_VERBOSE) {
179
+ process.stderr.write(`mixdog[tui-dev]: rebuild skipped — ${err?.message ?? err}\n`);
180
+ }
181
+ }
182
+ }
169
183
  const bundle = join(__dirname, 'tui', 'dist', 'index.mjs');
170
184
  if (!existsSync(bundle)) {
171
185
  process.stderr.write(
@@ -5,5 +5,4 @@
5
5
  directly.
6
6
  - Use the session's current project/workspace. Change the work project only
7
7
  when the user asks for another project or a tool call needs another root.
8
- - Use `agent` for scoped implementation, research, review, and debugging — not
9
- for git commit/push/stash or Ship.
8
+ - Use `agent` for scoped implementation, research, review, and debugging.
@@ -1,10 +1,12 @@
1
1
  # Tool Use
2
2
 
3
- - Every turn carries ALL independent calls you can already specify a
4
- second consecutive single-lookup turn is a defect, not a style choice;
5
- serialize only on real data dependency. Merge variants/scopes into ONE
6
- call wherever the schema accepts arrays (`pattern[]`, `path[]`,
7
- `symbols[]`, `query[]`).
3
+ - BATCH OR IT IS A DEFECT this is the single hardest rule here. Every turn
4
+ fires ALL independent calls at once; a second consecutive single-lookup,
5
+ single-`shell`, or single-`apply_patch` turn is a defect, never a style
6
+ choice. Serialize ONLY on a real data dependency — nothing else earns a
7
+ solo call. Merge variants/scopes into ONE call wherever the schema takes
8
+ arrays (`pattern[]`, `path[]`, `symbols[]`, `query[]`), chain shell with
9
+ `;`/`&&` or run them in parallel, and put every known edit in ONE patch.
8
10
  - Route by what is already known: known symbol/relation → `code_graph`;
9
11
  exact text in a known scope → `grep`; unknown location, machine-wide/
10
12
  out-of-repo whereabouts, or concept-level question → `explore` (which uses
@@ -33,9 +35,9 @@
33
35
  unit (function/section) — over-read once instead of paging the same file
34
36
  in small windows across turns; a 3rd window into one file means the first
35
37
  should have been wider.
36
- - Don't mix `apply_patch` with shell or other state-changing calls in one
37
- turn; batch independent-file patches in one turn, then verify them all in
38
- ONE shell call the next.
39
- - Consecutive single `shell` or `apply_patch` turns are a batching miss
40
- unless output-dependent: chain shell commands with `;`/`&&` or call in
41
- parallel; put all known edits in ONE patch.
38
+ - Parallel-first has ONE carve-out: verifying your own edits. Keep an
39
+ `apply_patch` and the `shell` that checks it in separate turns not for
40
+ ordering (same-turn calls run in emit order), but because you must SEE the
41
+ patch result before verifying: a same-turn shell is already emitted and
42
+ can't react to a failed or partial patch. Batch the patches this turn,
43
+ verify them all in ONE shell call the next. Anything else stays parallel.
@@ -11,9 +11,7 @@ function defaultAccess() {
11
11
  function normalizeAccess(parsed) {
12
12
  const defaults = defaultAccess();
13
13
  return {
14
- // Legacy "pairing" policy was removed (its approval flow was never
15
- // completable); normalize it to "allowlist" on load.
16
- dmPolicy: parsed?.dmPolicy === "pairing" ? "allowlist" : (parsed?.dmPolicy ?? defaults.dmPolicy),
14
+ dmPolicy: parsed?.dmPolicy ?? defaults.dmPolicy,
17
15
  allowFrom: parsed?.allowFrom ?? defaults.allowFrom,
18
16
  channels: parsed?.channels ?? defaults.channels,
19
17
  mentionPatterns: parsed?.mentionPatterns,
@@ -56,7 +56,7 @@ function defaultAccess() {
56
56
  function normalizeAccess(parsed) {
57
57
  const defaults = defaultAccess();
58
58
  return {
59
- dmPolicy: parsed?.dmPolicy === "pairing" ? "allowlist" : (parsed?.dmPolicy ?? defaults.dmPolicy),
59
+ dmPolicy: parsed?.dmPolicy ?? defaults.dmPolicy,
60
60
  allowFrom: parsed?.allowFrom ?? defaults.allowFrom,
61
61
  channels: parsed?.channels ?? defaults.channels,
62
62
  mentionPatterns: parsed?.mentionPatterns,
@@ -39,27 +39,10 @@ function channelIdForBackend(entry = {}, backend = "discord") {
39
39
  }
40
40
 
41
41
  // Resolve the single active-backend channel id from the config's `channel`
42
- // section. Backward compat (read-side only, no on-disk migration): when
43
- // `channel` is absent, fall back to the legacy `channelsConfig[mainChannel]`
44
- // entry (or the first entry that carries an id).
42
+ // section. Disk config carries a single `channel` object only.
45
43
  function resolveChannelId(raw = {}, backend = "discord") {
46
44
  const channel = raw.channel && typeof raw.channel === "object" ? raw.channel : null;
47
45
  if (channel) return channelIdForBackend(channel, backend);
48
- const legacy = raw.channelsConfig && typeof raw.channelsConfig === "object" ? raw.channelsConfig : null;
49
- if (legacy) {
50
- const mainName = raw.mainChannel ?? "main";
51
- const preferred = legacy[mainName];
52
- if (preferred && typeof preferred === "object") {
53
- const id = channelIdForBackend(preferred, backend);
54
- if (id) return id;
55
- }
56
- for (const entry of Object.values(legacy)) {
57
- if (entry && typeof entry === "object") {
58
- const id = channelIdForBackend(entry, backend);
59
- if (id) return id;
60
- }
61
- }
62
- }
63
46
  return "";
64
47
  }
65
48
 
@@ -96,13 +79,11 @@ async function loadConfig() {
96
79
  const backend = raw.backend === "telegram" ? "telegram" : "discord";
97
80
  const telegramToken = getTelegramToken();
98
81
  const channelId = resolveChannelId(raw, backend);
99
- // Drop legacy multi-channel keys from the returned shape; the runtime
100
- // reads only the resolved `channelId` now (read-side compat lives in
101
- // resolveChannelId — no on-disk migration).
102
- const { channelsConfig: _legacyChannels, mainChannel: _legacyMain, ...rawRest } = raw;
82
+ // The runtime reads only the resolved `channelId`; disk carries a single
83
+ // `channel` object, so spread `raw` directly.
103
84
  return applyDefaults({
104
85
  ...DEFAULT_CONFIG,
105
- ...rawRest,
86
+ ...raw,
106
87
  backend,
107
88
  channelId,
108
89
  discord: { ...DEFAULT_CONFIG.discord, ...(({ token: _, ...rest }) => rest)(raw.discord || {}), ...(discordToken && !discordTokenProblem ? { token: discordToken } : {}) },
@@ -111,12 +92,7 @@ async function loadConfig() {
111
92
  telegram: { ...DEFAULT_CONFIG.telegram, ...(({ token: _t, ...rest }) => rest)(raw.telegram || {}), ...(telegramToken ? { token: telegramToken } : {}) },
112
93
  access: {
113
94
  ...DEFAULT_ACCESS,
114
- // Drop the retired pairing-era keys at the config layer too (the
115
- // Discord backend's normalizeAccess() is the runtime belt): legacy
116
- // dmPolicy "pairing" → "allowlist", and the `pending` code store is
117
- // gone entirely.
118
- ...(({ pending: _pending, ...rest }) => rest)(raw.access || {}),
119
- ...(raw.access?.dmPolicy === "pairing" ? { dmPolicy: "allowlist" } : {}),
95
+ ...(raw.access || {}),
120
96
  channels: accessChannels,
121
97
  },
122
98
  voice: { ...(raw.voice || {}), ...voice }
@@ -30,7 +30,7 @@ export const TOOL_DEFS = [
30
30
  name: 'recall',
31
31
  title: 'Recall',
32
32
  annotations: { title: 'Recall', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
33
- description: 'Retrieve stored memory/session history. Use only to check prior conversation or past events: resumes, prior work, earlier decisions or messages. Do not call as a reflexive pre-step, to verify a just-made decision, or before storing memory. Query is topic/semantic search, not regex. Patterns: period:"last" for previous conversation; sessionId without query for current session; period:"3h"/"30m"/"24h" for recent; YYYY-MM-DD, date ranges, or HH:MM~HH:MM for time windows; id for exact follow-up.',
33
+ description: 'Retrieve stored memory/session history. Call when a task ties to prior work resumes, continuations, or references to earlier decisions/messages. Do not call as a reflexive pre-step, to verify a just-made decision, or before storing memory. Query is topic/semantic search, not regex. Patterns: period:"last" for previous conversation; sessionId without query for current session; period:"3h"/"30m"/"24h" for recent; YYYY-MM-DD, date ranges, or HH:MM~HH:MM for time windows; id for exact follow-up.',
34
34
  inputSchema: {
35
35
  type: 'object',
36
36
  properties: {
@@ -315,14 +315,9 @@ export function diagnoseDiscordTokenValue(value, config = {}) {
315
315
  const discord = config?.discord && typeof config.discord === 'object' ? config.discord : {}
316
316
  const appId = String(discord.applicationId || '').trim()
317
317
  if (appId && token === appId) return 'Bot token field contains the Application ID, not the bot token.'
318
- // Single main channel: check the `channel` object. Legacy `channelsConfig`
319
- // is still read here (read-only) so the diagnostic keeps working on configs
320
- // that predate the single-channel collapse.
318
+ // Single main channel: check the `channel` object.
321
319
  const candidateEntries = []
322
320
  if (config?.channel && typeof config.channel === 'object') candidateEntries.push(config.channel)
323
- if (config?.channelsConfig && typeof config.channelsConfig === 'object') {
324
- candidateEntries.push(...Object.values(config.channelsConfig))
325
- }
326
321
  for (const ch of candidateEntries) {
327
322
  if (!ch || typeof ch !== 'object') continue
328
323
  for (const id of [ch.channelId, ch.discordChannelId, ch.telegramChatId]) {
@@ -3,10 +3,8 @@
3
3
  * schedules (schema `scheduler`, table `scheduler.schedules`).
4
4
  *
5
5
  * All schedule readers/writers (scheduler.mjs, config.mjs, channel-admin.mjs)
6
- * go through this module. Legacy `<dataDir>/schedules/<name>/SCHEDULE.md`
7
- * files are imported once by the migration hook in getDb and the directory is
8
- * renamed to `schedules.migrated`; the old file-based schedules-store.mjs has
9
- * been removed.
6
+ * go through this module. It is the sole store for schedules; the old
7
+ * file-based schedules-store.mjs has been retired.
10
8
  *
11
9
  * DDL is idempotent and runs once on the first call per process. All queries
12
10
  * fully-qualify `scheduler.schedules` so they are correct regardless of the
@@ -15,9 +13,6 @@
15
13
 
16
14
  import { ensurePgInstance, withSchemaBootstrapLock } from '../memory/lib/pg/adapter.mjs';
17
15
  import { resolvePluginData } from './plugin-paths.mjs';
18
- import { readdirSync, readFileSync, renameSync, existsSync } from 'node:fs';
19
- import { join } from 'node:path';
20
- import { readMarkdownDocument } from './markdown-frontmatter.mjs';
21
16
 
22
17
  const SCHEMA = 'scheduler';
23
18
 
@@ -58,7 +53,6 @@ async function getDb(dataDir = resolvePluginData()) {
58
53
  // same cluster-global advisory lock the adapter uses for schema bootstrap,
59
54
  // so racing first calls can't run the DDL simultaneously.
60
55
  await withSchemaBootstrapLock(pool, () => db.exec(DDL));
61
- await migrateLegacySchedules(db, dataDir);
62
56
  return db;
63
57
  })();
64
58
  _ready.set(dataDir, p);
@@ -70,118 +64,6 @@ async function getDb(dataDir = resolvePluginData()) {
70
64
  }
71
65
  }
72
66
 
73
- // ---------------------------------------------------------------------------
74
- // One-time legacy SCHEDULE.md migration (additive; runs once per dataDir on
75
- // first getDb, right after DDL). Imports every `<dataDir>/schedules/<name>/
76
- // SCHEDULE.md` not already present in the table (by name), using the same
77
- // days->cron folding as the admin write path, then renames the directory to
78
- // `schedules.migrated` (never deletes user data). Per-entry failures are
79
- // isolated and never block store readiness.
80
- // ---------------------------------------------------------------------------
81
-
82
- // Day-name / keyword -> cron day-of-week number (Sun=0 .. Sat=6).
83
- const MIGRATE_DAY_TO_DOW = {
84
- sun: 0, sunday: 0,
85
- mon: 1, monday: 1,
86
- tue: 2, tues: 2, tuesday: 2,
87
- wed: 3, weds: 3, wednesday: 3,
88
- thu: 4, thur: 4, thurs: 4, thursday: 4,
89
- fri: 5, friday: 5,
90
- sat: 6, saturday: 6,
91
- };
92
-
93
- function foldLegacyDaysIntoCron(cron, days) {
94
- const parts = String(cron || '').trim().split(/\s+/).filter(Boolean);
95
- if (parts.length !== 5 && parts.length !== 6) {
96
- throw new Error(`invalid cron "${cron}"`);
97
- }
98
- const raw = String(days || '').trim().toLowerCase();
99
- const dowIndex = parts.length - 1;
100
- // days absent -> keep the cron's own day-of-week field ('0 9 * * 1' stays
101
- // Monday-only). Only an explicit selector rewrites the dow field.
102
- if (!raw) return parts.join(' ');
103
- let dow;
104
- if (raw === 'daily' || raw === 'everyday' || raw === 'every day') dow = '*';
105
- else if (raw === 'weekday' || raw === 'weekdays') dow = '1-5';
106
- else if (raw === 'weekend' || raw === 'weekends') dow = '0,6';
107
- else {
108
- const nums = raw.split(/[\s,]+/).filter(Boolean).map((t) => (
109
- /^[0-6]$/.test(t) ? Number(t) : MIGRATE_DAY_TO_DOW[t]
110
- ));
111
- if (nums.some((n) => n === undefined)) {
112
- throw new Error(`days "${days}" is not a recognizable day selector`);
113
- }
114
- dow = nums.join(',');
115
- }
116
- parts[dowIndex] = dow;
117
- return parts.join(' ');
118
- }
119
-
120
- async function migrateLegacySchedules(db, dataDir) {
121
- const dir = join(dataDir, 'schedules');
122
- let entries;
123
- try {
124
- entries = readdirSync(dir, { withFileTypes: true });
125
- } catch {
126
- return; // no legacy schedules/ dir -> nothing to migrate
127
- }
128
- let imported = 0;
129
- let skipped = 0;
130
- const failed = [];
131
- for (const ent of entries) {
132
- if (!ent.isDirectory()) continue;
133
- const name = ent.name;
134
- try {
135
- const { rows } = await db.query('SELECT 1 FROM scheduler.schedules WHERE name = $1', [name]);
136
- if (rows.length) { skipped++; continue; }
137
- let md;
138
- try { md = readFileSync(join(dir, name, 'SCHEDULE.md'), 'utf8'); }
139
- catch { skipped++; continue; }
140
- const { frontmatter, body } = readMarkdownDocument(md);
141
- const cron = foldLegacyDaysIntoCron(frontmatter.time, frontmatter.days);
142
- const channel = String(frontmatter.channel || '').trim();
143
- const enabled = frontmatter.enabled !== 'false' && frontmatter.enabled !== false;
144
- await db.query(
145
- `INSERT INTO scheduler.schedules
146
- (name, description, when_cron, timezone, target, channel_id, model, prompt, enabled)
147
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
148
- ON CONFLICT (name) DO NOTHING`,
149
- [
150
- name,
151
- String(frontmatter.description || '').trim(),
152
- cron,
153
- frontmatter.timezone ? String(frontmatter.timezone).trim() : null,
154
- channel ? 'channel' : 'session',
155
- channel || null,
156
- frontmatter.model ? String(frontmatter.model).trim() : null,
157
- String(body || '').trim(),
158
- enabled,
159
- ],
160
- );
161
- imported++;
162
- } catch (err) {
163
- failed.push(name);
164
- console.error(`[schedules] migration failed for "${name}": ${err?.message || err}`);
165
- }
166
- }
167
- if (failed.length) {
168
- // Some entries failed to import. Leave schedules/ in place (do NOT rename)
169
- // so the next boot retries them; already-imported entries are skipped by
170
- // the name check above, so retry is idempotent.
171
- console.error(`[schedules] migrated ${imported} legacy schedule(s), ${failed.length} failed (${failed.join(', ')}); leaving schedules/ for retry`);
172
- return;
173
- }
174
- try {
175
- let target = `${dir}.migrated`;
176
- if (existsSync(target)) target = `${dir}.migrated-${Date.now()}`;
177
- renameSync(dir, target);
178
- } catch (err) {
179
- console.error(`[schedules] migrated ${imported} legacy schedule(s) but could not rename schedules/: ${err?.message || err}`);
180
- return;
181
- }
182
- console.error(`[schedules] migrated ${imported} legacy schedule(s) (${skipped} skipped); renamed schedules/ -> schedules.migrated`);
183
- }
184
-
185
67
  // ---------------------------------------------------------------------------
186
68
  // Row <-> def mapping
187
69
  // ---------------------------------------------------------------------------
@@ -6,11 +6,9 @@
6
6
  * This module mirrors schedules-db.mjs: a lazy connection keyed per resolved
7
7
  * dataDir runs idempotent DDL exactly once per process, serialized across
8
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.
9
+ * lock. It is the sole store for endpoints + delivery dedup; the old
10
+ * per-endpoint WEBHOOK.md + deliveries.jsonl file store is fully retired
11
+ * (webhook.mjs reads/writes only through here).
14
12
  *
15
13
  * All queries fully-qualify their tables so they are correct regardless of
16
14
  * the connection search_path.
@@ -18,9 +16,6 @@
18
16
 
19
17
  import { ensurePgInstance, withSchemaBootstrapLock } from '../memory/lib/pg/adapter.mjs';
20
18
  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
19
 
25
20
  const SCHEMA = 'webhooks';
26
21
 
@@ -67,7 +62,6 @@ async function getDb(dataDir = resolvePluginData()) {
67
62
  // same cluster-global advisory lock the adapter uses for schema bootstrap,
68
63
  // so racing first calls can't run the DDL simultaneously.
69
64
  await withSchemaBootstrapLock(pool, () => db.exec(DDL));
70
- await migrateLegacyWebhooks(db, dataDir);
71
65
  return db;
72
66
  })();
73
67
  _ready.set(dataDir, p);
@@ -79,91 +73,6 @@ async function getDb(dataDir = resolvePluginData()) {
79
73
  }
80
74
  }
81
75
 
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
76
  // ---------------------------------------------------------------------------
168
77
  // Row <-> def mapping
169
78
  // ---------------------------------------------------------------------------
@@ -340,30 +249,6 @@ export async function claimDelivery(endpoint, deliveryId, fields = {}, { dataDir
340
249
  return { claimed: false, duplicate: true, row: rowToDelivery(existing[0]) };
341
250
  }
342
251
 
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
252
  /**
368
253
  * Update the status (and optional event/error) of an existing delivery,
369
254
  * keyed by (endpoint, delivery_id). Returns the updated row or null.
@@ -383,23 +268,3 @@ export async function updateDeliveryStatus(endpoint, deliveryId, status, fields
383
268
  );
384
269
  return rowToDelivery(rows[0]);
385
270
  }
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 { cancelBackgroundTasks } from '../runtime/shared/background-tasks.mjs';
16
16
  import { createTranscriptWriter } from '../runtime/shared/transcript-writer.mjs';
17
17
  import { mixdogHome } from '../runtime/shared/plugin-paths.mjs';
18
18
  import { checkLatestVersion, localPackageVersion, isDevInstall } from '../runtime/shared/update-checker.mjs';
19
- import { spawnStagedInstall, runStagedInstall } from '../runtime/shared/staged-update.mjs';
19
+ import { spawnStagedInstall, runStagedInstall, isStagedComplete } from '../runtime/shared/staged-update.mjs';
20
20
  import {
21
21
  modelVisibleToolCompletionMessage,
22
22
  shouldPersistModelVisibleToolCompletion,
@@ -713,15 +713,37 @@ export async function createMixdogSessionRuntime({
713
713
  const updateBootTimer = setTimeout(() => {
714
714
  void (async () => {
715
715
  await checkForUpdateInternal({ force: true });
716
- if (autoUpdateEnabled() && !isDevInstall() && updateCheckState.updateAvailable) {
717
- const ver = updateCheckState.latestVersion;
718
- try { spawnStagedInstall(ver); } catch { /* best-effort background stage */ }
719
- const v = ver ? `v${ver}` : 'latest';
720
- // UI-only notice (TUI maps meta.kind 'update-notice' to a transcript
721
- // notice, never a model-visible message). tone 'info': non-urgent,
722
- // staging runs quietly and applies on the next launch.
723
- emitRuntimeNotification(`mixdog ${v} staging in background applies on next launch.`, { kind: 'update-notice', tone: 'info' });
724
- }
716
+ if (!(autoUpdateEnabled() && !isDevInstall() && updateCheckState.updateAvailable)) return;
717
+ const ver = updateCheckState.latestVersion;
718
+ if (!ver) return;
719
+ // The notice fires ONLY once staging has completed (a ready-to-apply
720
+ // package sits on disk) never upfront so the user sees no "update
721
+ // available / installs on quit" nag while the background stage runs
722
+ // silently. The wording lives in the notice surface (notification-plan):
723
+ // this emit only carries meta.version. TUI maps meta.kind 'update-notice'
724
+ // to a transcript notice, never a model-visible message; tone 'info' =
725
+ // non-urgent, applies on the next launch.
726
+ const announceReady = () => {
727
+ emitRuntimeNotification('update ready', { kind: 'update-notice', version: ver, tone: 'info' });
728
+ };
729
+ // Already staged in a prior session → announce immediately.
730
+ if (isStagedComplete(ver)) { announceReady(); return; }
731
+ try { spawnStagedInstall(ver); } catch { /* best-effort background stage */ }
732
+ // Poll for staging completion, then announce once. The interval is
733
+ // unref'd so it never holds the process open, and gives up silently
734
+ // after the cap — the next launch retries.
735
+ const POLL_MS = 3_000;
736
+ const MAX_MS = 10 * 60 * 1000;
737
+ const startedAt = Date.now();
738
+ const poll = setInterval(() => {
739
+ if (isStagedComplete(ver)) {
740
+ clearInterval(poll);
741
+ announceReady();
742
+ } else if (Date.now() - startedAt > MAX_MS) {
743
+ clearInterval(poll);
744
+ }
745
+ }, POLL_MS);
746
+ poll.unref?.();
725
747
  })().catch(() => {});
726
748
  }, 0);
727
749
  updateBootTimer.unref?.();
@@ -35,7 +35,7 @@ export const AGENT_TOOL = {
35
35
  openWorldHint: true,
36
36
  agentHidden: true,
37
37
  },
38
- description: 'Delegate scoped work. Agent handoffs always start background tasks and return task IDs immediately. Spawn independent scopes in parallel with distinct tags; for the same scope, use send or spawn with the same tag to reuse the live session. Wait for the completion notification before dependent work. Do not call status/read after spawn; they are manual recovery only.',
38
+ description: 'Delegate scoped work. Agent handoffs always start background tasks and return task IDs immediately. Spawn every independent scope in parallel in the same turn, each with a distinct tag; for the same scope, use send or spawn with the same tag to reuse the live session. Wait for the completion notification before dependent work. Do not call status/read after spawn; they are manual recovery only.',
39
39
  inputSchema: {
40
40
  type: 'object',
41
41
  properties: {