alvin-bot 5.3.0 → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +100 -0
- package/CHANGELOG.md +68 -3
- package/README.md +2 -0
- package/alvin-bot.config.example.json +1 -1
- package/dist/config.js +7 -4
- package/dist/handlers/commands.js +10 -2
- package/dist/handlers/document.js +8 -1
- package/dist/handlers/message.js +173 -30
- package/dist/i18n.js +21 -0
- package/dist/index.js +19 -1
- package/dist/init-data-dir.js +17 -0
- package/dist/middleware/auth.js +19 -1
- package/dist/providers/tool-executor.js +29 -4
- package/dist/services/async-agent-watcher.js +105 -14
- package/dist/services/browser-manager.js +11 -9
- package/dist/services/browser-webfetch.js +47 -13
- package/dist/services/cron-scheduling.js +79 -19
- package/dist/services/cron.js +205 -16
- package/dist/services/delivery-queue.js +19 -0
- package/dist/services/embeddings/index.js +2 -5
- package/dist/services/env-file.js +4 -0
- package/dist/services/personality.js +40 -37
- package/dist/services/session-persistence.js +21 -3
- package/dist/services/session.js +3 -0
- package/dist/services/ssrf-guard.js +162 -0
- package/dist/services/steer-channel.js +7 -2
- package/dist/services/subagent-delivery.js +31 -8
- package/dist/services/telegram.js +9 -0
- package/dist/services/trends.js +202 -2
- package/dist/services/voice.js +0 -3
- package/dist/web/server.js +155 -5
- package/package.json +8 -7
package/dist/services/cron.js
CHANGED
|
@@ -17,13 +17,113 @@ import { prepareForExecution, handleStartupCatchup, calculateNextRunFrom, } from
|
|
|
17
17
|
import { resolveJobByNameOrId } from "./cron-resolver.js";
|
|
18
18
|
import { bootWasExpectedRestart } from "./watchdog.js";
|
|
19
19
|
// ── Storage ─────────────────────────────────────────────
|
|
20
|
+
/** Allowed job types — must stay in sync with the JobType union above. */
|
|
21
|
+
const ALLOWED_JOB_TYPES = new Set(["reminder", "shell", "ai-query", "http", "message"]);
|
|
22
|
+
/**
|
|
23
|
+
* M4: Per-entry structural validation for a raw parsed cron-jobs.json entry.
|
|
24
|
+
*
|
|
25
|
+
* Rules:
|
|
26
|
+
* - Must be a non-null object
|
|
27
|
+
* - `id`, `name`, `schedule`, `createdBy` must be non-empty strings
|
|
28
|
+
* - `type` must be one of the allowed JobType values
|
|
29
|
+
* - `payload` must be an object (not null)
|
|
30
|
+
* - `target` must be an object with `platform` (string) and `chatId` (string)
|
|
31
|
+
* - `enabled` must be a boolean
|
|
32
|
+
* - `createdAt`, `runCount` must be numbers
|
|
33
|
+
* - `oneShot` must be a boolean
|
|
34
|
+
* - Nullable fields (lastRunAt, lastResult, lastError, nextRunAt) can be
|
|
35
|
+
* null or their expected types — lenient check, just must not be undefined
|
|
36
|
+
*
|
|
37
|
+
* Returns a validated CronJob (cast) on success, null on failure.
|
|
38
|
+
* Logs a calm warning for every skipped entry.
|
|
39
|
+
*/
|
|
40
|
+
function validateCronJobEntry(raw, index) {
|
|
41
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
42
|
+
console.warn(`[cron] skipping entry #${index}: not an object`);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const e = raw;
|
|
46
|
+
if (typeof e.id !== "string" || !e.id) {
|
|
47
|
+
console.warn(`[cron] skipping entry #${index}: missing or invalid 'id'`);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (typeof e.name !== "string" || !e.name) {
|
|
51
|
+
console.warn(`[cron] skipping entry #${index} (id=${e.id}): missing or invalid 'name'`);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
if (typeof e.type !== "string" || !ALLOWED_JOB_TYPES.has(e.type)) {
|
|
55
|
+
console.warn(`[cron] skipping entry #${index} (id=${e.id}): unknown or missing job type '${e.type}'`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (typeof e.schedule !== "string" || !e.schedule) {
|
|
59
|
+
console.warn(`[cron] skipping entry #${index} (id=${e.id}): missing or invalid 'schedule'`);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (!e.payload || typeof e.payload !== "object" || Array.isArray(e.payload)) {
|
|
63
|
+
console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'payload' must be an object`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
if (!e.target || typeof e.target !== "object" || Array.isArray(e.target)) {
|
|
67
|
+
console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'target' must be an object`);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const t = e.target;
|
|
71
|
+
if (typeof t.platform !== "string" || typeof t.chatId !== "string") {
|
|
72
|
+
console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'target.platform' and 'target.chatId' must be strings`);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
if (typeof e.enabled !== "boolean") {
|
|
76
|
+
console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'enabled' must be a boolean`);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (typeof e.createdAt !== "number") {
|
|
80
|
+
console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'createdAt' must be a number`);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (typeof e.runCount !== "number") {
|
|
84
|
+
console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'runCount' must be a number`);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (typeof e.oneShot !== "boolean") {
|
|
88
|
+
console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'oneShot' must be a boolean`);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
// Lenient nullable fields
|
|
92
|
+
if (e.createdBy !== undefined && typeof e.createdBy !== "string") {
|
|
93
|
+
console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'createdBy' must be a string`);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return raw;
|
|
97
|
+
}
|
|
20
98
|
function loadJobs() {
|
|
99
|
+
let raw;
|
|
21
100
|
try {
|
|
22
|
-
|
|
101
|
+
raw = fs.readFileSync(CRON_FILE, "utf-8");
|
|
23
102
|
}
|
|
24
103
|
catch {
|
|
25
104
|
return [];
|
|
26
105
|
}
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(raw);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
console.warn("[cron] cron-jobs.json is not valid JSON — starting with empty job list:", err instanceof Error ? err.message : String(err));
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
if (!Array.isArray(parsed)) {
|
|
115
|
+
console.warn("[cron] cron-jobs.json root is not an array — starting with empty job list");
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
// M4: Per-entry validation — one bad entry must NOT crash the whole list
|
|
119
|
+
const jobs = [];
|
|
120
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
121
|
+
const validated = validateCronJobEntry(parsed[i], i);
|
|
122
|
+
if (validated !== null) {
|
|
123
|
+
jobs.push(validated);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return jobs;
|
|
27
127
|
}
|
|
28
128
|
function saveJobs(jobs) {
|
|
29
129
|
const dir = dirname(CRON_FILE);
|
|
@@ -54,27 +154,87 @@ function nextCronRun(expression, after = new Date()) {
|
|
|
54
154
|
if (parts.length !== 5)
|
|
55
155
|
return null;
|
|
56
156
|
const [minExpr, hourExpr, dayExpr, monthExpr, weekdayExpr] = parts;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Parse a single cron field token (no commas — commas handled by parseField).
|
|
159
|
+
* Supports: `*`, `a`, `a-b`, `a-b/s`, `*\/s`, `a/s`.
|
|
160
|
+
* Returns null for invalid/garbage tokens.
|
|
161
|
+
*/
|
|
162
|
+
function parseFieldToken(token, min, max) {
|
|
163
|
+
const fullRange = () => Array.from({ length: max - min + 1 }, (_, i) => i + min);
|
|
164
|
+
if (token.includes("/")) {
|
|
165
|
+
const slashIdx = token.indexOf("/");
|
|
166
|
+
const basePart = token.slice(0, slashIdx);
|
|
167
|
+
const stepPart = token.slice(slashIdx + 1);
|
|
168
|
+
const step = parseInt(stepPart, 10);
|
|
169
|
+
if (!Number.isFinite(step) || step <= 0)
|
|
170
|
+
return null;
|
|
171
|
+
let base;
|
|
172
|
+
if (basePart === "*") {
|
|
173
|
+
base = fullRange();
|
|
174
|
+
}
|
|
175
|
+
else if (basePart.includes("-")) {
|
|
176
|
+
const dashParts = basePart.split("-");
|
|
177
|
+
if (dashParts.length !== 2)
|
|
178
|
+
return null;
|
|
179
|
+
const a = parseInt(dashParts[0], 10);
|
|
180
|
+
const b = parseInt(dashParts[1], 10);
|
|
181
|
+
if (!Number.isFinite(a) || !Number.isFinite(b) || a > b || a < min || b > max)
|
|
182
|
+
return null;
|
|
183
|
+
base = Array.from({ length: b - a + 1 }, (_, i) => i + a);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const a = parseInt(basePart, 10);
|
|
187
|
+
if (!Number.isFinite(a) || a < min || a > max)
|
|
188
|
+
return null;
|
|
189
|
+
base = [a];
|
|
190
|
+
}
|
|
191
|
+
const baseStart = base[0];
|
|
192
|
+
return base.filter((v) => (v - baseStart) % step === 0);
|
|
64
193
|
}
|
|
65
|
-
if (
|
|
66
|
-
return
|
|
67
|
-
if (
|
|
68
|
-
const
|
|
194
|
+
if (token === "*")
|
|
195
|
+
return fullRange();
|
|
196
|
+
if (token.includes("-")) {
|
|
197
|
+
const dashParts = token.split("-");
|
|
198
|
+
if (dashParts.length !== 2)
|
|
199
|
+
return null;
|
|
200
|
+
const a = parseInt(dashParts[0], 10);
|
|
201
|
+
const b = parseInt(dashParts[1], 10);
|
|
202
|
+
if (!Number.isFinite(a) || !Number.isFinite(b) || a > b || a < min || b > max)
|
|
203
|
+
return null;
|
|
69
204
|
return Array.from({ length: b - a + 1 }, (_, i) => i + a);
|
|
70
205
|
}
|
|
71
|
-
|
|
206
|
+
const v = parseInt(token, 10);
|
|
207
|
+
if (!Number.isFinite(v) || v < min || v > max)
|
|
208
|
+
return null;
|
|
209
|
+
return [v];
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Parse a cron field expression (may contain commas) into a sorted array of valid integers.
|
|
213
|
+
* Returns null if any token is invalid/garbage (the expression is rejected).
|
|
214
|
+
*/
|
|
215
|
+
function parseField(expr, min, max) {
|
|
216
|
+
const tokens = expr.split(",").filter((t) => t.length > 0);
|
|
217
|
+
if (tokens.length === 0)
|
|
218
|
+
return null;
|
|
219
|
+
const result = new Set();
|
|
220
|
+
for (const token of tokens) {
|
|
221
|
+
const vals = parseFieldToken(token, min, max);
|
|
222
|
+
if (vals === null)
|
|
223
|
+
return null;
|
|
224
|
+
for (const v of vals)
|
|
225
|
+
result.add(v);
|
|
226
|
+
}
|
|
227
|
+
const arr = [...result].sort((a, b) => a - b);
|
|
228
|
+
return arr.length > 0 ? arr : null;
|
|
72
229
|
}
|
|
73
230
|
const minutes = parseField(minExpr, 0, 59);
|
|
74
231
|
const hours = parseField(hourExpr, 0, 23);
|
|
75
232
|
const days = parseField(dayExpr, 1, 31);
|
|
76
233
|
const months = parseField(monthExpr, 1, 12);
|
|
77
234
|
const weekdays = parseField(weekdayExpr, 0, 6); // 0=Sun
|
|
235
|
+
// Any field returning null means the expression is invalid — reject it
|
|
236
|
+
if (!minutes || !hours || !days || !months || !weekdays)
|
|
237
|
+
return null;
|
|
78
238
|
// Search forward up to 366 days
|
|
79
239
|
const candidate = new Date(after);
|
|
80
240
|
candidate.setSeconds(0, 0);
|
|
@@ -112,6 +272,10 @@ let notifyCallback = null;
|
|
|
112
272
|
export function setNotifyCallback(fn) {
|
|
113
273
|
notifyCallback = fn;
|
|
114
274
|
}
|
|
275
|
+
/** @internal exported for unit-test use only */
|
|
276
|
+
export async function runJob(job) {
|
|
277
|
+
return executeJob(job);
|
|
278
|
+
}
|
|
115
279
|
async function executeJob(job) {
|
|
116
280
|
try {
|
|
117
281
|
switch (job.type) {
|
|
@@ -162,11 +326,36 @@ async function executeJob(job) {
|
|
|
162
326
|
const url = job.payload.url || "";
|
|
163
327
|
const method = job.payload.method || "GET";
|
|
164
328
|
const headers = job.payload.headers || {};
|
|
165
|
-
|
|
329
|
+
// M1: SSRF guard — reject private/internal destinations before fetching.
|
|
330
|
+
// Runs even when EXEC_SECURITY=deny (it's a separate, independent control).
|
|
331
|
+
// We validate EVERY redirect hop manually (redirect:"manual") so a
|
|
332
|
+
// public host cannot 302 us into an internal address (post-redirect SSRF).
|
|
333
|
+
const { assertSsrfSafe, SsrfBlockedError: SsrfBlockedErrorCron } = await import("./ssrf-guard.js");
|
|
334
|
+
await assertSsrfSafe(url);
|
|
335
|
+
const baseOpts = { method, headers };
|
|
166
336
|
if (job.payload.body && method !== "GET") {
|
|
167
|
-
|
|
337
|
+
baseOpts.body = job.payload.body;
|
|
338
|
+
}
|
|
339
|
+
const MAX_CRON_REDIRECTS = 10;
|
|
340
|
+
let currentUrl = url;
|
|
341
|
+
let res;
|
|
342
|
+
for (let hop = 0;; hop++) {
|
|
343
|
+
res = await fetch(currentUrl, { ...baseOpts, redirect: "manual" });
|
|
344
|
+
// Not a redirect — we have the final response
|
|
345
|
+
if (res.status < 300 || res.status >= 400)
|
|
346
|
+
break;
|
|
347
|
+
const loc = res.headers.get("location");
|
|
348
|
+
if (!loc)
|
|
349
|
+
break; // no Location header — treat as final response
|
|
350
|
+
if (hop >= MAX_CRON_REDIRECTS) {
|
|
351
|
+
throw new SsrfBlockedErrorCron(url, `too many redirects (> ${MAX_CRON_REDIRECTS})`);
|
|
352
|
+
}
|
|
353
|
+
const next = new URL(loc, currentUrl).href;
|
|
354
|
+
// Re-validate each redirect target before following — closes the
|
|
355
|
+
// post-redirect SSRF bypass.
|
|
356
|
+
await assertSsrfSafe(next);
|
|
357
|
+
currentUrl = next;
|
|
168
358
|
}
|
|
169
|
-
const res = await fetch(url, fetchOpts);
|
|
170
359
|
const text = await res.text();
|
|
171
360
|
const output = `HTTP ${res.status}: ${text.slice(0, 2000)}`;
|
|
172
361
|
if (notifyCallback) {
|
|
@@ -10,6 +10,9 @@ import crypto from "crypto";
|
|
|
10
10
|
import { DELIVERY_QUEUE_FILE } from "../paths.js";
|
|
11
11
|
// ── State ───────────────────────────────────────────────
|
|
12
12
|
let senders = {};
|
|
13
|
+
/** Re-entrancy guard: prevents overlapping processQueue() invocations.
|
|
14
|
+
* Mirrors the `runningJobs` Set pattern used by cron.ts. */
|
|
15
|
+
let inFlight = false;
|
|
13
16
|
// ── File I/O ────────────────────────────────────────────
|
|
14
17
|
function readQueue() {
|
|
15
18
|
try {
|
|
@@ -67,8 +70,24 @@ export function enqueue(channel, chatId, content, options) {
|
|
|
67
70
|
* Process all pending entries in the queue.
|
|
68
71
|
* Respects exponential backoff and max attempts.
|
|
69
72
|
* Returns counts of delivered, failed, and still-pending entries.
|
|
73
|
+
*
|
|
74
|
+
* Re-entrancy guard: if a prior processQueue() call is still in-flight
|
|
75
|
+
* (e.g. a slow sender blocks beyond the 30s tick), the second invocation
|
|
76
|
+
* returns immediately with zero counts. Mirrors the runningJobs Set
|
|
77
|
+
* pattern used by cron.ts to guard overlapping job executions.
|
|
70
78
|
*/
|
|
71
79
|
export async function processQueue() {
|
|
80
|
+
if (inFlight)
|
|
81
|
+
return { delivered: 0, failed: 0, pending: 0 };
|
|
82
|
+
inFlight = true;
|
|
83
|
+
try {
|
|
84
|
+
return await _processQueueInner();
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
inFlight = false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function _processQueueInner() {
|
|
72
91
|
const queue = readQueue();
|
|
73
92
|
const now = Date.now();
|
|
74
93
|
let delivered = 0;
|
|
@@ -40,11 +40,8 @@ function loadSqlite() {
|
|
|
40
40
|
}
|
|
41
41
|
catch (err) {
|
|
42
42
|
sqliteLoadError = err instanceof Error ? err : new Error(String(err));
|
|
43
|
-
console.
|
|
44
|
-
"
|
|
45
|
-
"`cd $(npm root -g)/alvin-bot && npm rebuild better-sqlite3` or reinstall " +
|
|
46
|
-
"alvin-bot. Underlying error: " +
|
|
47
|
-
sqliteLoadError.message);
|
|
43
|
+
console.log("ℹ️ Semantic memory (better-sqlite3) unavailable — using keyword search (FTS5). " +
|
|
44
|
+
"Reinstall or run `npm rebuild better-sqlite3` to enable.");
|
|
48
45
|
return null;
|
|
49
46
|
}
|
|
50
47
|
}
|
|
@@ -26,6 +26,10 @@ export function readEnv() {
|
|
|
26
26
|
}
|
|
27
27
|
/** Upsert a key=value pair in the env file, preserving all other lines. */
|
|
28
28
|
export function writeEnvVar(key, value) {
|
|
29
|
+
// M6 (centralized): reject values containing newline characters — they allow
|
|
30
|
+
// injecting extra .env lines (e.g. value="good\nEVIL=injected").
|
|
31
|
+
if (/[\n\r]/.test(value))
|
|
32
|
+
throw new Error("env value must not contain newline characters");
|
|
29
33
|
let content = fs.existsSync(ENV_FILE) ? fs.readFileSync(ENV_FILE, "utf-8") : "";
|
|
30
34
|
const regex = new RegExp(`^${key}=.*$`, "m");
|
|
31
35
|
if (regex.test(content)) {
|
|
@@ -35,7 +35,7 @@ try {
|
|
|
35
35
|
soulContent = readFileSync(SOUL_FILE, "utf-8");
|
|
36
36
|
}
|
|
37
37
|
catch {
|
|
38
|
-
console.
|
|
38
|
+
console.log("ℹ️ soul.md not found — using built-in default personality. Create ~/.alvin-bot/soul.md to customize.");
|
|
39
39
|
}
|
|
40
40
|
loadStandingOrders();
|
|
41
41
|
/** Base system prompt — adapts to user language */
|
|
@@ -49,9 +49,14 @@ const SDK_ADDON = `When you run commands or edit files, briefly explain what you
|
|
|
49
49
|
/**
|
|
50
50
|
* Stage 1 of Fix #17 — async sub-agents.
|
|
51
51
|
*
|
|
52
|
-
* Tells Claude
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* Tells Claude that `mcp__alvin__dispatch_agent` is the ONLY sanctioned
|
|
53
|
+
* path for long-running work on chat platforms. The built-in Task/Agent
|
|
54
|
+
* tool is explicitly disallowed for anything that takes more than ~30 s
|
|
55
|
+
* because it blocks the session: isProcessing stays true, the typing
|
|
56
|
+
* indicator keeps firing, and the user cannot send a new message until
|
|
57
|
+
* the sub-agent finishes. Path A (mcp__alvin__dispatch_agent) spawns a
|
|
58
|
+
* truly detached subprocess and returns in milliseconds so the session
|
|
59
|
+
* is freed immediately.
|
|
55
60
|
*
|
|
56
61
|
* Only injected into the prompt when isSDK === true (non-SDK providers
|
|
57
62
|
* have no Agent tool). The bot's async-agent-watcher (Stage 2) picks up
|
|
@@ -63,34 +68,29 @@ const SDK_ADDON = `When you run commands or edit files, briefly explain what you
|
|
|
63
68
|
*/
|
|
64
69
|
const BACKGROUND_SUBAGENT_HINT = `## ⚠️ CRITICAL: Background Sub-Agents on Telegram/WhatsApp/Slack
|
|
65
70
|
|
|
66
|
-
**THE RULE
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
**THE RULE**: For ANY long-running work (research, audits, multi-step
|
|
72
|
+
analysis, >30 seconds), you MUST use \`mcp__alvin__dispatch_agent\`.
|
|
73
|
+
The built-in \`Task\`/\`Agent\` tool is BANNED for long work on chat
|
|
74
|
+
platforms (Telegram/WhatsApp/Slack/Discord). Do not use it for anything
|
|
75
|
+
that takes more than ~30 seconds.
|
|
69
76
|
|
|
70
|
-
**Why
|
|
77
|
+
**Why mcp__alvin__dispatch_agent is the ONLY background path:**
|
|
71
78
|
|
|
72
|
-
The built-in Task tool
|
|
73
|
-
the sub-agent tied to THIS session's CLI subprocess.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
The built-in Task/Agent tool — even with \`run_in_background: true\` —
|
|
80
|
+
keeps the sub-agent tied to THIS session's CLI subprocess. The session
|
|
81
|
+
stays blocked: \`isProcessing\` remains true, the typing indicator
|
|
82
|
+
keeps firing, and the user's next message is queued until the
|
|
83
|
+
synchronous Task finishes. There is no way to free the session while a
|
|
84
|
+
built-in Task is running — that is a hard platform constraint.
|
|
77
85
|
|
|
78
86
|
\`mcp__alvin__dispatch_agent\` spawns a COMPLETELY INDEPENDENT
|
|
79
87
|
\`claude -p\` subprocess with its own PID, own process group. It
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
88
|
+
returns in milliseconds, the session is freed immediately, and the user
|
|
89
|
+
can keep chatting. The bot delivers the result as a separate message
|
|
90
|
+
when ready.
|
|
83
91
|
|
|
84
92
|
**THIS IS THE SINGLE MOST IMPORTANT RULE FOR USER RESPONSIVENESS.**
|
|
85
93
|
|
|
86
|
-
**Why it matters**: During a synchronous Agent tool call the parent
|
|
87
|
-
session has no way to know the sub-agent is still working. It appears
|
|
88
|
-
hung. After a long silence the session can be aborted, destroying the
|
|
89
|
-
work. Using \`run_in_background: true\` solves this: the tool returns
|
|
90
|
-
an \`agentId\` + \`outputFile\` path IMMEDIATELY, your turn ends in
|
|
91
|
-
seconds, the user can keep chatting with me, and the bot automatically
|
|
92
|
-
delivers the sub-agent's final result as a separate message when ready.
|
|
93
|
-
|
|
94
94
|
**Decision tree** (apply every time you consider any sub-agent tool):
|
|
95
95
|
|
|
96
96
|
Does the task involve ANY of the following?
|
|
@@ -103,16 +103,11 @@ delivers the sub-agent's final result as a separate message when ready.
|
|
|
103
103
|
• Crawling, scraping, or fetching multiple resources
|
|
104
104
|
• Research across multiple sources or domains
|
|
105
105
|
|
|
106
|
-
YES → use \`mcp__alvin__dispatch_agent\` (
|
|
106
|
+
YES → use \`mcp__alvin__dispatch_agent\` (the ONLY sanctioned path)
|
|
107
107
|
NO → foreground is fine (single quick sub-query under 30s, answer
|
|
108
108
|
yourself if possible)
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
but is now deprecated on Telegram/Slack/Discord/WhatsApp because it
|
|
112
|
-
ties sub-agent lifetime to this session. Only use Task directly when
|
|
113
|
-
you explicitly need the sub-agent's result IN THIS SAME TURN (rare).
|
|
114
|
-
|
|
115
|
-
**Examples where you MUST use \`run_in_background: true\`:**
|
|
110
|
+
**Examples where you MUST use \`mcp__alvin__dispatch_agent\`:**
|
|
116
111
|
- ANY audit (SEO, security, code quality, performance, accessibility, GEO)
|
|
117
112
|
- Research visiting more than 1-2 web pages
|
|
118
113
|
- Code reviews on more than a single file
|
|
@@ -122,12 +117,12 @@ you explicitly need the sub-agent's result IN THIS SAME TURN (rare).
|
|
|
122
117
|
- Long data-processing jobs
|
|
123
118
|
- Anything involving the word "analyze", "audit", "review", "scan", "research"
|
|
124
119
|
|
|
125
|
-
**Examples where foreground is fine:**
|
|
120
|
+
**Examples where foreground is fine (no sub-agent needed):**
|
|
126
121
|
- "Read this file and summarize it" (single file, <10s)
|
|
127
122
|
- "What's 2+2?" (no sub-agent needed — answer yourself)
|
|
128
123
|
- "Check if package.json has foo" (one quick tool call)
|
|
129
124
|
|
|
130
|
-
**After
|
|
125
|
+
**After calling \`mcp__alvin__dispatch_agent\` you MUST:**
|
|
131
126
|
1. Tell the user in ONE short sentence what you kicked off.
|
|
132
127
|
Example: "Starting SEO audit for example.com in the background —
|
|
133
128
|
I'll send the report when it's done."
|
|
@@ -135,6 +130,14 @@ you explicitly need the sub-agent's result IN THIS SAME TURN (rare).
|
|
|
135
130
|
3. The bot will deliver the result as a separate message when ready.
|
|
136
131
|
You don't need to poll the outputFile proactively.
|
|
137
132
|
|
|
133
|
+
Only say "running in the background — you can keep chatting" if you
|
|
134
|
+
actually called \`mcp__alvin__dispatch_agent\` in this turn. If the
|
|
135
|
+
task ran inline (foreground tool calls, no dispatch), it blocked the
|
|
136
|
+
session and the user could NOT chat during it — do NOT claim otherwise.
|
|
137
|
+
If a task was too long for foreground but you didn't dispatch it, tell
|
|
138
|
+
the user truthfully: "That ran inline and took a while. Your messages
|
|
139
|
+
were queued until it finished."
|
|
140
|
+
|
|
138
141
|
**For PARALLEL dispatch** (e.g. user says "research X and Y in parallel"):
|
|
139
142
|
Call \`mcp__alvin__dispatch_agent\` multiple times in the SAME assistant
|
|
140
143
|
turn, once per sub-task. Each returns its own agentId immediately. Your
|
|
@@ -145,10 +148,10 @@ If the user asks "is it done yet?" before the bot delivers the result,
|
|
|
145
148
|
you MAY read the agent's \`outputFile\` (from the original tool result)
|
|
146
149
|
using the Read tool to peek at progress — but don't block on it.
|
|
147
150
|
|
|
148
|
-
**Never** call the Agent/Task tool
|
|
149
|
-
|
|
150
|
-
cost of
|
|
151
|
-
|
|
151
|
+
**Never** call the built-in Agent/Task tool for anything you're not
|
|
152
|
+
100% sure completes in under 30 seconds. The cost of dispatch is zero.
|
|
153
|
+
The cost of blocking the chat user for 20 minutes on a synchronous
|
|
154
|
+
inline Task is very high.`;
|
|
152
155
|
/**
|
|
153
156
|
* Self-Awareness Core — Dynamic introspection block.
|
|
154
157
|
*
|
|
@@ -142,6 +142,12 @@ export function loadPersistedSessions() {
|
|
|
142
142
|
if (!raw_parsed || typeof raw_parsed !== "object")
|
|
143
143
|
return 0;
|
|
144
144
|
// v4.12.0 — Detect envelope format vs legacy v4.11.0 flat format
|
|
145
|
+
// M4: Validate the top-level shape before trusting any field.
|
|
146
|
+
if (Array.isArray(raw_parsed)) {
|
|
147
|
+
// An array at root is not a valid sessions file
|
|
148
|
+
console.warn("⚠️ session-persistence: sessions file contains an array at root, starting fresh");
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
145
151
|
let parsed;
|
|
146
152
|
let tgWorkspaces = {};
|
|
147
153
|
if (raw_parsed &&
|
|
@@ -149,11 +155,22 @@ export function loadPersistedSessions() {
|
|
|
149
155
|
"version" in raw_parsed &&
|
|
150
156
|
"sessions" in raw_parsed) {
|
|
151
157
|
const env = raw_parsed;
|
|
152
|
-
|
|
153
|
-
|
|
158
|
+
// M4: sessions field must be a non-null object; degrade gracefully if tampered
|
|
159
|
+
if (!env.sessions || typeof env.sessions !== "object" || Array.isArray(env.sessions)) {
|
|
160
|
+
console.warn("⚠️ session-persistence: 'sessions' field is not an object, starting fresh");
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
parsed = env.sessions;
|
|
164
|
+
tgWorkspaces = (env.telegramWorkspaces && typeof env.telegramWorkspaces === "object" && !Array.isArray(env.telegramWorkspaces))
|
|
165
|
+
? env.telegramWorkspaces
|
|
166
|
+
: {};
|
|
154
167
|
}
|
|
155
168
|
else {
|
|
156
|
-
// Legacy flat format (v4.11.0)
|
|
169
|
+
// Legacy flat format (v4.11.0) — must also be an object
|
|
170
|
+
if (!raw_parsed || typeof raw_parsed !== "object") {
|
|
171
|
+
console.warn("⚠️ session-persistence: sessions file is not a valid object, starting fresh");
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
157
174
|
parsed = raw_parsed;
|
|
158
175
|
}
|
|
159
176
|
// Rehydrate Telegram workspace map
|
|
@@ -184,6 +201,7 @@ export function loadPersistedSessions() {
|
|
|
184
201
|
_qHandle: null,
|
|
185
202
|
_steerChannel: null,
|
|
186
203
|
_steerAckSentThisTurn: false,
|
|
204
|
+
_turnId: null,
|
|
187
205
|
lastActivity: persisted.lastActivity ?? Date.now(),
|
|
188
206
|
startedAt: persisted.startedAt ?? Date.now(),
|
|
189
207
|
totalCost: persisted.totalCost ?? 0,
|
package/dist/services/session.js
CHANGED
|
@@ -84,6 +84,7 @@ export function getSession(key) {
|
|
|
84
84
|
_qHandle: null,
|
|
85
85
|
_steerChannel: null,
|
|
86
86
|
_steerAckSentThisTurn: false,
|
|
87
|
+
_turnId: null,
|
|
87
88
|
lastActivity: Date.now(),
|
|
88
89
|
startedAt: Date.now(),
|
|
89
90
|
totalCost: 0,
|
|
@@ -120,6 +121,8 @@ export function getSession(key) {
|
|
|
120
121
|
session._steerChannel = null;
|
|
121
122
|
if (session._steerAckSentThisTurn === undefined)
|
|
122
123
|
session._steerAckSentThisTurn = false;
|
|
124
|
+
if (session._turnId === undefined)
|
|
125
|
+
session._turnId = null;
|
|
123
126
|
}
|
|
124
127
|
return session;
|
|
125
128
|
}
|