macroclaw 0.32.0 → 0.34.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/package.json +1 -1
- package/src/app.test.ts +159 -103
- package/src/app.ts +13 -0
- package/src/claude.integration-test.ts +65 -27
- package/src/claude.test.ts +369 -189
- package/src/claude.ts +171 -71
- package/src/orchestrator.test.ts +301 -249
- package/src/orchestrator.ts +157 -96
- package/workspace-template/.claude/skills/self-update/SKILL.md +1 -1
- package/workspace-template/.claude/skills/self-update/scripts/update.sh +3 -4
package/src/orchestrator.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { z } from "zod/v4";
|
|
2
2
|
import {
|
|
3
3
|
Claude,
|
|
4
|
+
type ClaudeProcess,
|
|
4
5
|
QueryParseError,
|
|
5
6
|
QueryProcessError,
|
|
7
|
+
type QueryResult,
|
|
6
8
|
QueryValidationError,
|
|
7
|
-
type RunningQuery
|
|
8
9
|
} from "./claude";
|
|
9
10
|
import { writeHistoryPrompt, writeHistoryResult } from "./history";
|
|
10
11
|
import { createLogger } from "./logger";
|
|
@@ -92,7 +93,8 @@ interface SessionInfo {
|
|
|
92
93
|
name: string;
|
|
93
94
|
prompt: string;
|
|
94
95
|
model?: string;
|
|
95
|
-
|
|
96
|
+
process: ClaudeProcess<AgentOutput>;
|
|
97
|
+
pendingResult: Promise<QueryResult<AgentOutput>>;
|
|
96
98
|
lastMessageAt: Date;
|
|
97
99
|
healthCheckTimer?: Timer;
|
|
98
100
|
}
|
|
@@ -119,6 +121,7 @@ export class Orchestrator {
|
|
|
119
121
|
#healthCheckTimeout: number;
|
|
120
122
|
|
|
121
123
|
#mainSessionId: string | undefined;
|
|
124
|
+
#mainProcess: ClaudeProcess<AgentOutput> | null = null;
|
|
122
125
|
#runningSessions = new Map<string, SessionInfo>();
|
|
123
126
|
#queue: Queue<OrchestratorRequest>;
|
|
124
127
|
|
|
@@ -167,14 +170,14 @@ export class Orchestrator {
|
|
|
167
170
|
return;
|
|
168
171
|
}
|
|
169
172
|
const lines = sessions.map(([sid, s]) => {
|
|
170
|
-
const elapsed = Math.round((Date.now() - s.
|
|
173
|
+
const elapsed = Math.round((Date.now() - s.process.startedAt.getTime()) / 1000);
|
|
171
174
|
const isMain = sid === this.#mainSessionId;
|
|
172
175
|
return isMain
|
|
173
176
|
? `▶ ${escapeHtml(s.name)} (${elapsed}s) [main]`
|
|
174
177
|
: `- ${escapeHtml(s.name)} (${elapsed}s)`;
|
|
175
178
|
});
|
|
176
179
|
const buttons: ButtonSpec[] = sessions.map(([sid, s]) => {
|
|
177
|
-
const elapsed = Math.round((Date.now() - s.
|
|
180
|
+
const elapsed = Math.round((Date.now() - s.process.startedAt.getTime()) / 1000);
|
|
178
181
|
const text = `${s.name} (${elapsed}s)`.slice(0, 27);
|
|
179
182
|
return { text, data: `detail:${sid}` };
|
|
180
183
|
});
|
|
@@ -189,7 +192,7 @@ export class Orchestrator {
|
|
|
189
192
|
return;
|
|
190
193
|
}
|
|
191
194
|
|
|
192
|
-
const elapsed = Math.round((Date.now() - session.
|
|
195
|
+
const elapsed = Math.round((Date.now() - session.process.startedAt.getTime()) / 1000);
|
|
193
196
|
const truncatedPrompt = session.prompt.length > 300 ? `${session.prompt.slice(0, 300)}…` : session.prompt;
|
|
194
197
|
const isMain = sessionId === this.#mainSessionId;
|
|
195
198
|
const lines = [
|
|
@@ -222,13 +225,13 @@ export class Orchestrator {
|
|
|
222
225
|
session.name,
|
|
223
226
|
`Only consider progress since the "${session.name}" event. Brief status update: done, in progress, remaining. 2-3 sentences max, plain text.`,
|
|
224
227
|
);
|
|
225
|
-
const
|
|
228
|
+
const peekProcess = this.#claude.forkSession(
|
|
226
229
|
sessionId,
|
|
227
|
-
prompt,
|
|
228
230
|
textResultType,
|
|
229
231
|
{ model: "haiku" },
|
|
230
232
|
);
|
|
231
|
-
const { value } = await
|
|
233
|
+
const { value } = await peekProcess.send(prompt);
|
|
234
|
+
peekProcess.kill().catch(() => {});
|
|
232
235
|
this.#callOnResponse({ message: `<b>[${escapeHtml(session.name)}]</b> ${value || "[No output]"}` });
|
|
233
236
|
} catch (err) {
|
|
234
237
|
this.#callOnResponse({ message: `Couldn't peek at ${escapeHtml(session.name)}: ${err}` });
|
|
@@ -245,7 +248,7 @@ export class Orchestrator {
|
|
|
245
248
|
this.#clearSession(sessionId);
|
|
246
249
|
|
|
247
250
|
try {
|
|
248
|
-
await session.
|
|
251
|
+
await session.process.kill();
|
|
249
252
|
} catch (err) {
|
|
250
253
|
log.error({ err, name: session.name }, "Kill failed");
|
|
251
254
|
}
|
|
@@ -253,102 +256,127 @@ export class Orchestrator {
|
|
|
253
256
|
this.#callOnResponse({ message: `Killed <b>${escapeHtml(session.name)}</b>.` });
|
|
254
257
|
}
|
|
255
258
|
|
|
259
|
+
async handleClear(): Promise<void> {
|
|
260
|
+
const sid = this.#mainProcess?.sessionId;
|
|
261
|
+
if (sid) {
|
|
262
|
+
this.#clearSession(sid);
|
|
263
|
+
}
|
|
264
|
+
if (this.#mainProcess) {
|
|
265
|
+
try {
|
|
266
|
+
await this.#mainProcess.kill();
|
|
267
|
+
} catch (err) {
|
|
268
|
+
log.error({ err }, "Failed to kill main process during clear");
|
|
269
|
+
}
|
|
270
|
+
this.#mainProcess = null;
|
|
271
|
+
}
|
|
272
|
+
this.#mainSessionId = undefined;
|
|
273
|
+
saveSessions({}, this.#config.settingsDir);
|
|
274
|
+
log.info("Session cleared");
|
|
275
|
+
this.#callOnResponse({ message: "Session cleared." });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Tear down all sessions and the main process. Used in tests. */
|
|
279
|
+
async dispose(): Promise<void> {
|
|
280
|
+
for (const sid of [...this.#runningSessions.keys()]) {
|
|
281
|
+
this.#clearSession(sid);
|
|
282
|
+
}
|
|
283
|
+
if (this.#mainProcess) {
|
|
284
|
+
await this.#mainProcess.kill().catch(() => {});
|
|
285
|
+
this.#mainProcess = null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// --- Main process lifecycle ---
|
|
290
|
+
|
|
291
|
+
#ensureMainProcess(): ClaudeProcess<AgentOutput> {
|
|
292
|
+
if (this.#mainProcess && this.#mainProcess.state !== "dead") {
|
|
293
|
+
return this.#mainProcess;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const opts = { model: this.#config.model };
|
|
297
|
+
this.#mainProcess = this.#mainSessionId
|
|
298
|
+
? this.#claude.resumeSession(this.#mainSessionId, responseResultType, opts)
|
|
299
|
+
: this.#claude.newSession(responseResultType, opts);
|
|
300
|
+
|
|
301
|
+
if (this.#mainProcess.sessionId !== this.#mainSessionId) {
|
|
302
|
+
this.#mainSessionId = this.#mainProcess.sessionId;
|
|
303
|
+
saveSessions({ mainSessionId: this.#mainSessionId }, this.#config.settingsDir);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
log.info({ sessionId: this.#mainSessionId }, "Main process created");
|
|
307
|
+
return this.#mainProcess;
|
|
308
|
+
}
|
|
256
309
|
|
|
257
310
|
// --- Internal queue handler ---
|
|
258
311
|
|
|
259
312
|
async #handleRequest(request: OrchestratorRequest): Promise<void> {
|
|
260
313
|
log.debug({ type: request.type }, "Incoming request");
|
|
261
314
|
|
|
262
|
-
const mainInfo = this.#mainSessionId ? this.#runningSessions.get(this.#mainSessionId) : undefined;
|
|
263
315
|
let movedToBackground: string | undefined;
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
// Main started recently — wait for it to finish or threshold
|
|
273
|
-
const remaining = this.#waitThreshold - elapsed;
|
|
274
|
-
const finished = await Promise.race([
|
|
275
|
-
mainInfo.query.result.then(() => true as const, () => true as const),
|
|
276
|
-
new Promise<false>((r) => setTimeout(() => r(false), remaining)),
|
|
277
|
-
]);
|
|
278
|
-
|
|
279
|
-
if (!finished) {
|
|
280
|
-
log.info({ name: mainInfo.name, sessionId: mainInfo.query.sessionId }, "Moving main session to background (wait timed out)");
|
|
316
|
+
let backgroundedName: string | undefined;
|
|
317
|
+
const currentMain = this.#mainProcess;
|
|
318
|
+
|
|
319
|
+
if (currentMain?.state === "busy") {
|
|
320
|
+
const mainInfo = this.#runningSessions.get(currentMain.sessionId);
|
|
321
|
+
if (mainInfo) {
|
|
322
|
+
const elapsed = Date.now() - mainInfo.lastMessageAt.getTime();
|
|
323
|
+
if (elapsed >= this.#waitThreshold) {
|
|
281
324
|
movedToBackground = mainInfo.prompt;
|
|
325
|
+
backgroundedName = mainInfo.name;
|
|
326
|
+
} else {
|
|
327
|
+
const remaining = this.#waitThreshold - elapsed;
|
|
328
|
+
const finished = await Promise.race([
|
|
329
|
+
mainInfo.pendingResult.then(() => true as const, () => true as const),
|
|
330
|
+
new Promise<false>((r) => setTimeout(() => r(false), remaining)),
|
|
331
|
+
]);
|
|
332
|
+
|
|
333
|
+
if (!finished) {
|
|
334
|
+
movedToBackground = mainInfo.prompt;
|
|
335
|
+
backgroundedName = mainInfo.name;
|
|
336
|
+
}
|
|
282
337
|
}
|
|
283
|
-
// If finished: completion handler already delivered the result and removed from map.
|
|
284
338
|
}
|
|
285
339
|
}
|
|
286
340
|
|
|
341
|
+
if (movedToBackground && currentMain) {
|
|
342
|
+
log.info({ name: backgroundedName, sessionId: currentMain.sessionId }, "Moving main session to background");
|
|
343
|
+
this.#mainProcess = this.#claude.forkSession(
|
|
344
|
+
this.#mainSessionId ?? currentMain.sessionId,
|
|
345
|
+
responseResultType,
|
|
346
|
+
{ model: this.#config.model },
|
|
347
|
+
);
|
|
348
|
+
this.#mainSessionId = this.#mainProcess.sessionId;
|
|
349
|
+
saveSessions({ mainSessionId: this.#mainSessionId }, this.#config.settingsDir);
|
|
350
|
+
}
|
|
351
|
+
|
|
287
352
|
await writeHistoryPrompt(request);
|
|
288
353
|
|
|
289
354
|
const label = Orchestrator.#requestLabel(request);
|
|
290
355
|
const name = generateName(label);
|
|
291
|
-
const backgroundedName = movedToBackground ? mainInfo?.name : undefined;
|
|
292
356
|
const formatted = this.#formatPrompt(request, name, backgroundedName);
|
|
293
357
|
|
|
294
|
-
this.#
|
|
358
|
+
this.#sendToMain(name, label, formatted);
|
|
295
359
|
}
|
|
296
360
|
|
|
297
|
-
// ---
|
|
361
|
+
// --- Main session send ---
|
|
298
362
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
this.#callOnResponse({
|
|
303
|
-
message: response.message || "[No output]",
|
|
304
|
-
files: response.files,
|
|
305
|
-
buttons: response.buttons,
|
|
306
|
-
});
|
|
307
|
-
} else {
|
|
308
|
-
log.debug("Silent response");
|
|
309
|
-
}
|
|
363
|
+
#sendToMain(name: string, displayPrompt: string, formatted: string): void {
|
|
364
|
+
const process = this.#ensureMainProcess();
|
|
365
|
+
const sid = process.sessionId;
|
|
310
366
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
#callOnResponse(response: OrchestratorResponse): void {
|
|
321
|
-
this.#config.onResponse(response).catch((err) => {
|
|
322
|
-
log.error({ err }, "onResponse callback failed");
|
|
367
|
+
const pendingResult = process.send(formatted);
|
|
368
|
+
this.#runningSessions.set(sid, {
|
|
369
|
+
name,
|
|
370
|
+
prompt: displayPrompt,
|
|
371
|
+
model: this.#config.model,
|
|
372
|
+
process,
|
|
373
|
+
pendingResult,
|
|
374
|
+
lastMessageAt: new Date(),
|
|
323
375
|
});
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// --- Main session query ---
|
|
327
|
-
|
|
328
|
-
#startMainQuery(name: string, displayPrompt: string, formatted: string, model: string | undefined): void {
|
|
329
|
-
const opts = { model };
|
|
330
|
-
let query: RunningQuery<AgentOutput>;
|
|
331
|
-
|
|
332
|
-
if (this.#mainSessionId && this.#runningSessions.has(this.#mainSessionId)) {
|
|
333
|
-
query = this.#claude.forkSession(this.#mainSessionId, formatted, responseResultType, opts);
|
|
334
|
-
} else if (this.#mainSessionId) {
|
|
335
|
-
query = this.#claude.resumeSession(this.#mainSessionId, formatted, responseResultType, opts);
|
|
336
|
-
} else {
|
|
337
|
-
query = this.#claude.newSession(formatted, responseResultType, opts);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
const sid = query.sessionId;
|
|
341
|
-
this.#runningSessions.set(sid, { name, prompt: displayPrompt, model, query, lastMessageAt: new Date() });
|
|
342
|
-
|
|
343
|
-
if (sid !== this.#mainSessionId) {
|
|
344
|
-
log.info({ oldSessionId: this.#mainSessionId, newSessionId: sid }, "Session updated");
|
|
345
|
-
this.#mainSessionId = sid;
|
|
346
|
-
saveSessions({ mainSessionId: sid }, this.#config.settingsDir);
|
|
347
|
-
}
|
|
348
376
|
|
|
349
|
-
log.debug({ name, sessionId: sid }, "Main query
|
|
377
|
+
log.debug({ name, sessionId: sid }, "Main query sent");
|
|
350
378
|
|
|
351
|
-
|
|
379
|
+
pendingResult.then(
|
|
352
380
|
async ({ value: response }) => {
|
|
353
381
|
if (!this.#runningSessions.has(sid)) {
|
|
354
382
|
log.error({ name, sessionId: sid }, "Completed session not in runningSessions — delivering anyway");
|
|
@@ -357,11 +385,12 @@ export class Orchestrator {
|
|
|
357
385
|
}
|
|
358
386
|
this.#clearSession(sid);
|
|
359
387
|
|
|
360
|
-
if (
|
|
388
|
+
if (process === this.#mainProcess) {
|
|
361
389
|
log.debug({ name, sessionId: sid }, "Main query finished, delivering directly");
|
|
362
390
|
await this.#deliverResponse(response);
|
|
363
391
|
} else {
|
|
364
392
|
log.debug({ name, sessionId: sid }, "Non-main query finished, feeding to main session");
|
|
393
|
+
process.kill().catch(() => {});
|
|
365
394
|
this.#queue.push({ type: "background-agent-result", name, response });
|
|
366
395
|
}
|
|
367
396
|
},
|
|
@@ -377,6 +406,35 @@ export class Orchestrator {
|
|
|
377
406
|
);
|
|
378
407
|
}
|
|
379
408
|
|
|
409
|
+
// --- Response delivery ---
|
|
410
|
+
|
|
411
|
+
async #deliverResponse(response: AgentOutput): Promise<void> {
|
|
412
|
+
await writeHistoryResult(response);
|
|
413
|
+
if (response.action === "send") {
|
|
414
|
+
this.#callOnResponse({
|
|
415
|
+
message: response.message || "[No output]",
|
|
416
|
+
files: response.files,
|
|
417
|
+
buttons: response.buttons,
|
|
418
|
+
});
|
|
419
|
+
} else {
|
|
420
|
+
log.debug("Silent response");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (response.backgroundAgents?.length) {
|
|
424
|
+
for (const agent of response.backgroundAgents) {
|
|
425
|
+
const agentModel = agent.model ?? this.#config.model;
|
|
426
|
+
this.#spawnBackground(agent.name, agent.prompt, agentModel);
|
|
427
|
+
this.#callOnResponse({ message: `Background agent "${escapeHtml(agent.name)}" started.` });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
#callOnResponse(response: OrchestratorResponse): void {
|
|
433
|
+
this.#config.onResponse(response).catch((err) => {
|
|
434
|
+
log.error({ err }, "onResponse callback failed");
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
380
438
|
#formatPrompt(request: OrchestratorRequest, name: string, backgroundedEvent?: string): string {
|
|
381
439
|
switch (request.type) {
|
|
382
440
|
case "user":
|
|
@@ -438,31 +496,32 @@ export class Orchestrator {
|
|
|
438
496
|
}
|
|
439
497
|
|
|
440
498
|
#spawnBackgroundRaw(name: string, prompt: string, formatted: string, model: string | undefined) {
|
|
441
|
-
const
|
|
442
|
-
? this.#claude.forkSession(this.#mainSessionId,
|
|
443
|
-
: this.#claude.newSession(
|
|
444
|
-
this.#registerBackground(name, prompt, model, query);
|
|
445
|
-
}
|
|
499
|
+
const process = this.#mainSessionId
|
|
500
|
+
? this.#claude.forkSession(this.#mainSessionId, responseResultType, { model })
|
|
501
|
+
: this.#claude.newSession(responseResultType, { model });
|
|
446
502
|
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
|
|
503
|
+
const sid = process.sessionId;
|
|
504
|
+
const pendingResult = process.send(formatted);
|
|
505
|
+
|
|
506
|
+
const info: SessionInfo = { name, prompt, model, process, pendingResult, lastMessageAt: new Date() };
|
|
450
507
|
this.#runningSessions.set(sid, info);
|
|
451
508
|
|
|
452
509
|
log.debug({ name, sessionId: sid }, "Background session registered");
|
|
453
510
|
|
|
454
511
|
this.#scheduleHealthCheck(sid);
|
|
455
512
|
|
|
456
|
-
|
|
513
|
+
pendingResult.then(
|
|
457
514
|
({ value: response }) => {
|
|
458
515
|
if (!this.#runningSessions.has(sid)) return;
|
|
459
516
|
this.#clearSession(sid);
|
|
517
|
+
process.kill().catch(() => {});
|
|
460
518
|
log.debug({ name, message: response.message }, "Background session finished");
|
|
461
519
|
this.#queue.push({ type: "background-agent-result", name, response });
|
|
462
520
|
},
|
|
463
521
|
(err) => {
|
|
464
522
|
if (!this.#runningSessions.has(sid)) return;
|
|
465
523
|
this.#clearSession(sid);
|
|
524
|
+
process.kill().catch(() => {});
|
|
466
525
|
log.error({ name, err }, "Background session failed");
|
|
467
526
|
this.#queue.push({ type: "background-agent-result", name, response: { action: "send", message: `[Error] ${err}`, actionReason: "bg-failed" } });
|
|
468
527
|
},
|
|
@@ -502,9 +561,9 @@ export class Orchestrator {
|
|
|
502
561
|
"Report your current status. If your task is complete, set finished=true and provide the full output. If still working, set finished=false and describe current progress in one sentence.",
|
|
503
562
|
);
|
|
504
563
|
|
|
505
|
-
let
|
|
564
|
+
let hcProcess: ClaudeProcess<z.infer<typeof healthCheckSchema>>;
|
|
506
565
|
try {
|
|
507
|
-
|
|
566
|
+
hcProcess = this.#claude.forkSession(sessionId, healthCheckResultType, { model: "haiku" });
|
|
508
567
|
} catch (err) {
|
|
509
568
|
log.error({ name: info.name, sessionId, err }, "Health check fork failed");
|
|
510
569
|
this.#scheduleHealthCheck(sessionId);
|
|
@@ -512,18 +571,20 @@ export class Orchestrator {
|
|
|
512
571
|
}
|
|
513
572
|
|
|
514
573
|
const result = await Promise.race([
|
|
515
|
-
|
|
574
|
+
hcProcess.send(prompt).then((r) => r.value),
|
|
516
575
|
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), this.#healthCheckTimeout)),
|
|
517
576
|
]);
|
|
518
577
|
|
|
578
|
+
// Always kill health check process
|
|
579
|
+
hcProcess.kill().catch(() => {});
|
|
580
|
+
|
|
519
581
|
// Session may have completed/been killed while health check was running
|
|
520
582
|
if (!this.#runningSessions.has(sessionId)) return;
|
|
521
583
|
|
|
522
584
|
if (result === "timeout") {
|
|
523
585
|
log.warn({ name: info.name, sessionId }, "Health check timed out, killing session");
|
|
524
|
-
try { await query.kill(); } catch { /* ignore */ }
|
|
525
586
|
this.#clearSession(sessionId);
|
|
526
|
-
try { await info.
|
|
587
|
+
try { await info.process.kill(); } catch { /* ignore */ }
|
|
527
588
|
this.#callOnResponse({ message: `Agent <b>${escapeHtml(info.name)}</b> appears unresponsive, killed it.` });
|
|
528
589
|
return;
|
|
529
590
|
}
|
|
@@ -531,7 +592,7 @@ export class Orchestrator {
|
|
|
531
592
|
if (result.finished) {
|
|
532
593
|
log.info({ name: info.name, sessionId }, "Health check: agent reports finished");
|
|
533
594
|
this.#clearSession(sessionId);
|
|
534
|
-
try { await info.
|
|
595
|
+
try { await info.process.kill(); } catch { /* ignore */ }
|
|
535
596
|
const response = result.output ?? { action: "send" as const, message: "[Agent finished but returned no output]", actionReason: "health-check-finished" };
|
|
536
597
|
this.#queue.push({ type: "background-agent-result", name: info.name, response });
|
|
537
598
|
return;
|
|
@@ -24,6 +24,6 @@ Update macroclaw to the latest version.
|
|
|
24
24
|
|
|
25
25
|
## Important
|
|
26
26
|
|
|
27
|
-
- The `macroclaw service update` command stops the service, installs the latest version, and starts it again. Stopping the service kills all processes in the cgroup — including this Claude Code session.
|
|
27
|
+
- The `macroclaw service update` command stops the user service, installs the latest version, and starts it again. Stopping the service kills all processes in the cgroup — including this Claude Code session.
|
|
28
28
|
- Do NOT use a background agent — it gets killed along with the main process.
|
|
29
29
|
- Always run step 4 LAST — everything after it may not execute.
|
|
@@ -10,16 +10,15 @@ LOG_FILE="$1"
|
|
|
10
10
|
|
|
11
11
|
case "$(uname -s)" in
|
|
12
12
|
Linux)
|
|
13
|
-
|
|
13
|
+
systemd-run --user \
|
|
14
14
|
--unit="macroclaw-update-$(date -u +%Y%m%dT%H%M%SZ)" \
|
|
15
15
|
--collect \
|
|
16
16
|
--no-block \
|
|
17
|
-
--property="User=$(id -un)" \
|
|
18
|
-
--property="Group=$(id -gn)" \
|
|
19
|
-
--setenv="HOME=$HOME" \
|
|
20
17
|
--setenv="PATH=$PATH" \
|
|
21
18
|
/bin/bash -lc "exec macroclaw service update > \"$LOG_FILE\" 2>&1"
|
|
22
19
|
|
|
20
|
+
# --user: run in user service manager (not system), so the transient unit
|
|
21
|
+
# has access to the user D-Bus session bus and can restart user services.
|
|
23
22
|
# --collect: automatically remove the transient unit after it finishes.
|
|
24
23
|
# --no-block: return immediately instead of waiting for the started job.
|
|
25
24
|
;;
|