macroclaw 0.25.0 → 0.27.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.
@@ -3,13 +3,12 @@ import {
3
3
  Claude,
4
4
  QueryParseError,
5
5
  QueryProcessError,
6
- type QueryResult,
7
6
  QueryValidationError,
8
7
  type RunningQuery
9
8
  } from "./claude";
10
9
  import { writeHistoryPrompt, writeHistoryResult } from "./history";
11
10
  import { createLogger } from "./logger";
12
- import { CRON_TIMEOUT, MAIN_TIMEOUT, SYSTEM_PROMPT } from "./prompts";
11
+ import { SYSTEM_PROMPT } from "./prompts";
13
12
  import { Queue } from "./queue";
14
13
  import { loadSessions, saveSessions } from "./sessions";
15
14
 
@@ -17,6 +16,10 @@ type ButtonSpec = string | { text: string; data: string };
17
16
 
18
17
  const log = createLogger("orchestrator");
19
18
 
19
+ // --- Constants ---
20
+
21
+ const WAIT_THRESHOLD = 60_000;
22
+
20
23
  // --- Response schema ---
21
24
 
22
25
  const backgroundAgentSchema = z.object({
@@ -56,20 +59,21 @@ export interface OrchestratorResponse {
56
59
  type OrchestratorRequest =
57
60
  | { type: "user"; message: string; files?: string[] }
58
61
  | { type: "cron"; name: string; prompt: string; model?: string }
59
- | { type: "background-agent-result"; name: string; response: AgentOutput; sessionId?: string }
62
+ | { type: "background-agent-result"; name: string; response: AgentOutput }
60
63
  | { type: "button"; label: string };
61
64
 
62
65
  function escapeHtml(text: string): string {
63
66
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
64
67
  }
65
68
 
66
- // --- Background tracking ---
69
+ // --- Session tracking ---
67
70
 
68
- interface BackgroundInfo {
71
+ interface SessionInfo {
69
72
  name: string;
70
73
  prompt: string;
71
74
  model?: string;
72
75
  query: RunningQuery<AgentOutput>;
76
+ lastMessageAt: Date;
73
77
  }
74
78
 
75
79
  export interface OrchestratorConfig {
@@ -78,19 +82,23 @@ export interface OrchestratorConfig {
78
82
  settingsDir?: string;
79
83
  onResponse: (response: OrchestratorResponse) => Promise<void>;
80
84
  claude?: Claude;
85
+ /** How long to wait for a running main session before demoting it (ms). Default: 60000 */
86
+ waitThreshold?: number;
81
87
  }
82
88
 
83
89
  export class Orchestrator {
84
90
  #config: Omit<OrchestratorConfig , 'claude'>;
85
91
  #claude: Claude;
92
+ #waitThreshold: number;
86
93
 
87
94
  #mainSessionId: string | undefined;
88
- #backgroundAgents = new Map<string, BackgroundInfo>();
95
+ #runningSessions = new Map<string, SessionInfo>();
89
96
  #queue: Queue<OrchestratorRequest>;
90
97
 
91
98
  constructor(config: OrchestratorConfig) {
92
99
  this.#config = config;
93
100
  this.#claude = config.claude ?? new Claude({ workspace: config.workspace, systemPrompt: SYSTEM_PROMPT });
101
+ this.#waitThreshold = config.waitThreshold ?? WAIT_THRESHOLD;
94
102
  this.#queue = new Queue<OrchestratorRequest>();
95
103
  this.#queue.setHandler((request) => this.#handleRequest(request));
96
104
 
@@ -108,7 +116,9 @@ export class Orchestrator {
108
116
  }
109
117
 
110
118
  handleCron(name: string, prompt: string, model?: string): void {
111
- this.#queue.push({ type: "cron", name, prompt, model });
119
+ const cronName = `cron-${name}`;
120
+ const cronPrompt = `[Context: cron/${name}] ${prompt}`;
121
+ this.#spawnBackground(cronName, cronPrompt, model ?? this.#config.model);
112
122
  }
113
123
 
114
124
  handleBackgroundCommand(prompt: string): void {
@@ -117,38 +127,42 @@ export class Orchestrator {
117
127
  this.#callOnResponse({ message: `Background agent "${escapeHtml(name)}" started.` });
118
128
  }
119
129
 
120
- handleBackgroundList(): void {
121
- const agents = [...this.#backgroundAgents.values()];
122
- if (agents.length === 0) {
123
- this.#callOnResponse({ message: "No background agents running." });
130
+ handleSessions(): void {
131
+ const sessions = [...this.#runningSessions.entries()];
132
+ if (sessions.length === 0) {
133
+ this.#callOnResponse({ message: "No running sessions." });
124
134
  return;
125
135
  }
126
- const lines = agents.map((a) => {
127
- const elapsed = Math.round((Date.now() - a.query.startedAt.getTime()) / 1000);
128
- return `- ${escapeHtml(a.name)} (${elapsed}s)`;
136
+ const lines = sessions.map(([sid, s]) => {
137
+ const elapsed = Math.round((Date.now() - s.query.startedAt.getTime()) / 1000);
138
+ const isMain = sid === this.#mainSessionId;
139
+ return isMain
140
+ ? `▶ ${escapeHtml(s.name)} (${elapsed}s) [main]`
141
+ : `- ${escapeHtml(s.name)} (${elapsed}s)`;
129
142
  });
130
- const buttons: ButtonSpec[] = agents.map((a) => {
131
- const elapsed = Math.round((Date.now() - a.query.startedAt.getTime()) / 1000);
132
- const text = `${a.name} (${elapsed}s)`.slice(0, 27);
133
- return { text, data: `detail:${a.query.sessionId}` };
143
+ const buttons: ButtonSpec[] = sessions.map(([sid, s]) => {
144
+ const elapsed = Math.round((Date.now() - s.query.startedAt.getTime()) / 1000);
145
+ const text = `${s.name} (${elapsed}s)`.slice(0, 27);
146
+ return { text, data: `detail:${sid}` };
134
147
  });
135
- buttons.push({text: "Dismiss", data: "_dismiss"});
148
+ buttons.push({ text: "Dismiss", data: "_dismiss" });
136
149
  this.#callOnResponse({ message: lines.join("\n"), buttons });
137
150
  }
138
151
 
139
152
  handleDetail(sessionId: string): void {
140
- const agent = this.#backgroundAgents.get(sessionId);
141
- if (!agent) {
142
- this.#callOnResponse({ message: "Agent not found or already finished." });
153
+ const session = this.#runningSessions.get(sessionId);
154
+ if (!session) {
155
+ this.#callOnResponse({ message: "Session not found or already finished." });
143
156
  return;
144
157
  }
145
158
 
146
- const elapsed = Math.round((Date.now() - agent.query.startedAt.getTime()) / 1000);
147
- const truncatedPrompt = agent.prompt.length > 300 ? `${agent.prompt.slice(0, 300)}…` : agent.prompt;
159
+ const elapsed = Math.round((Date.now() - session.query.startedAt.getTime()) / 1000);
160
+ const truncatedPrompt = session.prompt.length > 300 ? `${session.prompt.slice(0, 300)}…` : session.prompt;
161
+ const isMain = sessionId === this.#mainSessionId;
148
162
  const lines = [
149
- `<b>${escapeHtml(agent.name)}</b>`,
163
+ `<b>${escapeHtml(session.name)}</b>${isMain ? " [main]" : ""}`,
150
164
  `Prompt: ${escapeHtml(truncatedPrompt)}`,
151
- `Model: ${agent.model ?? "default"}`,
165
+ `Model: ${session.model ?? "default"}`,
152
166
  `Elapsed: ${elapsed}s`,
153
167
  "Status: running",
154
168
  ];
@@ -161,16 +175,16 @@ export class Orchestrator {
161
175
  }
162
176
 
163
177
  async handlePeek(sessionId: string): Promise<void> {
164
- const agent = this.#backgroundAgents.get(sessionId);
165
- if (!agent) {
166
- this.#callOnResponse({ message: "Agent not found or already finished." });
178
+ const session = this.#runningSessions.get(sessionId);
179
+ if (!session) {
180
+ this.#callOnResponse({ message: "Session not found or already finished." });
167
181
  return;
168
182
  }
169
183
 
170
- this.#callOnResponse({ message: `Peeking at <b>${escapeHtml(agent.name)}</b>...` });
184
+ this.#callOnResponse({ message: `Peeking at <b>${escapeHtml(session.name)}</b>...` });
171
185
 
172
186
  try {
173
- const startedAt = agent.query.startedAt.toISOString();
187
+ const startedAt = session.query.startedAt.toISOString();
174
188
  const query = this.#claude.forkSession(
175
189
  sessionId,
176
190
  `This session started at ${startedAt}. Only consider events after that time. Give a brief status update: what has been done so far, what's currently happening, and what's remaining. 2-3 sentences max.`,
@@ -178,65 +192,76 @@ export class Orchestrator {
178
192
  { model: "haiku" },
179
193
  );
180
194
  const { value } = await query.result;
181
- this.#callOnResponse({ message: `<b>[${escapeHtml(agent.name)}]</b> ${value || "[No output]"}` });
195
+ this.#callOnResponse({ message: `<b>[${escapeHtml(session.name)}]</b> ${value || "[No output]"}` });
182
196
  } catch (err) {
183
- this.#callOnResponse({ message: `Couldn't peek at ${escapeHtml(agent.name)}: ${err}` });
197
+ this.#callOnResponse({ message: `Couldn't peek at ${escapeHtml(session.name)}: ${err}` });
184
198
  }
185
199
  }
186
200
 
187
201
  async handleKill(sessionId: string): Promise<void> {
188
- const agent = this.#backgroundAgents.get(sessionId);
189
- if (!agent) {
190
- this.#callOnResponse({ message: "Agent not found or already finished." });
202
+ const session = this.#runningSessions.get(sessionId);
203
+ if (!session) {
204
+ this.#callOnResponse({ message: "Session not found or already finished." });
191
205
  return;
192
206
  }
193
207
 
194
- this.#backgroundAgents.delete(sessionId);
208
+ this.#runningSessions.delete(sessionId);
195
209
 
196
210
  try {
197
- await agent.query.kill();
211
+ await session.query.kill();
198
212
  } catch (err) {
199
- log.error({ err, name: agent.name }, "Kill failed");
213
+ log.error({ err, name: session.name }, "Kill failed");
200
214
  }
201
215
 
202
- this.#callOnResponse({ message: `Killed <b>${escapeHtml(agent.name)}</b>.` });
216
+ this.#callOnResponse({ message: `Killed <b>${escapeHtml(session.name)}</b>.` });
203
217
  }
204
218
 
205
- handleSessionCommand(): void {
206
- this.#callOnResponse({ message: `Session: <code>${this.#mainSessionId ?? "none"}</code>` });
207
- }
208
219
 
209
220
  // --- Internal queue handler ---
210
221
 
211
222
  async #handleRequest(request: OrchestratorRequest): Promise<void> {
212
223
  log.debug({ type: request.type }, "Incoming request");
213
224
 
214
- // Background result with matching session ID: deliver directly
215
- if (request.type === "background-agent-result" && request.sessionId === this.#mainSessionId) {
216
- log.debug({ name: request.name }, "Background result on current session, applying directly");
217
- await this.#deliverResponse(request.response);
218
- return;
225
+ const mainInfo = this.#mainSessionId ? this.#runningSessions.get(this.#mainSessionId) : undefined;
226
+ let movedToBackground: string | undefined;
227
+
228
+ if (mainInfo) {
229
+ const elapsed = Date.now() - mainInfo.lastMessageAt.getTime();
230
+ if (elapsed >= this.#waitThreshold) {
231
+ // Main has been running too long — move to background immediately
232
+ log.info({ name: mainInfo.name, sessionId: mainInfo.query.sessionId }, "Moving main session to background (exceeded threshold)");
233
+ movedToBackground = mainInfo.prompt;
234
+ } else {
235
+ // Main started recently — wait for it to finish or threshold
236
+ const remaining = this.#waitThreshold - elapsed;
237
+ const finished = await Promise.race([
238
+ mainInfo.query.result.then(() => true as const, () => true as const),
239
+ new Promise<false>((r) => setTimeout(() => r(false), remaining)),
240
+ ]);
241
+
242
+ if (!finished) {
243
+ log.info({ name: mainInfo.name, sessionId: mainInfo.query.sessionId }, "Moving main session to background (wait timed out)");
244
+ movedToBackground = mainInfo.prompt;
245
+ }
246
+ // If finished: completion handler already delivered the result and removed from map.
247
+ }
219
248
  }
220
249
 
221
250
  await writeHistoryPrompt(request);
222
251
 
223
- const result = await this.#queryWithRetry(request);
224
- if (!result) return;
225
-
226
- // Update session ID (important after fork or new session)
227
- if (result.sessionId !== this.#mainSessionId) {
228
- log.info({ oldSessionId: this.#mainSessionId, newSessionId: result.sessionId }, "Session updated");
229
- this.#mainSessionId = result.sessionId;
230
- saveSessions({ mainSessionId: this.#mainSessionId }, this.#config.settingsDir);
252
+ let prompt = this.#formatPrompt(request);
253
+ if (movedToBackground) {
254
+ const truncated = movedToBackground.length > 100 ? `${movedToBackground.slice(0, 100)}...` : movedToBackground;
255
+ prompt = `[Context: previous task "${truncated}" moved to background]\n${prompt}`;
231
256
  }
232
257
 
233
- await writeHistoryResult(result.value);
234
- await this.#deliverResponse(result.value);
258
+ this.#startMainQuery(prompt, this.#config.model);
235
259
  }
236
260
 
237
261
  // --- Response delivery ---
238
262
 
239
263
  async #deliverResponse(response: AgentOutput): Promise<void> {
264
+ await writeHistoryResult(response);
240
265
  if (response.action === "send") {
241
266
  this.#callOnResponse({
242
267
  message: response.message || "[No output]",
@@ -262,53 +287,59 @@ export class Orchestrator {
262
287
  });
263
288
  }
264
289
 
265
- // --- Claude calls ---
266
-
267
- async #queryWithRetry(request: OrchestratorRequest): Promise<QueryResult<AgentOutput> | null> {
268
- const timeout = request.type === "cron" ? CRON_TIMEOUT : MAIN_TIMEOUT;
269
- const query = this.#query(request);
290
+ // --- Main session query ---
270
291
 
271
- try {
272
- const result = await this.#awaitOrBackground(query, request, timeout);
273
- if (!result) return null;
274
- return result;
275
- } catch (err) {
276
- // Resume failed — retry with a fresh session
277
- if (err instanceof QueryProcessError && this.#mainSessionId) {
278
- log.info("Resume failed, retrying with new session");
279
- this.#mainSessionId = undefined;
280
- const retryQuery = this.#query(request);
281
-
282
- try {
283
- const retryResult = await this.#awaitOrBackground(retryQuery, request, timeout);
284
- if (!retryResult) return null;
285
- return retryResult;
286
- } catch (retryErr) {
287
- return { value: this.#errorResponse(retryErr), sessionId: retryQuery.sessionId };
288
- }
289
- }
292
+ #startMainQuery(prompt: string, model: string | undefined): void {
293
+ const opts = { model };
294
+ let query: RunningQuery<AgentOutput>;
290
295
 
291
- return { value: this.#errorResponse(err), sessionId: this.#mainSessionId ?? "" };
296
+ if (this.#mainSessionId && this.#runningSessions.has(this.#mainSessionId)) {
297
+ query = this.#claude.forkSession(this.#mainSessionId, prompt, responseResultType, opts);
298
+ } else if (this.#mainSessionId) {
299
+ query = this.#claude.resumeSession(this.#mainSessionId, prompt, responseResultType, opts);
300
+ } else {
301
+ query = this.#claude.newSession(prompt, responseResultType, opts);
292
302
  }
293
- }
294
303
 
295
- #query(request: OrchestratorRequest) {
296
- const prompt = this.#formatPrompt(request);
297
- const model = request.type === "cron" ? (request.model ?? this.#config.model) : this.#config.model;
298
- const opts = { model };
304
+ const sid = query.sessionId;
305
+ const name = prompt.slice(0, 30).replace(/\s+/g, "-");
306
+ this.#runningSessions.set(sid, { name, prompt, model, query, lastMessageAt: new Date() });
299
307
 
300
- // Fork if a background agent is running on the main session
301
- if (this.#mainSessionId && this.#backgroundAgents.has(this.#mainSessionId)) {
302
- return this.#claude.forkSession(this.#mainSessionId, prompt, responseResultType, opts);
308
+ if (sid !== this.#mainSessionId) {
309
+ log.info({ oldSessionId: this.#mainSessionId, newSessionId: sid }, "Session updated");
310
+ this.#mainSessionId = sid;
311
+ saveSessions({ mainSessionId: sid }, this.#config.settingsDir);
303
312
  }
304
313
 
305
- // Resume existing session
306
- if (this.#mainSessionId) {
307
- return this.#claude.resumeSession(this.#mainSessionId, prompt, responseResultType, opts);
308
- }
314
+ log.debug({ name, sessionId: sid }, "Main query started");
309
315
 
310
- // Start fresh
311
- return this.#claude.newSession(prompt, responseResultType, opts);
316
+ query.result.then(
317
+ async ({ value: response }) => {
318
+ if (!this.#runningSessions.has(sid)) {
319
+ log.error({ name, sessionId: sid }, "Completed session not in runningSessions — delivering anyway");
320
+ await this.#deliverResponse(response);
321
+ return;
322
+ }
323
+ this.#runningSessions.delete(sid);
324
+
325
+ if (sid === this.#mainSessionId) {
326
+ log.debug({ name, sessionId: sid }, "Main query finished, delivering directly");
327
+ await this.#deliverResponse(response);
328
+ } else {
329
+ log.debug({ name, sessionId: sid }, "Non-main query finished, feeding to main session");
330
+ this.#queue.push({ type: "background-agent-result", name, response });
331
+ }
332
+ },
333
+ async (err) => {
334
+ if (!this.#runningSessions.has(sid)) {
335
+ log.error({ name, sessionId: sid, err }, "Failed session not in runningSessions — delivering error");
336
+ } else {
337
+ this.#runningSessions.delete(sid);
338
+ log.error({ name, sessionId: sid, err }, "Main query failed");
339
+ }
340
+ await this.#deliverResponse(this.#errorResponse(err));
341
+ },
342
+ );
312
343
  }
313
344
 
314
345
  #formatPrompt(request: OrchestratorRequest): string {
@@ -327,29 +358,6 @@ export class Orchestrator {
327
358
  }
328
359
  }
329
360
 
330
- async #awaitOrBackground(
331
- query: RunningQuery<AgentOutput>,
332
- request: OrchestratorRequest,
333
- timeoutMs: number,
334
- ): Promise<QueryResult<AgentOutput> | null> {
335
- const result = await Promise.race([
336
- query.result,
337
- new Promise<null>((resolve) => setTimeout(() => resolve(null), timeoutMs)),
338
- ]);
339
-
340
- if (result !== null) return result;
341
-
342
- const name = request.type === "user" ? request.message.slice(0, 30).replace(/\s+/g, "-")
343
- : request.type === "cron" ? `cron-${request.name}`
344
- : "task";
345
- const prompt = this.#formatPrompt(request);
346
- const model = request.type === "cron" ? (request.model ?? this.#config.model) : this.#config.model;
347
- log.info({ name, sessionId: query.sessionId }, "Request backgrounded due to timeout");
348
- this.#callOnResponse({ message: "This is taking longer, continuing in the background." });
349
- this.#adoptBackground(name, prompt, model, query);
350
- return null;
351
- }
352
-
353
361
  #errorResponse(err: unknown): AgentOutput {
354
362
  if (err instanceof QueryProcessError) {
355
363
  return { action: "send", message: `[Error] Claude exited with code ${err.exitCode}:\n${err.stderr}`, actionReason: "process-error" };
@@ -372,47 +380,27 @@ export class Orchestrator {
372
380
  const query = this.#mainSessionId
373
381
  ? this.#claude.forkSession(this.#mainSessionId, bgPrompt, responseResultType, { model })
374
382
  : this.#claude.newSession(bgPrompt, responseResultType, { model });
375
- const sessionId = query.sessionId;
376
- const info: BackgroundInfo = { name, prompt, model, query };
377
- this.#backgroundAgents.set(sessionId, info);
378
-
379
- log.debug({ name, sessionId }, "Starting background agent");
380
-
381
- query.result.then(
382
- async ({ value: response }) => {
383
- if (!this.#backgroundAgents.has(sessionId)) return;
384
- this.#backgroundAgents.delete(sessionId);
385
- log.debug({ name, message: response.message }, "Background agent finished");
386
- this.#queue.push({ type: "background-agent-result", name, response });
387
- },
388
- (err) => {
389
- if (!this.#backgroundAgents.has(sessionId)) return;
390
- this.#backgroundAgents.delete(sessionId);
391
- log.error({ name, err }, "Background agent failed");
392
- this.#queue.push({ type: "background-agent-result", name, response: { action: "send", message: `[Error] ${err}`, actionReason: "bg-agent-failed" } });
393
- },
394
- );
383
+ this.#registerBackground(name, prompt, model, query);
395
384
  }
396
385
 
397
- #adoptBackground(name: string, prompt: string, model: string | undefined, query: RunningQuery<AgentOutput>) {
398
- const sessionId = query.sessionId;
399
- const info: BackgroundInfo = { name, prompt, model, query };
400
- this.#backgroundAgents.set(sessionId, info);
386
+ #registerBackground(name: string, prompt: string, model: string | undefined, query: RunningQuery<AgentOutput>) {
387
+ const sid = query.sessionId;
388
+ this.#runningSessions.set(sid, { name, prompt, model, query, lastMessageAt: new Date() });
401
389
 
402
- log.debug({ name, sessionId }, "Adopting backgrounded task");
390
+ log.debug({ name, sessionId: sid }, "Background session registered");
403
391
 
404
392
  query.result.then(
405
393
  ({ value: response }) => {
406
- if (!this.#backgroundAgents.has(sessionId)) return;
407
- this.#backgroundAgents.delete(sessionId);
408
- log.debug({ name }, "Adopted task finished");
409
- this.#queue.push({ type: "background-agent-result", name, response, sessionId });
394
+ if (!this.#runningSessions.has(sid)) return;
395
+ this.#runningSessions.delete(sid);
396
+ log.debug({ name, message: response.message }, "Background session finished");
397
+ this.#queue.push({ type: "background-agent-result", name, response });
410
398
  },
411
399
  (err) => {
412
- if (!this.#backgroundAgents.has(sessionId)) return;
413
- this.#backgroundAgents.delete(sessionId);
414
- log.error({ name, err }, "Adopted task failed");
415
- this.#queue.push({ type: "background-agent-result", name, response: { action: "send", message: `[Error] ${err}`, actionReason: "deferred-failed" }, sessionId });
400
+ if (!this.#runningSessions.has(sid)) return;
401
+ this.#runningSessions.delete(sid);
402
+ log.error({ name, err }, "Background session failed");
403
+ this.#queue.push({ type: "background-agent-result", name, response: { action: "send", message: `[Error] ${err}`, actionReason: "bg-failed" } });
416
404
  },
417
405
  );
418
406
  }
@@ -10,7 +10,7 @@ describe("SYSTEM_PROMPT", () => {
10
10
  expect(SYSTEM_PROMPT).toContain("Cron");
11
11
  expect(SYSTEM_PROMPT).toContain("Buttons");
12
12
  expect(SYSTEM_PROMPT).toContain("Files");
13
- expect(SYSTEM_PROMPT).toContain("Timeouts");
13
+ expect(SYSTEM_PROMPT).toContain("Session routing");
14
14
  });
15
15
 
16
16
  it("contains HTML formatting instructions", () => {
package/src/prompts.ts CHANGED
@@ -1,12 +1,3 @@
1
- export const MAIN_TIMEOUT = 60_000;
2
- export const CRON_TIMEOUT = 300_000;
3
- export const BG_TIMEOUT = 1_800_000;
4
-
5
- const fmtMin = (ms: number) => {
6
- const m = ms / 60_000;
7
- return `${m} minute${m > 1 ? "s" : ""}`;
8
- };
9
-
10
1
  export const SYSTEM_PROMPT = `\
11
2
  AI assistant running in macroclaw, an autonomous agent platform. \
12
3
  Persistent workspace at cwd with config, memory, skills. \
@@ -28,21 +19,24 @@ Context tags: messages may be prefixed with [Context: <type>]. Types:
28
19
  - cron/<name> — automated scheduled task. Prefer action="silent" when nothing noteworthy.
29
20
  - button-click — user tapped an inline keyboard button.
30
21
  - background-result/<name> — output from a background agent you spawned. Decide whether to relay or handle silently.
31
- - background-agent/<name> — you are a background agent. Complete task, return result. Cannot spawn sub-agents.
22
+ - background-agent/<name> — you are a background agent. Complete task, return result.
23
+ - previous task "<prompt>" moved to background — a long-running task was demoted. Mention briefly if relevant.
32
24
 
33
25
  Background agents: spawn alongside any response via backgroundAgents array:
34
26
  backgroundAgents: [{ name: "label", prompt: "task", model: "haiku" }]
35
- Each runs in same workspace, fresh session. Result fed back as [Context: background-result/<name>].
27
+ Each runs in same workspace, forked session. Result fed back as [Context: background-result/<name>].
36
28
  Models: haiku (fast/cheap), sonnet (balanced, default), opus (complex reasoning).
37
- User can spawn directly with "bg:" prefix. Use for long-running tasks that shouldn't block.
29
+ User can spawn directly with /bg command. Use for long-running tasks that shouldn't block.
30
+
31
+ Session routing: if a new message arrives while your session is busy for over 1 minute, \
32
+ the running task is automatically moved to background and a new session is forked. \
33
+ You may see a [Context: previous task "..." moved to background] prefix when this happens.
38
34
 
39
35
  Files: attachments listed as [File: /path] prefixes. Read/view at those paths. \
40
36
  Send files via files array (absolute paths). Images (.png/.jpg/.jpeg/.gif/.webp) as photos, rest as documents. 50MB limit.
41
37
 
42
- Timeouts: user=${fmtMin(MAIN_TIMEOUT)}, cron=${fmtMin(CRON_TIMEOUT)}, background=${fmtMin(BG_TIMEOUT)}. \
43
- On timeout, task continues in background automatically. Spawn background agents proactively for long tasks.
44
-
45
- Cron: jobs in data/schedule.json (hot-reloaded). Use "silent" when check finds nothing new, "send" when noteworthy.
38
+ Cron: jobs in data/schedule.json (hot-reloaded). Cron jobs always run as background sessions. \
39
+ Use "silent" when check finds nothing new, "send" when noteworthy.
46
40
 
47
41
  MessageButtons: include a buttons field (flat array of label strings) to attach inline buttons below your message. \
48
42
  Each button gets its own row. Max 27 characters per label — if options need more detail, describe them in the message and use short labels on buttons.`;
@@ -1,13 +1,16 @@
1
- import { afterEach, describe, expect, it } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
2
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { loadSessions, newSessionId, saveSessions } from "./sessions";
5
5
 
6
6
  const tmpDir = "/tmp/macroclaw-sessions-test";
7
7
 
8
- afterEach(() => {
8
+ function cleanup() {
9
9
  if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true });
10
- });
10
+ }
11
+
12
+ beforeEach(cleanup);
13
+ afterEach(cleanup);
11
14
 
12
15
  describe("loadSessions", () => {
13
16
  it("returns empty object when dir does not exist", () => {
@@ -679,6 +679,9 @@ describe("status", () => {
679
679
  });
680
680
  mockExistsSync.mockReturnValue(false);
681
681
  const mgr = createManager({ home: "/nonexistent" });
682
+ // Override isInstalled getter — on hosts where macroclaw is installed as a systemd
683
+ // service, existsSync("/etc/systemd/system/macroclaw.service") returns true
684
+ Object.defineProperty(mgr, "isInstalled", { get: () => false });
682
685
  const s = mgr.status();
683
686
  expect(s.installed).toBe(false);
684
687
  expect(s.running).toBe(false);