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 +6 -2
- package/src/app.mjs +14 -0
- package/src/rules/lead/lead-tool.md +1 -2
- package/src/rules/shared/01-tool.md +13 -11
- package/src/runtime/channels/backends/discord-access.mjs +1 -3
- package/src/runtime/channels/backends/telegram.mjs +1 -1
- package/src/runtime/channels/lib/config.mjs +5 -29
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/config.mjs +1 -6
- package/src/runtime/shared/schedules-db.mjs +2 -120
- package/src/runtime/shared/webhooks-db.mjs +3 -138
- package/src/session-runtime/runtime-core.mjs +32 -10
- package/src/standalone/agent-tool/tool-def.mjs +1 -1
- package/src/standalone/channel-admin.mjs +6 -27
- package/src/tui/App.jsx +2 -1
- package/src/tui/components/StatusLine.jsx +2 -2
- package/src/tui/components/ToolExecution.jsx +10 -1
- package/src/tui/display-width.mjs +4 -1
- package/src/tui/dist/index.mjs +173 -157
- package/src/tui/engine/notification-plan.mjs +5 -1
- package/src/tui/index.jsx +2 -1
- package/src/workflows/default/WORKFLOW.md +15 -11
- package/vendor/ink/build/display-width.js +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mixdog",
|
|
3
|
-
"version": "0.9.
|
|
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
|
|
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
|
-
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
100
|
-
//
|
|
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
|
-
...
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
7
|
-
*
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
|
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: {
|