mixdog 0.9.28 → 0.9.30
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 +1 -1
- package/scripts/pending-completion-drop-test.mjs +64 -0
- package/src/rules/lead/lead-tool.md +1 -2
- package/src/rules/shared/01-tool.md +13 -11
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +4 -2
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +71 -19
- package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
- 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/shared/child-guardian.mjs +6 -61
- 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 +8 -2
- package/src/standalone/agent-tool/notify.mjs +5 -1
- package/src/standalone/channel-admin.mjs +6 -27
- package/src/tui/display-width.mjs +4 -1
- package/src/tui/dist/index.mjs +1 -1
- package/src/workflows/default/WORKFLOW.md +10 -10
- package/vendor/ink/build/display-width.js +3 -1
package/package.json
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Proves deferred agent/tool *completion* notifications are DROPPED on drain
|
|
2
|
+
// (never replayed out-of-order on a later session resume) while genuine
|
|
3
|
+
// user/steering messages in the same queue survive with order preserved.
|
|
4
|
+
// Owner decision: out-of-order replay is worse than loss.
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
// Redirect the pending-message spool to a throwaway dir before import so the
|
|
12
|
+
// real ~/.mixdog/data spool is never touched.
|
|
13
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'mixpend-'));
|
|
14
|
+
process.env.MIXDOG_DATA_DIR = dataDir;
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
enqueuePendingMessage,
|
|
18
|
+
drainPendingMessages,
|
|
19
|
+
COMPLETION_NOTIFICATION_KIND,
|
|
20
|
+
markCompletionEntry,
|
|
21
|
+
} = await import('../src/runtime/agent/orchestrator/session/manager/pending-messages.mjs');
|
|
22
|
+
|
|
23
|
+
function completionEntry(text) {
|
|
24
|
+
return { content: text, text, notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: Date.now() };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('drain drops completion notifications, keeps user messages in order', async () => {
|
|
28
|
+
const sid = 'sess_drop_test_1';
|
|
29
|
+
enqueuePendingMessage(sid, 'user first');
|
|
30
|
+
enqueuePendingMessage(sid, completionEntry('Async agent task xyz finished.\n\nResult:\n> done'));
|
|
31
|
+
enqueuePendingMessage(sid, 'user second');
|
|
32
|
+
|
|
33
|
+
// Let the buffered persist flush to disk so we also verify the on-disk marker
|
|
34
|
+
// (the shape a later resume would drain).
|
|
35
|
+
await new Promise((r) => setImmediate(r));
|
|
36
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
37
|
+
const store = JSON.parse(readFileSync(join(dataDir, 'session-pending-messages.json'), 'utf8'));
|
|
38
|
+
const persistedQueue = store.sessions[sid] || [];
|
|
39
|
+
const marked = persistedQueue.filter((e) => e && typeof e === 'object' && e.notificationKind === COMPLETION_NOTIFICATION_KIND);
|
|
40
|
+
assert.equal(marked.length, 1, 'completion notification persisted as a marked object');
|
|
41
|
+
assert.ok(persistedQueue.includes('user first') && persistedQueue.includes('user second'), 'user messages persisted as plain strings');
|
|
42
|
+
|
|
43
|
+
const drained = drainPendingMessages(sid).map((m) => (typeof m === 'string' ? m : m.text));
|
|
44
|
+
assert.deepEqual(drained, ['user first', 'user second'], 'completion dropped; user messages kept in order');
|
|
45
|
+
assert.ok(!drained.some((t) => /finished/.test(t)), 'no completion prose replayed');
|
|
46
|
+
|
|
47
|
+
// A second drain (post-resume) yields nothing — the completion was discarded,
|
|
48
|
+
// not deferred.
|
|
49
|
+
assert.deepEqual(drainPendingMessages(sid), []);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test.after(() => {
|
|
53
|
+
try { rmSync(dataDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Proves the shared tagger (used by notify.mjs, tool-exec.mjs, runtime-core.mjs)
|
|
57
|
+
// yields entries drain drops, while a real user message survives in order.
|
|
58
|
+
test('markCompletionEntry path is dropped on drain; user message survives', () => {
|
|
59
|
+
const sid = 'sess_drop_test_2';
|
|
60
|
+
enqueuePendingMessage(sid, 'real user question');
|
|
61
|
+
enqueuePendingMessage(sid, markCompletionEntry('Async agent task abc finished.\n\nResult:\n> ok'));
|
|
62
|
+
const drained = drainPendingMessages(sid).map((m) => (typeof m === 'string' ? m : m.text));
|
|
63
|
+
assert.deepEqual(drained, ['real user question']);
|
|
64
|
+
});
|
|
@@ -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.
|
|
@@ -9,7 +9,7 @@ import { executeBashSessionTool } from '../../tools/bash-session.mjs';
|
|
|
9
9
|
import { executePatchTool } from '../../tools/patch.mjs';
|
|
10
10
|
import { executeInternalTool, isInternalTool } from '../../internal-tools.mjs';
|
|
11
11
|
import { normalizeToolEnvelope, makeToolEnvelope } from '../tool-envelope.mjs';
|
|
12
|
-
import { getSessionAbortSignal, enqueuePendingMessage } from '../manager.mjs';
|
|
12
|
+
import { getSessionAbortSignal, enqueuePendingMessage, markCompletionEntry } from '../manager.mjs';
|
|
13
13
|
import { createScopedCacheOutcome } from '../cache/scoped-cache-outcome.mjs';
|
|
14
14
|
import { modelVisibleToolCompletionMessage } from '../../../../shared/tool-execution-contract.mjs';
|
|
15
15
|
import { _isScopedCacheableTool } from './tool-classify.mjs';
|
|
@@ -68,7 +68,9 @@ export async function executeTool(name, args, cwd, callerSessionId, sessionRef,
|
|
|
68
68
|
if (!notificationSessionId) return;
|
|
69
69
|
try {
|
|
70
70
|
const visible = modelVisibleToolCompletionMessage(text, meta);
|
|
71
|
-
|
|
71
|
+
// Inherently a tool-completion notification → tag so a later
|
|
72
|
+
// resume drops it instead of replaying it as user text.
|
|
73
|
+
if (visible) enqueuePendingMessage(notificationSessionId, markCompletionEntry(visible));
|
|
72
74
|
} catch { /* best effort */ }
|
|
73
75
|
};
|
|
74
76
|
const completionToolOpts = {
|
|
@@ -11,9 +11,31 @@ const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
|
|
|
11
11
|
const PENDING_MESSAGES_MODE = 0o600;
|
|
12
12
|
const PENDING_ORPHAN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
13
13
|
const PENDING_ORPHAN_GRACE_MS = 60 * 60 * 1000;
|
|
14
|
+
// Marker for deferred agent/tool *completion* notifications. Such entries must
|
|
15
|
+
// never be replayed into a later turn on session resume (out-of-order delivery
|
|
16
|
+
// is worse than loss — owner decision), so drain discards them. Genuine
|
|
17
|
+
// user/steering messages carry no marker and keep full queue + replay behavior.
|
|
18
|
+
export const COMPLETION_NOTIFICATION_KIND = 'completion_notification';
|
|
14
19
|
const _pendingPersistBuffers = new Map();
|
|
15
20
|
let _pendingPersistImmediate = null;
|
|
16
21
|
|
|
22
|
+
function isCompletionNotificationEntry(entry) {
|
|
23
|
+
return Boolean(entry) && typeof entry === 'object'
|
|
24
|
+
&& entry.notificationKind === COMPLETION_NOTIFICATION_KIND;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Canonical completion-enqueue tagger. Every deferred tool/agent completion
|
|
28
|
+
// notification MUST be enqueued through this so drain can discard it on resume
|
|
29
|
+
// (never replay out-of-order). Pass the model-visible completion text (or an
|
|
30
|
+
// existing entry); genuine user/steering messages must NOT be tagged.
|
|
31
|
+
export function markCompletionEntry(text) {
|
|
32
|
+
const value = typeof text === 'string'
|
|
33
|
+
? text
|
|
34
|
+
: (text && typeof text === 'object' ? (text.text || text.content || '') : '');
|
|
35
|
+
const content = String(value ?? '');
|
|
36
|
+
return { content, text: content, notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: Date.now() };
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
function pendingMessagesPath() {
|
|
18
40
|
return join(resolvePluginData(), PENDING_MESSAGES_FILE);
|
|
19
41
|
}
|
|
@@ -53,13 +75,7 @@ function normalizePendingStore(raw) {
|
|
|
53
75
|
if (!isValidPendingSessionId(sid) || !Array.isArray(value)) continue;
|
|
54
76
|
const q = isTuiSteeringPendingKey(sid)
|
|
55
77
|
? value.map(normalizeTuiSteeringQueueEntry).filter(Boolean)
|
|
56
|
-
: value
|
|
57
|
-
.map((entry) => {
|
|
58
|
-
if (typeof entry === 'string') return entry;
|
|
59
|
-
if (entry && typeof entry === 'object' && typeof entry.message === 'string') return entry.message;
|
|
60
|
-
return '';
|
|
61
|
-
})
|
|
62
|
-
.filter(Boolean);
|
|
78
|
+
: value.map(normalizePersistedEntry).filter(Boolean);
|
|
63
79
|
if (q.length > 0) {
|
|
64
80
|
out.sessions[sid] = q;
|
|
65
81
|
const touched = Number(touchedRaw[sid]);
|
|
@@ -85,16 +101,22 @@ function normalizePendingMessageEntry(entry) {
|
|
|
85
101
|
return { content: entry, text };
|
|
86
102
|
}
|
|
87
103
|
if (!entry || typeof entry !== 'object') return null;
|
|
104
|
+
const marker = entry.notificationKind === COMPLETION_NOTIFICATION_KIND
|
|
105
|
+
? { notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: Number(entry.enqueuedAt) || Date.now() }
|
|
106
|
+
: null;
|
|
88
107
|
const content = Object.prototype.hasOwnProperty.call(entry, 'content') ? entry.content : null;
|
|
89
108
|
if (content == null) return null;
|
|
90
109
|
const text = typeof entry.text === 'string' ? entry.text.trim() : promptContentText(content).trim();
|
|
91
|
-
|
|
92
|
-
if (
|
|
110
|
+
let out = null;
|
|
111
|
+
if (Array.isArray(content)) out = content.length > 0 ? { content, text } : null;
|
|
112
|
+
else if (typeof content === 'string') {
|
|
93
113
|
const value = content.trim();
|
|
94
|
-
|
|
114
|
+
out = value ? { content: value, text: text || value } : null;
|
|
115
|
+
} else {
|
|
116
|
+
const fallback = promptContentText(content).trim();
|
|
117
|
+
out = fallback ? { content: fallback, text: text || fallback } : null;
|
|
95
118
|
}
|
|
96
|
-
|
|
97
|
-
return fallback ? { content: fallback, text: text || fallback } : null;
|
|
119
|
+
return out && marker ? { ...out, ...marker } : out;
|
|
98
120
|
}
|
|
99
121
|
|
|
100
122
|
function pendingMessageText(entry) {
|
|
@@ -105,14 +127,39 @@ function pendingMessageText(entry) {
|
|
|
105
127
|
function pendingMessageQueueEntry(entry) {
|
|
106
128
|
const normalized = normalizePendingMessageEntry(entry);
|
|
107
129
|
if (!normalized) return null;
|
|
108
|
-
|
|
109
|
-
|
|
130
|
+
const marker = isCompletionNotificationEntry(normalized)
|
|
131
|
+
? { notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: normalized.enqueuedAt }
|
|
132
|
+
: null;
|
|
133
|
+
if (typeof normalized.content === 'string' && normalized.content === normalized.text && !marker) return normalized.content;
|
|
134
|
+
const base = { content: normalized.content, text: normalized.text || promptContentText(normalized.content).trim() };
|
|
135
|
+
return marker ? { ...base, ...marker } : base;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Canonical persisted-queue entry: a plain string for user/steering messages,
|
|
139
|
+
// or a { message, notificationKind, enqueuedAt } object for completion/task
|
|
140
|
+
// notifications so the marker survives an on-disk round trip. Accepts either an
|
|
141
|
+
// in-memory queue entry (content/text) or an already-persisted entry
|
|
142
|
+
// (string | { message } legacy | marked object); back-compatible with both.
|
|
143
|
+
function normalizePersistedEntry(entry) {
|
|
144
|
+
if (typeof entry === 'string') { const t = entry.trim(); return t || null; }
|
|
145
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
146
|
+
if (isCompletionNotificationEntry(entry)) {
|
|
147
|
+
const message = (typeof entry.message === 'string' && entry.message.trim())
|
|
148
|
+
? entry.message.trim()
|
|
149
|
+
: pendingMessageText(entry);
|
|
150
|
+
return message
|
|
151
|
+
? { message, notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: Number(entry.enqueuedAt) || Date.now() }
|
|
152
|
+
: null;
|
|
153
|
+
}
|
|
154
|
+
if (typeof entry.message === 'string') { const t = entry.message.trim(); return t || null; }
|
|
155
|
+
const t = pendingMessageText(entry);
|
|
156
|
+
return t || null;
|
|
110
157
|
}
|
|
111
158
|
|
|
112
159
|
function persistPendingMessages(sessionId, messages) {
|
|
113
160
|
if (!isValidPendingSessionId(sessionId)) return 0;
|
|
114
161
|
const persistedMessages = (Array.isArray(messages) ? messages : [messages])
|
|
115
|
-
.map(
|
|
162
|
+
.map(normalizePersistedEntry)
|
|
116
163
|
.filter(Boolean);
|
|
117
164
|
if (persistedMessages.length === 0) return 0;
|
|
118
165
|
// Async lock wait: this runs on the lead/TUI main process (tool-exec +
|
|
@@ -159,7 +206,7 @@ function flushPendingMessagePersistsSync() {
|
|
|
159
206
|
|
|
160
207
|
function schedulePendingMessagePersist(sessionId, message) {
|
|
161
208
|
if (!isValidPendingSessionId(sessionId)) return 0;
|
|
162
|
-
const persistedMessage =
|
|
209
|
+
const persistedMessage = normalizePersistedEntry(message);
|
|
163
210
|
if (!persistedMessage) return 0;
|
|
164
211
|
const q = _pendingPersistBuffers.get(sessionId) || [];
|
|
165
212
|
q.push(persistedMessage);
|
|
@@ -192,7 +239,7 @@ function drainPersistedPendingMessages(sessionId) {
|
|
|
192
239
|
updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
|
|
193
240
|
const next = normalizePendingStore(raw);
|
|
194
241
|
const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
|
|
195
|
-
drained = q.filter((m) => typeof m === 'string' && m.length > 0);
|
|
242
|
+
drained = q.filter((m) => (typeof m === 'string' && m.length > 0) || isCompletionNotificationEntry(m));
|
|
196
243
|
if (drained.length === 0) return undefined;
|
|
197
244
|
delete next.sessions[sessionId];
|
|
198
245
|
if (next.sessionTouchedAt) delete next.sessionTouchedAt[sessionId];
|
|
@@ -335,8 +382,13 @@ export function drainPendingMessages(sessionId) {
|
|
|
335
382
|
// first. Reversing this order (buffer before disk) delivered newer
|
|
336
383
|
// buffered sends ahead of older persisted ones after a restart.
|
|
337
384
|
const persisted = [...drainPersistedPendingMessages(sessionId), ...takeBufferedPendingMessages(sessionId)];
|
|
338
|
-
|
|
339
|
-
|
|
385
|
+
// Discard deferred completion/task notifications instead of injecting them
|
|
386
|
+
// out-of-order into a future turn on resume. Genuine user/steering messages
|
|
387
|
+
// (plain strings / content entries) carry no marker and are kept in order.
|
|
388
|
+
const memoryKept = memory.filter((m) => !isCompletionNotificationEntry(m));
|
|
389
|
+
const persistedKept = persisted.filter((m) => !isCompletionNotificationEntry(m));
|
|
390
|
+
const memoryVisible = modelVisiblePendingMessages(memoryKept);
|
|
391
|
+
const persistedVisible = modelVisiblePendingMessages(persistedKept);
|
|
340
392
|
if (memoryVisible.length === 0) return persistedVisible;
|
|
341
393
|
if (persistedVisible.length === 0) return memoryVisible;
|
|
342
394
|
const persistedTexts = persistedVisible.map(pendingMessageText);
|
|
@@ -63,6 +63,8 @@ export {
|
|
|
63
63
|
_mergePendingMessageEntries,
|
|
64
64
|
enqueuePendingMessage,
|
|
65
65
|
drainPendingMessages,
|
|
66
|
+
markCompletionEntry,
|
|
67
|
+
COMPLETION_NOTIFICATION_KIND,
|
|
66
68
|
} from './manager/pending-messages.mjs';
|
|
67
69
|
export { isInternalRuntimeNotificationText as _isInternalRuntimeNotificationText } from './manager/prompt-utils.mjs';
|
|
68
70
|
|
|
@@ -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 }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
import { spawn
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
4
|
import { detachedSpawnOpts } from './spawn-flags.mjs';
|
|
5
5
|
|
|
6
6
|
function positiveInt(value) {
|
|
@@ -8,64 +8,12 @@ function positiveInt(value) {
|
|
|
8
8
|
return Number.isInteger(n) && n > 0 ? n : null;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
// PID alive after its terminal window is closed, so parent-liveness alone never
|
|
13
|
-
// releases the worker. When the session is genuinely interactive (a TTY), the
|
|
14
|
-
// controlling console window is owned by a host process (conhost.exe) that dies
|
|
15
|
-
// with the terminal — monitoring that PID turns "terminal closed" into a fatal
|
|
16
|
-
// orphan signal. Returns null for non-interactive/service/hidden-console starts
|
|
17
|
-
// (no TTY, or no console window) so those are never treated as terminal loss.
|
|
18
|
-
function detectControllingTerminalPid() {
|
|
19
|
-
if (process.platform !== 'win32') return null;
|
|
20
|
-
// Gate on a real TTY: a service/pipe/hidden launch has no controlling
|
|
21
|
-
// terminal, and probing one there would spawn a transient console whose host
|
|
22
|
-
// dies immediately — a false "terminal lost" that would kill a healthy worker.
|
|
23
|
-
const interactive = Boolean(
|
|
24
|
-
(process.stdin && process.stdin.isTTY) || (process.stdout && process.stdout.isTTY),
|
|
25
|
-
);
|
|
26
|
-
if (!interactive) return null;
|
|
27
|
-
try {
|
|
28
|
-
const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows';
|
|
29
|
-
const powershell = systemRoot + '\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
|
|
30
|
-
const script = [
|
|
31
|
-
'Add-Type @"',
|
|
32
|
-
'using System;',
|
|
33
|
-
'using System.Runtime.InteropServices;',
|
|
34
|
-
'public class MixdogTerm {',
|
|
35
|
-
' [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();',
|
|
36
|
-
' [DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(IntPtr h, out int pid);',
|
|
37
|
-
'}',
|
|
38
|
-
'"@',
|
|
39
|
-
'$h=[MixdogTerm]::GetConsoleWindow()',
|
|
40
|
-
'if($h -eq [IntPtr]::Zero){ Write-Output 0 } else { $p=0; [void][MixdogTerm]::GetWindowThreadProcessId($h,[ref]$p); Write-Output $p }',
|
|
41
|
-
].join('\n');
|
|
42
|
-
// NO windowsHide: CREATE_NO_WINDOW gives the probe its own/absent console so
|
|
43
|
-
// GetConsoleWindow() returns 0 or a foreign hwnd. A plain console app spawned
|
|
44
|
-
// from a console-attached parent (this runs at the guardian start site, in the
|
|
45
|
-
// guarded parent) inherits that console with no window flash. Under Windows
|
|
46
|
-
// Terminal/ConPTY the owner is the tab's conhost (dies on tab close), which is
|
|
47
|
-
// exactly the pid we want to monitor.
|
|
48
|
-
const res = spawnSync(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
|
|
49
|
-
timeout: 4000,
|
|
50
|
-
encoding: 'utf8',
|
|
51
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
52
|
-
});
|
|
53
|
-
const pid = positiveInt(String((res && res.stdout) || '').trim());
|
|
54
|
-
// Ignore our own PID: that would make the guardian its own kill trigger.
|
|
55
|
-
if (pid && pid !== process.pid) return pid;
|
|
56
|
-
return null;
|
|
57
|
-
} catch {
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function guardianScript({ parentPid, childPid, childGroupPid, terminalPid, platform, pollMs, orphanGraceMs, forceGraceMs }) {
|
|
11
|
+
function guardianScript({ parentPid, childPid, childGroupPid, platform, pollMs, orphanGraceMs, forceGraceMs }) {
|
|
63
12
|
return `
|
|
64
13
|
const { spawnSync } = require('node:child_process');
|
|
65
14
|
const parentPid = ${JSON.stringify(parentPid)};
|
|
66
15
|
const childPid = ${JSON.stringify(childPid)};
|
|
67
16
|
const childGroupPid = ${JSON.stringify(childGroupPid || childPid)};
|
|
68
|
-
const terminalPid = ${JSON.stringify(terminalPid || 0)};
|
|
69
17
|
const platform = ${JSON.stringify(platform)};
|
|
70
18
|
const pollMs = ${JSON.stringify(pollMs)};
|
|
71
19
|
const orphanGraceMs = ${JSON.stringify(orphanGraceMs)};
|
|
@@ -95,12 +43,10 @@ let killing = false;
|
|
|
95
43
|
let orphanedAt = 0;
|
|
96
44
|
const timer = setInterval(() => {
|
|
97
45
|
if (!alive(childPid)) process.exit(0);
|
|
98
|
-
// Orphaned when the parent PID dies
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
const terminalLost = terminalPid > 0 && !alive(terminalPid);
|
|
103
|
-
if (alive(parentPid) && !terminalLost) {
|
|
46
|
+
// Orphaned when the parent PID dies. Matches Claude Code / pi: no console
|
|
47
|
+
// probe — a hidden shell spawn (windowsHide) plus tree-kill on cleanup, so
|
|
48
|
+
// orphan detection is pure parent-liveness.
|
|
49
|
+
if (alive(parentPid)) {
|
|
104
50
|
orphanedAt = 0;
|
|
105
51
|
return;
|
|
106
52
|
}
|
|
@@ -135,7 +81,6 @@ export function startChildGuardian({
|
|
|
135
81
|
parentPid: parent,
|
|
136
82
|
childPid: child,
|
|
137
83
|
childGroupPid: positiveInt(childGroupPid) || child,
|
|
138
|
-
terminalPid: detectControllingTerminalPid() || 0,
|
|
139
84
|
platform: process.platform,
|
|
140
85
|
pollMs: Math.max(100, Math.floor(Number(pollMs) || 750)),
|
|
141
86
|
orphanGraceMs: Math.max(100, Math.floor(Number(orphanGraceMs) || Number(graceMs) || 3000)),
|
|
@@ -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
|
-
}
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from '../runtime/shared/markdown-frontmatter.mjs';
|
|
32
32
|
import { setConfiguredShell } from '../runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs';
|
|
33
33
|
import { hasUserConversationMessage } from '../runtime/agent/orchestrator/session/manager/prompt-utils.mjs';
|
|
34
|
+
import { markCompletionEntry } from '../runtime/agent/orchestrator/session/manager/pending-messages.mjs';
|
|
34
35
|
import {
|
|
35
36
|
beginOAuthProviderLogin,
|
|
36
37
|
forgetProviderAuth,
|
|
@@ -773,7 +774,9 @@ export async function createMixdogSessionRuntime({
|
|
|
773
774
|
&& shouldPersistModelVisibleToolCompletion(text, meta)) {
|
|
774
775
|
try {
|
|
775
776
|
const visible = modelVisibleToolCompletionMessage(text, meta);
|
|
776
|
-
|
|
777
|
+
// Terminal completion (gated by shouldPersistModelVisibleToolCompletion)
|
|
778
|
+
// → tag so drain discards it on resume rather than replaying out-of-order.
|
|
779
|
+
if (visible) enqueued = mgr.enqueuePendingMessage(callerSessionId, markCompletionEntry(visible)) > 0;
|
|
777
780
|
} catch {}
|
|
778
781
|
}
|
|
779
782
|
// Headless/API listeners may exist but not consume the event; preserve
|
|
@@ -782,7 +785,10 @@ export async function createMixdogSessionRuntime({
|
|
|
782
785
|
&& typeof mgr.enqueuePendingMessage === 'function') {
|
|
783
786
|
try {
|
|
784
787
|
const visible = modelVisibleToolCompletionMessage(text, meta);
|
|
785
|
-
|
|
788
|
+
// modelVisibleToolCompletionMessage only returns non-empty for a
|
|
789
|
+
// persistable terminal completion, so this fallback is a completion
|
|
790
|
+
// too → tag it (genuine non-completion notifications yield '' above).
|
|
791
|
+
if (visible) enqueued = mgr.enqueuePendingMessage(callerSessionId, markCompletionEntry(visible)) > 0;
|
|
786
792
|
} catch {}
|
|
787
793
|
}
|
|
788
794
|
return enqueued || handledByRuntimeListener;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Behavior-preserving: bodies identical to the originals; deps injected.
|
|
4
4
|
import { modelVisibleToolCompletionMessage } from '../../runtime/shared/tool-execution-contract.mjs';
|
|
5
5
|
import { renderBackgroundTask, sanitizeTaskMeta, setBackgroundTaskEnqueueFallback } from '../../runtime/shared/background-tasks.mjs';
|
|
6
|
+
import { markCompletionEntry } from '../../runtime/agent/orchestrator/session/manager/pending-messages.mjs';
|
|
6
7
|
import { clean } from './helpers.mjs';
|
|
7
8
|
|
|
8
9
|
export function createNotify(mgr) {
|
|
@@ -11,7 +12,10 @@ export function createNotify(mgr) {
|
|
|
11
12
|
if (!target || typeof mgr.enqueuePendingMessage !== 'function') return false;
|
|
12
13
|
try {
|
|
13
14
|
const visible = modelVisibleToolCompletionMessage(text, meta);
|
|
14
|
-
|
|
15
|
+
if (!visible) return false;
|
|
16
|
+
// Mark this as a deferred completion/task notification so a later session
|
|
17
|
+
// resume drops it rather than replaying it out-of-order (owner decision).
|
|
18
|
+
return Boolean(mgr.enqueuePendingMessage(target, markCompletionEntry(visible)) > 0);
|
|
15
19
|
} catch {
|
|
16
20
|
return false;
|
|
17
21
|
}
|
|
@@ -102,42 +102,22 @@ function seedBackendChannelIds(entry = {}, backend = 'discord') {
|
|
|
102
102
|
return next;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
// Resolve the single-channel entry from the config
|
|
106
|
-
// object; fall back (read-side only, no on-disk migration) to the legacy
|
|
107
|
-
// channelsConfig[mainChannel ?? 'main'] entry, then the first entry with an id.
|
|
105
|
+
// Resolve the single-channel entry from the config's `channel` object.
|
|
108
106
|
function resolveChannelEntry(cfg = {}) {
|
|
109
107
|
if (cfg.channel && typeof cfg.channel === 'object'
|
|
110
108
|
&& (cfg.channel.channelId || cfg.channel.discordChannelId || cfg.channel.telegramChatId)) {
|
|
111
109
|
return cfg.channel;
|
|
112
110
|
}
|
|
113
|
-
const legacy = cfg.channelsConfig && typeof cfg.channelsConfig === 'object' ? cfg.channelsConfig : null;
|
|
114
|
-
if (legacy) {
|
|
115
|
-
const mainName = cfg.mainChannel ?? 'main';
|
|
116
|
-
const preferred = legacy[mainName];
|
|
117
|
-
if (preferred && typeof preferred === 'object'
|
|
118
|
-
&& (preferred.channelId || preferred.discordChannelId || preferred.telegramChatId)) {
|
|
119
|
-
return preferred;
|
|
120
|
-
}
|
|
121
|
-
for (const entry of Object.values(legacy)) {
|
|
122
|
-
if (entry && typeof entry === 'object'
|
|
123
|
-
&& (entry.channelId || entry.discordChannelId || entry.telegramChatId)) {
|
|
124
|
-
return entry;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
111
|
return cfg.channel && typeof cfg.channel === 'object' ? cfg.channel : {};
|
|
129
112
|
}
|
|
130
113
|
|
|
131
114
|
function updateChannelsSection(build) {
|
|
132
115
|
let next;
|
|
133
116
|
updateSection('channels', (current) => {
|
|
134
|
-
//
|
|
135
|
-
// for read-side compat, but never re-emit them from our writers: strip them
|
|
136
|
-
// out of the returned shape so writes converge on the single `channel`.
|
|
117
|
+
// Writes converge on the single `channel` object.
|
|
137
118
|
const normalized = normalizeChannelsConfig(current);
|
|
138
119
|
next = build(normalized);
|
|
139
|
-
|
|
140
|
-
return clean;
|
|
120
|
+
return normalizeChannelsConfig(next);
|
|
141
121
|
});
|
|
142
122
|
return next;
|
|
143
123
|
}
|
|
@@ -151,8 +131,7 @@ async function updateChannelsSectionAsync(build) {
|
|
|
151
131
|
await updateSectionAsync('channels', (current) => {
|
|
152
132
|
const normalized = normalizeChannelsConfig(current);
|
|
153
133
|
next = build(normalized);
|
|
154
|
-
|
|
155
|
-
return clean;
|
|
134
|
+
return normalizeChannelsConfig(next);
|
|
156
135
|
});
|
|
157
136
|
return next;
|
|
158
137
|
}
|
|
@@ -168,8 +147,8 @@ function listEntryDirs(dir) {
|
|
|
168
147
|
}
|
|
169
148
|
}
|
|
170
149
|
|
|
171
|
-
// Single-channel read: resolves `cfg.channel`
|
|
172
|
-
//
|
|
150
|
+
// Single-channel read: resolves `cfg.channel` into the flat shape the
|
|
151
|
+
// settings/TUI layer consumes.
|
|
173
152
|
export function getChannel(config = {}) {
|
|
174
153
|
const cfg = normalizeChannelsConfig(config);
|
|
175
154
|
const backend = cfg.backend === 'telegram' ? 'telegram' : 'discord';
|
|
@@ -38,9 +38,12 @@ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme
|
|
|
38
38
|
* markers (agent card ←/→, history ↑/↓): WT draws them 1 cell in
|
|
39
39
|
* Cascadia, and widening them ate the marker's gutter padding space
|
|
40
40
|
* ("←Spawn" rendered glued / shifted vs the ● rows).
|
|
41
|
+
* Also excludes U+21BB (↻, the statusline quota-reset marker): WT/Cascadia
|
|
42
|
+
* draws it 1 cell, so widening it reserved a phantom cell that shifted the
|
|
43
|
+
* right-aligned workflow label one column left when the usage segment appeared.
|
|
41
44
|
*/
|
|
42
45
|
export function isProblemCodePoint(cp) {
|
|
43
|
-
return (cp >= 0x2460 && cp <= 0x24ff) || (cp >= 0x2194 && cp <= 0x21ff);
|
|
46
|
+
return (cp >= 0x2460 && cp <= 0x24ff) || (cp >= 0x2194 && cp <= 0x21ff && cp !== 0x21bb);
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
// Fast precheck for the problem ranges above. Lets the hot path bail before
|
package/src/tui/dist/index.mjs
CHANGED
|
@@ -4342,7 +4342,7 @@ import { Box as Box3, Text as Text3, useInput, usePaste, useStdin } from "../../
|
|
|
4342
4342
|
import stringWidth from "string-width";
|
|
4343
4343
|
var graphemeSegmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
|
|
4344
4344
|
function isProblemCodePoint(cp) {
|
|
4345
|
-
return cp >= 9312 && cp <= 9471 || cp >= 8596 && cp <= 8703;
|
|
4345
|
+
return cp >= 9312 && cp <= 9471 || cp >= 8596 && cp <= 8703 && cp !== 8635;
|
|
4346
4346
|
}
|
|
4347
4347
|
var PROBLEM_RE = /[\u2194-\u21ff\u2460-\u24ff]/;
|
|
4348
4348
|
function resolveAmbiguousWidePolicy(env = process.env, platform = process.platform) {
|
|
@@ -23,14 +23,14 @@ Lead supervises: delegates, coordinates, judges, decides. Route by complexity
|
|
|
23
23
|
1. Plan — present a draft plan before ANY implementation; if not approved,
|
|
24
24
|
revise and re-present (ping-pong) until an explicit go-ahead.
|
|
25
25
|
2. Delegate — maximize distribution: split the work into as many independent
|
|
26
|
-
scopes as possible and hand each to its own
|
|
27
|
-
turn (parallel by default; sequential
|
|
28
|
-
gated build/test-green).
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
implementation scopes as possible and hand each to its own worker or
|
|
27
|
+
heavy-worker, all spawned in the SAME turn (parallel by default; sequential
|
|
28
|
+
steps only inside one complex scope, gated build/test-green). Fan them out
|
|
29
|
+
across agents, never one at a time. Only a genuinely inseparable single
|
|
30
|
+
scope stays whole, and say so. Briefs per the Lead brief contract. After
|
|
31
|
+
spawning async agents, END THE TURN.
|
|
32
|
+
3. Review — spawn one reviewer 1:1 per implementation scope, all reviewers in
|
|
33
|
+
the same turn once their scopes land.
|
|
34
34
|
Cross-check agent results yourself; send fixes back to the original scope
|
|
35
35
|
and loop fix -> re-verify until clean. Skip only for simple low-risk work.
|
|
36
36
|
Debugger first when the user asks for debugging or a bug survives 2+ fix
|
|
@@ -39,7 +39,7 @@ Lead supervises: delegates, coordinates, judges, decides. Route by complexity
|
|
|
39
39
|
and how work proceeds, marked in-progress — never as a conclusion.
|
|
40
40
|
4. Report — final report briefs the whole work vs the approved plan and the
|
|
41
41
|
verified result, distinct from interim updates. Never forward raw agent
|
|
42
|
-
output. Ask about ship/deploy when relevant; deploy/build/commit only
|
|
43
|
-
|
|
42
|
+
output. Ask about ship/deploy when relevant; deploy/build/commit only on an
|
|
43
|
+
explicit user request, after feedback with no issues.
|
|
44
44
|
|
|
45
45
|
On major direction shifts mid-work, pause and re-consult the user.
|
|
@@ -24,7 +24,9 @@ import stringWidth from 'string-width';
|
|
|
24
24
|
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
25
25
|
|
|
26
26
|
function isProblemCodePoint(cp) {
|
|
27
|
-
|
|
27
|
+
// [mixdog fork] U+21BB (↻ quota-reset marker) excluded: WT draws it 1 cell,
|
|
28
|
+
// so widening it shifted the right-aligned statusline label one col left.
|
|
29
|
+
return (cp >= 0x2460 && cp <= 0x24ff) || (cp >= 0x2194 && cp <= 0x21ff && cp !== 0x21bb);
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
// [mixdog fork] Fast precheck for the problem ranges above. Lets the hot path
|