@cleocode/cleo 2026.6.13 → 2026.6.15
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/dist/cli/index.js +7545 -1924
- package/dist/cli/index.js.map +3 -3
- package/package.json +16 -14
- package/scripts/copy-studio-dist.mjs +75 -0
- package/scripts/install-daemon-service.mjs +396 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cleocode/cleo",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.15",
|
|
4
4
|
"description": "CLEO CLI — the assembled product consuming @cleocode/core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cli/index.js",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"ct": "bin/cleo.js"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
+
"@earendil-works/pi-tui": "^0.79.1",
|
|
15
16
|
"check-disk-space": "^3.4.0",
|
|
16
17
|
"citty": "^0.2.1",
|
|
17
18
|
"graphology": "^0.26.0",
|
|
@@ -30,17 +31,17 @@
|
|
|
30
31
|
"tree-sitter-rust": "0.23.1",
|
|
31
32
|
"tree-sitter-typescript": "^0.23.2",
|
|
32
33
|
"yaml": "^2.8.3",
|
|
33
|
-
"@cleocode/
|
|
34
|
-
"@cleocode/
|
|
35
|
-
"@cleocode/
|
|
36
|
-
"@cleocode/contracts": "2026.6.
|
|
37
|
-
"@cleocode/
|
|
38
|
-
"@cleocode/
|
|
39
|
-
"@cleocode/nexus": "2026.6.
|
|
40
|
-
"@cleocode/playbooks": "2026.6.
|
|
41
|
-
"@cleocode/runtime": "2026.6.
|
|
42
|
-
"@cleocode/
|
|
43
|
-
"@cleocode/
|
|
34
|
+
"@cleocode/caamp": "2026.6.15",
|
|
35
|
+
"@cleocode/core": "2026.6.15",
|
|
36
|
+
"@cleocode/lafs": "2026.6.15",
|
|
37
|
+
"@cleocode/contracts": "2026.6.15",
|
|
38
|
+
"@cleocode/cant": "2026.6.15",
|
|
39
|
+
"@cleocode/animations": "2026.6.15",
|
|
40
|
+
"@cleocode/nexus": "2026.6.15",
|
|
41
|
+
"@cleocode/playbooks": "2026.6.15",
|
|
42
|
+
"@cleocode/runtime": "2026.6.15",
|
|
43
|
+
"@cleocode/worktree": "2026.6.15",
|
|
44
|
+
"@cleocode/paths": "2026.6.15"
|
|
44
45
|
},
|
|
45
46
|
"engines": {
|
|
46
47
|
"node": ">=24.16.0"
|
|
@@ -55,7 +56,8 @@
|
|
|
55
56
|
"bin",
|
|
56
57
|
"scripts",
|
|
57
58
|
"templates",
|
|
58
|
-
"assets"
|
|
59
|
+
"assets",
|
|
60
|
+
"studio-dist"
|
|
59
61
|
],
|
|
60
62
|
"devDependencies": {
|
|
61
63
|
"@types/node-cron": "^3.0.11",
|
|
@@ -71,7 +73,7 @@
|
|
|
71
73
|
"gen:manifest": "node scripts/generate-command-manifest.mjs",
|
|
72
74
|
"prebuild": "node scripts/generate-command-manifest.mjs",
|
|
73
75
|
"build": "tsc",
|
|
74
|
-
"postbuild": "node scripts/assert-shebang.mjs",
|
|
76
|
+
"postbuild": "node scripts/assert-shebang.mjs && node scripts/copy-studio-dist.mjs",
|
|
75
77
|
"pretypecheck": "node scripts/generate-command-manifest.mjs",
|
|
76
78
|
"typecheck": "tsc --noEmit",
|
|
77
79
|
"test": "cd ../.. && vitest run packages/cleo/src",
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* copy-studio-dist.mjs — postbuild step for @cleocode/cleo (T11979).
|
|
4
|
+
*
|
|
5
|
+
* Copies the Studio adapter-node build output from
|
|
6
|
+
* `packages/studio/build/` into `packages/cleo/studio-dist/` so the
|
|
7
|
+
* published @cleocode/cleo tarball contains a batteries-included Studio
|
|
8
|
+
* bundle that the gateway can serve at `/studio` with zero repo checkout.
|
|
9
|
+
*
|
|
10
|
+
* Resolution order for the Studio build source:
|
|
11
|
+
* 1. `CLEO_STUDIO_BUILD_DIR` environment variable (CI override).
|
|
12
|
+
* 2. `<monorepo-root>/packages/studio/build` (standard monorepo layout).
|
|
13
|
+
*
|
|
14
|
+
* When the source directory does not exist, the script exits successfully
|
|
15
|
+
* with a warning — a missing Studio build is not a hard build failure in
|
|
16
|
+
* dev checkouts where Studio has not been built yet. CI must explicitly
|
|
17
|
+
* build Studio before building the cleo package (see the wave-based build
|
|
18
|
+
* script at `build.mjs`).
|
|
19
|
+
*
|
|
20
|
+
* @task T11979
|
|
21
|
+
* @epic T11261
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { cp, mkdir, rm } from 'node:fs/promises';
|
|
25
|
+
import { existsSync } from 'node:fs';
|
|
26
|
+
import { dirname, join, resolve } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
|
|
31
|
+
// packages/cleo/scripts → packages/cleo
|
|
32
|
+
const cleoPackageDir = resolve(__dirname, '..');
|
|
33
|
+
// packages/cleo → monorepo root
|
|
34
|
+
const monorepoRoot = resolve(cleoPackageDir, '..', '..');
|
|
35
|
+
|
|
36
|
+
const srcDir =
|
|
37
|
+
process.env['CLEO_STUDIO_BUILD_DIR'] ??
|
|
38
|
+
join(monorepoRoot, 'packages', 'studio', 'build');
|
|
39
|
+
|
|
40
|
+
const destDir = join(cleoPackageDir, 'studio-dist');
|
|
41
|
+
|
|
42
|
+
if (!existsSync(srcDir)) {
|
|
43
|
+
console.warn(
|
|
44
|
+
`[copy-studio-dist] Studio build not found at ${srcDir}. ` +
|
|
45
|
+
'Run `pnpm --filter @cleocode/studio run build` first. ' +
|
|
46
|
+
'Skipping studio-dist copy (bundle will be absent from the tarball).',
|
|
47
|
+
);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Clean the destination directory so stale assets do not accumulate.
|
|
52
|
+
await rm(destDir, { recursive: true, force: true });
|
|
53
|
+
await mkdir(destDir, { recursive: true });
|
|
54
|
+
|
|
55
|
+
await cp(srcDir, destDir, { recursive: true });
|
|
56
|
+
|
|
57
|
+
// Quick sanity check: the adapter-node index.js must be present.
|
|
58
|
+
const indexJs = join(destDir, 'index.js');
|
|
59
|
+
if (!existsSync(indexJs)) {
|
|
60
|
+
console.error(
|
|
61
|
+
`[copy-studio-dist] ERROR: ${indexJs} not found after copy. ` +
|
|
62
|
+
'The Studio build may be incomplete (client-only, no server).',
|
|
63
|
+
);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const clientDir = join(destDir, 'client');
|
|
68
|
+
if (!existsSync(clientDir)) {
|
|
69
|
+
console.warn(
|
|
70
|
+
`[copy-studio-dist] Warning: studio-dist/client/ not found. ` +
|
|
71
|
+
'Gateway static serving at /studio will fall back to the absent-bundle 503.',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(`[copy-studio-dist] Copied ${srcDir} → ${destDir}`);
|
|
@@ -229,7 +229,26 @@ function isWSL() {
|
|
|
229
229
|
// Constants
|
|
230
230
|
// ---------------------------------------------------------------------------
|
|
231
231
|
|
|
232
|
-
/**
|
|
232
|
+
/**
|
|
233
|
+
* Environment variable that disables daemon auto-start for the duration of a
|
|
234
|
+
* single install invocation (CI/container path).
|
|
235
|
+
*
|
|
236
|
+
* Unlike `daemon.autoStart = false` in the global config, this env var is NOT
|
|
237
|
+
* persisted — it must be set on every install invocation. For a durable
|
|
238
|
+
* opt-out that survives upgrades, set `daemon.autoStart = false` in the CLEO
|
|
239
|
+
* global config file (printed path: `~/.local/share/cleo/config.json` on
|
|
240
|
+
* Linux / `~/Library/Application Support/cleo/config.json` on macOS):
|
|
241
|
+
*
|
|
242
|
+
* ```json
|
|
243
|
+
* { "daemon": { "autoStart": false } }
|
|
244
|
+
* ```
|
|
245
|
+
*
|
|
246
|
+
* Priority (highest wins):
|
|
247
|
+
* 1. CLEO_DAEMON_DISABLE=1 — env override (non-persistent; CI/container)
|
|
248
|
+
* 2. daemon.autoStart === false — config-file opt-out (persistent across upgrades)
|
|
249
|
+
* 3. systemd/launchd enabled state — operator's prior `disable` is respected on upgrade
|
|
250
|
+
* 4. Default: enable+start on first install; keep-enabled on upgrade where enabled
|
|
251
|
+
*/
|
|
233
252
|
const DAEMON_DISABLE_ENV = 'CLEO_DAEMON_DISABLE';
|
|
234
253
|
|
|
235
254
|
/**
|
|
@@ -308,6 +327,246 @@ function runBin(bin, args) {
|
|
|
308
327
|
}
|
|
309
328
|
}
|
|
310
329
|
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// Config helpers — daemon.autoStart persistent opt-out (T11984)
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Read `daemon.autoStart` from the CLEO global config file.
|
|
336
|
+
*
|
|
337
|
+
* This is a minimal, tolerant JSON read — it does NOT import compiled core
|
|
338
|
+
* because postinstall runs before the package's own TypeScript is compiled.
|
|
339
|
+
* If the config file is absent, unreadable, or missing the key, returns
|
|
340
|
+
* `true` (the safe default: honour existing enable/start logic).
|
|
341
|
+
*
|
|
342
|
+
* Config file location: `{cleoHome}/config.json`
|
|
343
|
+
* Linux: ~/.local/share/cleo/config.json
|
|
344
|
+
* macOS: ~/Library/Application Support/cleo/config.json
|
|
345
|
+
* Windows: %LOCALAPPDATA%\cleo\Data\config.json
|
|
346
|
+
*
|
|
347
|
+
* @returns {boolean} `false` only when `daemon.autoStart` is explicitly set
|
|
348
|
+
* to `false` in the global config. Returns `true` in all other cases.
|
|
349
|
+
*/
|
|
350
|
+
function readGlobalAutoStart() {
|
|
351
|
+
try {
|
|
352
|
+
const paths = getPlatformPaths();
|
|
353
|
+
const configPath = join(paths.data, 'config.json');
|
|
354
|
+
if (!existsSync(configPath)) return true;
|
|
355
|
+
const raw = readFileSync(configPath, 'utf8');
|
|
356
|
+
const cfg = JSON.parse(raw);
|
|
357
|
+
// Explicit false opt-out only — missing key = default true.
|
|
358
|
+
if (
|
|
359
|
+
cfg !== null &&
|
|
360
|
+
typeof cfg === 'object' &&
|
|
361
|
+
typeof cfg.daemon === 'object' &&
|
|
362
|
+
cfg.daemon !== null &&
|
|
363
|
+
cfg.daemon.autoStart === false
|
|
364
|
+
) {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
return true;
|
|
368
|
+
} catch {
|
|
369
|
+
// Config unreadable or malformed JSON — safe default: allow auto-start.
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Decide what action the postinstall hook should take for daemon activation.
|
|
376
|
+
*
|
|
377
|
+
* This is a pure function (no side-effects, no I/O) that implements the
|
|
378
|
+
* operator-state-respecting decision table. It can be unit-tested in
|
|
379
|
+
* isolation without systemd or launchd being present.
|
|
380
|
+
*
|
|
381
|
+
* Decision table:
|
|
382
|
+
*
|
|
383
|
+
* | firstInstall | isEnabledState | autoStartConfig | envDisable | action |
|
|
384
|
+
* |:------------:|:------------------:|:---------------:|:----------:|:--------------------|
|
|
385
|
+
* | any | any | any | true | 'skip' |
|
|
386
|
+
* | any | any | false | false | 'skip' |
|
|
387
|
+
* | true | 'not-found'/other | true | false | 'enable-and-start' |
|
|
388
|
+
* | false | 'enabled' | true | false | 'restart-if-changed'|
|
|
389
|
+
* | false | 'disabled' | true | false | 'leave-disabled' |
|
|
390
|
+
* | false | 'masked' | true | false | 'leave-disabled' |
|
|
391
|
+
* | false | other/unknown | true | false | 'enable-and-start' |
|
|
392
|
+
*
|
|
393
|
+
* @param {object} opts
|
|
394
|
+
* @param {boolean} opts.firstInstall - True when the unit/plist file did not
|
|
395
|
+
* exist before this postinstall run (i.e. `writeIfChanged` wrote a new file).
|
|
396
|
+
* @param {string} opts.isEnabledState - Output of `systemctl --user is-enabled
|
|
397
|
+
* <unit>` (or equivalent), e.g. 'enabled', 'disabled', 'masked',
|
|
398
|
+
* 'not-found', 'static', 'indirect', ''. On macOS use 'not-found' for a
|
|
399
|
+
* missing plist and 'enabled' for a loaded agent.
|
|
400
|
+
* @param {boolean} opts.autoStartConfig - Value of `daemon.autoStart` from the
|
|
401
|
+
* global config (via `readGlobalAutoStart()`). Default: true.
|
|
402
|
+
* @param {boolean} opts.envDisable - Whether `CLEO_DAEMON_DISABLE=1` is set.
|
|
403
|
+
*
|
|
404
|
+
* @returns {'enable-and-start' | 'restart-if-changed' | 'leave-disabled' | 'skip'}
|
|
405
|
+
*
|
|
406
|
+
* @task T11984
|
|
407
|
+
*/
|
|
408
|
+
export function decideDaemonAction({ firstInstall, isEnabledState, autoStartConfig, envDisable }) {
|
|
409
|
+
// Highest priority: env override or persistent config opt-out.
|
|
410
|
+
if (envDisable || autoStartConfig === false) {
|
|
411
|
+
return 'skip';
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// First install (unit file did not previously exist): safe to enable+start.
|
|
415
|
+
if (firstInstall) {
|
|
416
|
+
return 'enable-and-start';
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Upgrade path: inspect the current enabled state.
|
|
420
|
+
const state = (isEnabledState ?? '').trim().toLowerCase();
|
|
421
|
+
|
|
422
|
+
if (state === 'disabled' || state === 'masked') {
|
|
423
|
+
// Operator explicitly disabled the unit — honour that decision.
|
|
424
|
+
return 'leave-disabled';
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (state === 'enabled') {
|
|
428
|
+
// Already enabled; only restart if the unit content changed (handled by caller).
|
|
429
|
+
return 'restart-if-changed';
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Unknown / 'not-found' / 'static' / 'indirect' / empty — treat as first-like install.
|
|
433
|
+
return 'enable-and-start';
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// Linux — cleo.slice unit (T11993 · Epic T11992)
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Name of the systemd user slice unit (without .slice extension).
|
|
442
|
+
*
|
|
443
|
+
* The slice acts as a shared cgroup container for all cleo child scopes:
|
|
444
|
+
* cleo.slice
|
|
445
|
+
* ├── cleo-daemon.service (managed by the daemon service unit)
|
|
446
|
+
* └── cleo-<class>-<n>.scope (transient scopes from spawn-wrapper)
|
|
447
|
+
*
|
|
448
|
+
* IMPORTANT — `Delegate=` is FORBIDDEN on slices (only on services/scopes).
|
|
449
|
+
* Do NOT add a Delegate= directive here.
|
|
450
|
+
*/
|
|
451
|
+
const CLEO_SLICE_UNIT_NAME = 'cleo.slice';
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Absolute path to the cleo.slice unit file.
|
|
455
|
+
*
|
|
456
|
+
* Always co-located with the cleo-daemon.service unit under
|
|
457
|
+
* XDG_CONFIG_HOME/systemd/user/ (default: ~/.config/systemd/user/).
|
|
458
|
+
*
|
|
459
|
+
* @returns {string} Absolute path to the cleo.slice file.
|
|
460
|
+
*/
|
|
461
|
+
function getSystemdSliceFile() {
|
|
462
|
+
const paths = getPlatformPaths();
|
|
463
|
+
const configParent = join(paths.config, '..');
|
|
464
|
+
return join(configParent, 'systemd', 'user', CLEO_SLICE_UNIT_NAME);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Generate the cleo.slice unit file content.
|
|
469
|
+
*
|
|
470
|
+
* ## Staged P1 budget (data-safety decision — T11993 Amendment 1)
|
|
471
|
+
*
|
|
472
|
+
* P1 ships MemoryHigh DISABLED and MemoryMax=85% of the host MemTotal.
|
|
473
|
+
* A reclaim-stalled scope holding a SQLite WAL write-txn for > 30 s
|
|
474
|
+
* (busy_timeout) cascades SQLITE_BUSY across every slice member.
|
|
475
|
+
* MemoryHigh within 5 % of MemoryMax causes exactly this stall.
|
|
476
|
+
*
|
|
477
|
+
* P1 safe defaults:
|
|
478
|
+
* MemoryMax = 85% of MemTotal (hard cap, benign cgroup kill)
|
|
479
|
+
* MemoryHigh = (omitted — disabled)
|
|
480
|
+
*
|
|
481
|
+
* P2 target (after T11994 stall-escalator lands):
|
|
482
|
+
* MemoryMax = 85% of MemTotal
|
|
483
|
+
* MemoryHigh = 60% of MemTotal
|
|
484
|
+
*
|
|
485
|
+
* Both values are live-tunable via `systemctl --user set-property cleo.slice`
|
|
486
|
+
* without restarting any cleo process.
|
|
487
|
+
*
|
|
488
|
+
* NOTE: With MemorySwapMax=0 on child scopes, MemoryHigh over anonymous
|
|
489
|
+
* heaps ≈ MemoryMax (reclaim has only page-cache to chew). Admission (P2)
|
|
490
|
+
* is the primary control; throttling is not load-bearing in P1.
|
|
491
|
+
*
|
|
492
|
+
* @returns {string} Systemd slice unit content.
|
|
493
|
+
*/
|
|
494
|
+
function buildSliceUnit() {
|
|
495
|
+
// Read MemTotal from /proc/meminfo (Linux only — this function is only
|
|
496
|
+
// called on Linux so the readFileSync path is always reachable).
|
|
497
|
+
let memTotalKb = 0;
|
|
498
|
+
try {
|
|
499
|
+
const raw = readFileSync('/proc/meminfo', 'utf8');
|
|
500
|
+
const m = raw.match(/^MemTotal:\s+(\d+)\s+kB/m);
|
|
501
|
+
if (m && m[1]) memTotalKb = parseInt(m[1], 10);
|
|
502
|
+
} catch {
|
|
503
|
+
// ignore — fallback below
|
|
504
|
+
}
|
|
505
|
+
// Fallback to a conservative 32 GiB (= 33554432 kB) when /proc is absent.
|
|
506
|
+
if (!memTotalKb || memTotalKb <= 0) memTotalKb = 32 * 1024 * 1024;
|
|
507
|
+
|
|
508
|
+
// P1: MemoryMax = 85% of MemTotal, rounded to nearest MiB.
|
|
509
|
+
const memMaxMib = Math.round((memTotalKb * 0.85) / 1024);
|
|
510
|
+
// P2 target (document in comment; not emitted in P1):
|
|
511
|
+
// const memHighMib = Math.round((memTotalKb * 0.60) / 1024);
|
|
512
|
+
|
|
513
|
+
return `# cleo.slice — CLEO child-process cgroup slice (T11993 · Epic T11992)
|
|
514
|
+
#
|
|
515
|
+
# This file was generated by cleo's postinstall / doctor flow.
|
|
516
|
+
# Re-running 'cleo doctor' rewrites it only when content changes.
|
|
517
|
+
#
|
|
518
|
+
# All cleo child processes (gateway, studio, agents, tests) are placed
|
|
519
|
+
# under this slice via `systemd-run --user --slice=cleo.slice`.
|
|
520
|
+
#
|
|
521
|
+
# IMPORTANT: Delegate= is forbidden on slices (only valid on services/scopes).
|
|
522
|
+
#
|
|
523
|
+
# Staged budget — see T11993 for the rationale:
|
|
524
|
+
# P1 (current): MemoryMax=${memMaxMib}M (hard cap), MemoryHigh disabled
|
|
525
|
+
# P2 target: MemoryMax=${memMaxMib}M + MemoryHigh=${Math.round(memTotalKb * 0.60 / 1024)}M (60/85 shape)
|
|
526
|
+
# (requires T11994 stall-escalator to be safe)
|
|
527
|
+
#
|
|
528
|
+
# Live-tune without restart:
|
|
529
|
+
# systemctl --user set-property cleo.slice MemoryMax=<value>
|
|
530
|
+
# systemctl --user set-property cleo.slice MemoryHigh=<value>
|
|
531
|
+
[Unit]
|
|
532
|
+
Description=CLEO child-process slice (agents, gateway, studio, tools)
|
|
533
|
+
Documentation=https://github.com/kryptobaseddev/cleocode
|
|
534
|
+
|
|
535
|
+
[Slice]
|
|
536
|
+
# P1: hard cap only — MemoryHigh disabled until T11994 stall-escalator lands.
|
|
537
|
+
MemoryMax=${memMaxMib}M
|
|
538
|
+
# P2 (uncomment after T11994 ships):
|
|
539
|
+
# MemoryHigh=${Math.round(memTotalKb * 0.60 / 1024)}M
|
|
540
|
+
`;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Install the cleo.slice user unit idempotently.
|
|
545
|
+
*
|
|
546
|
+
* Writes the slice file only when content has changed (SHA-256 comparison),
|
|
547
|
+
* then calls `systemctl --user daemon-reload` to make systemd aware of it.
|
|
548
|
+
*
|
|
549
|
+
* SAFETY: does NOT touch or restart `cleo-daemon.service` — the operator's
|
|
550
|
+
* enabled/disabled state (T11984 decision table) is sacred.
|
|
551
|
+
*
|
|
552
|
+
* @task T11993
|
|
553
|
+
*/
|
|
554
|
+
function installSliceUnit() {
|
|
555
|
+
const sliceFile = getSystemdSliceFile();
|
|
556
|
+
const unit = buildSliceUnit();
|
|
557
|
+
const written = writeIfChanged(sliceFile, unit);
|
|
558
|
+
|
|
559
|
+
if (written) {
|
|
560
|
+
console.log(`CLEO: Wrote systemd slice unit → ${sliceFile}`);
|
|
561
|
+
const reload = runBin('systemctl', ['--user', 'daemon-reload']);
|
|
562
|
+
if (!reload.ok) {
|
|
563
|
+
console.log(`CLEO: systemctl daemon-reload skipped (${reload.output || 'no output'})`);
|
|
564
|
+
}
|
|
565
|
+
} else {
|
|
566
|
+
console.log('CLEO: cleo.slice already up-to-date — skipping write.');
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
311
570
|
// ---------------------------------------------------------------------------
|
|
312
571
|
// Linux — systemd user unit
|
|
313
572
|
// ---------------------------------------------------------------------------
|
|
@@ -375,8 +634,20 @@ function buildSystemdUnit(cleoExec, scope) {
|
|
|
375
634
|
// OOM-killed. systemd now stops restarting after 5 failures in 60 s.
|
|
376
635
|
// - NODE_OPTIONS=--max-old-space-size: a runaway daemon tick throws a
|
|
377
636
|
// recoverable single-process JS heap OOM instead of growing unbounded.
|
|
378
|
-
// - MemoryMax: best-effort soft cgroup ceiling (enforced only when the
|
|
379
|
-
// manager has memory-cgroup delegation; harmless otherwise).
|
|
637
|
+
// - MemoryMax=2G: best-effort soft cgroup ceiling (enforced only when the
|
|
638
|
+
// user manager has memory-cgroup delegation; harmless otherwise).
|
|
639
|
+
//
|
|
640
|
+
// Drop-in overrides (T11984 / DHQ-D):
|
|
641
|
+
// Operators may tighten MemoryMax — or any other [Service] directive —
|
|
642
|
+
// by placing a drop-in file at:
|
|
643
|
+
// ~/.config/systemd/user/cleo-daemon.service.d/10-memory-cap.conf
|
|
644
|
+
// Example content:
|
|
645
|
+
// [Service]
|
|
646
|
+
// MemoryHigh=768M
|
|
647
|
+
// MemoryMax=1G
|
|
648
|
+
// A drop-in's directives override the unit's values; re-running postinstall
|
|
649
|
+
// rewrites the BASE unit file but does NOT touch or remove any drop-ins.
|
|
650
|
+
// Run `systemctl --user daemon-reload` after adding/changing a drop-in.
|
|
380
651
|
return `[Unit]
|
|
381
652
|
Description=${description}
|
|
382
653
|
Documentation=https://github.com/kryptobaseddev/cleocode
|
|
@@ -403,12 +674,23 @@ WantedBy=default.target
|
|
|
403
674
|
/**
|
|
404
675
|
* Install and optionally activate the systemd user unit.
|
|
405
676
|
*
|
|
677
|
+
* Respects operator state (T11984): if the unit already exists and is
|
|
678
|
+
* currently disabled or masked, the postinstall hook does NOT re-enable it.
|
|
679
|
+
* The operator's explicit `systemctl --user disable cleo-daemon` survives
|
|
680
|
+
* upgrades. See `decideDaemonAction` for the full decision table.
|
|
681
|
+
*
|
|
406
682
|
* @param {string} cleoExec - Absolute path to the `cleo` binary.
|
|
407
683
|
* @param {{ scopeSagaId?: string; scopeEpicId?: string }} [scope] - Optional scope filter (T11497 AC3).
|
|
408
684
|
*/
|
|
409
685
|
function installSystemd(cleoExec, scope) {
|
|
686
|
+
// Install the cleo.slice unit first so child scopes can reference it.
|
|
687
|
+
// The slice write is idempotent and does NOT touch the daemon service.
|
|
688
|
+
installSliceUnit();
|
|
689
|
+
|
|
410
690
|
const unitFile = getSystemdUnitFile();
|
|
411
691
|
const unit = buildSystemdUnit(cleoExec, scope);
|
|
692
|
+
// firstInstall = true when the file did NOT exist before this write.
|
|
693
|
+
const fileExistedBefore = existsSync(unitFile);
|
|
412
694
|
const written = writeIfChanged(unitFile, unit);
|
|
413
695
|
|
|
414
696
|
if (written) {
|
|
@@ -422,14 +704,54 @@ function installSystemd(cleoExec, scope) {
|
|
|
422
704
|
console.log('CLEO: systemd unit already up-to-date — skipping write.');
|
|
423
705
|
}
|
|
424
706
|
|
|
425
|
-
|
|
707
|
+
// Determine the operator's current enabled state (needed for upgrade path).
|
|
708
|
+
const isEnabledResult = runBin('systemctl', ['--user', 'is-enabled', SYSTEMD_UNIT_NAME]);
|
|
709
|
+
const isEnabledState = isEnabledResult.output.trim();
|
|
710
|
+
|
|
711
|
+
const action = decideDaemonAction({
|
|
712
|
+
firstInstall: !fileExistedBefore,
|
|
713
|
+
isEnabledState,
|
|
714
|
+
autoStartConfig: readGlobalAutoStart(),
|
|
715
|
+
envDisable: process.env[DAEMON_DISABLE_ENV] === '1',
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
if (action === 'skip') {
|
|
719
|
+
const reason = process.env[DAEMON_DISABLE_ENV] === '1'
|
|
720
|
+
? `${DAEMON_DISABLE_ENV}=1 (CI/container path)`
|
|
721
|
+
: 'daemon.autoStart=false in global config';
|
|
426
722
|
console.log(
|
|
427
|
-
`CLEO:
|
|
723
|
+
`CLEO: Unit written but activation skipped (${reason}).`,
|
|
428
724
|
);
|
|
725
|
+
console.log("CLEO: To enable later: run 'cleo daemon enable' or 'systemctl --user enable --now cleo-daemon'");
|
|
429
726
|
return;
|
|
430
727
|
}
|
|
431
728
|
|
|
432
|
-
|
|
729
|
+
if (action === 'leave-disabled') {
|
|
730
|
+
console.log(
|
|
731
|
+
`CLEO: cleo-daemon left ${isEnabledState} (operator state respected — skipping re-enable).`,
|
|
732
|
+
);
|
|
733
|
+
console.log("CLEO: To re-enable: run 'cleo daemon enable' or 'systemctl --user enable --now cleo-daemon'");
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (action === 'restart-if-changed') {
|
|
738
|
+
if (written) {
|
|
739
|
+
// Unit content changed on an already-enabled service — restart to pick up changes.
|
|
740
|
+
const restart = runBin('systemctl', ['--user', 'restart', SYSTEMD_UNIT_NAME]);
|
|
741
|
+
if (restart.ok) {
|
|
742
|
+
console.log('CLEO: systemd user service restarted (unit updated).');
|
|
743
|
+
} else {
|
|
744
|
+
console.log(
|
|
745
|
+
`CLEO: systemctl restart skipped (${restart.output || 'systemctl unavailable'}).`,
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
} else {
|
|
749
|
+
console.log('CLEO: systemd unit unchanged and already enabled — no restart needed.');
|
|
750
|
+
}
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// action === 'enable-and-start': first install or unknown prior state.
|
|
433
755
|
const enable = runBin('systemctl', ['--user', 'enable', '--now', SYSTEMD_UNIT_NAME]);
|
|
434
756
|
if (enable.ok) {
|
|
435
757
|
console.log('CLEO: systemd user service enabled and started.');
|
|
@@ -540,15 +862,39 @@ function buildLaunchdPlist(cleoExec, scope) {
|
|
|
540
862
|
`;
|
|
541
863
|
}
|
|
542
864
|
|
|
865
|
+
/**
|
|
866
|
+
* Check whether the launchd agent is currently loaded (macOS).
|
|
867
|
+
*
|
|
868
|
+
* Uses `launchctl list <label>` — exit 0 with output = loaded; non-zero or
|
|
869
|
+
* empty = not loaded (disabled / never loaded).
|
|
870
|
+
*
|
|
871
|
+
* @returns {'enabled' | 'disabled' | 'not-found'} Launchd agent state.
|
|
872
|
+
*/
|
|
873
|
+
function getLaunchdEnabledState() {
|
|
874
|
+
const result = runBin('launchctl', ['list', LAUNCHD_PLIST_LABEL]);
|
|
875
|
+
if (result.ok && result.output.trim()) {
|
|
876
|
+
return 'enabled';
|
|
877
|
+
}
|
|
878
|
+
// Non-zero means 'not in the service manager' — treat same as 'disabled'.
|
|
879
|
+
return 'disabled';
|
|
880
|
+
}
|
|
881
|
+
|
|
543
882
|
/**
|
|
544
883
|
* Install and optionally load the launchd plist.
|
|
545
884
|
*
|
|
885
|
+
* Respects operator state (T11984): if the plist already exists and the agent
|
|
886
|
+
* is NOT currently loaded, the postinstall hook does NOT reload it. The
|
|
887
|
+
* operator's explicit `launchctl bootout` survives upgrades. See
|
|
888
|
+
* `decideDaemonAction` for the full decision table.
|
|
889
|
+
*
|
|
546
890
|
* @param {string} cleoExec - Absolute path to the `cleo` binary.
|
|
547
891
|
* @param {{ scopeSagaId?: string; scopeEpicId?: string }} [scope] - Optional scope filter (T11497 AC3).
|
|
548
892
|
*/
|
|
549
893
|
function installLaunchd(cleoExec, scope) {
|
|
550
894
|
const plistFile = getLaunchdPlistFile();
|
|
551
895
|
const plist = buildLaunchdPlist(cleoExec, scope);
|
|
896
|
+
// firstInstall = true when the file did NOT exist before this write.
|
|
897
|
+
const fileExistedBefore = existsSync(plistFile);
|
|
552
898
|
const written = writeIfChanged(plistFile, plist);
|
|
553
899
|
|
|
554
900
|
if (written) {
|
|
@@ -557,14 +903,55 @@ function installLaunchd(cleoExec, scope) {
|
|
|
557
903
|
console.log('CLEO: launchd plist already up-to-date — skipping write.');
|
|
558
904
|
}
|
|
559
905
|
|
|
560
|
-
|
|
906
|
+
// Determine the operator's current loaded state (needed for upgrade path).
|
|
907
|
+
const isEnabledState = fileExistedBefore ? getLaunchdEnabledState() : 'not-found';
|
|
908
|
+
|
|
909
|
+
const action = decideDaemonAction({
|
|
910
|
+
firstInstall: !fileExistedBefore,
|
|
911
|
+
isEnabledState,
|
|
912
|
+
autoStartConfig: readGlobalAutoStart(),
|
|
913
|
+
envDisable: process.env[DAEMON_DISABLE_ENV] === '1',
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
if (action === 'skip') {
|
|
917
|
+
const reason = process.env[DAEMON_DISABLE_ENV] === '1'
|
|
918
|
+
? `${DAEMON_DISABLE_ENV}=1 (CI/container path)`
|
|
919
|
+
: 'daemon.autoStart=false in global config';
|
|
920
|
+
console.log(
|
|
921
|
+
`CLEO: Plist written but activation skipped (${reason}).`,
|
|
922
|
+
);
|
|
923
|
+
console.log(`CLEO: To enable later: launchctl load "${plistFile}"`);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (action === 'leave-disabled') {
|
|
561
928
|
console.log(
|
|
562
|
-
|
|
929
|
+
'CLEO: cleo-daemon launchd agent left unloaded (operator state respected — skipping re-load).',
|
|
563
930
|
);
|
|
931
|
+
console.log(`CLEO: To re-enable: launchctl load "${plistFile}"`);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (action === 'restart-if-changed') {
|
|
936
|
+
const uid = process.getuid ? String(process.getuid()) : '';
|
|
937
|
+
if (written && uid) {
|
|
938
|
+
// Plist content changed on an already-loaded agent — reload to pick up changes.
|
|
939
|
+
runBin('launchctl', ['bootout', `gui/${uid}`, plistFile]);
|
|
940
|
+
const reload = runBin('launchctl', ['bootstrap', `gui/${uid}`, plistFile]);
|
|
941
|
+
if (reload.ok) {
|
|
942
|
+
console.log(`CLEO: launchd agent reloaded (plist updated, gui/${uid}).`);
|
|
943
|
+
} else {
|
|
944
|
+
console.log(
|
|
945
|
+
`CLEO: launchctl reload skipped (${reload.output || 'launchctl unavailable'}).`,
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
} else {
|
|
949
|
+
console.log('CLEO: launchd plist unchanged and already loaded — no reload needed.');
|
|
950
|
+
}
|
|
564
951
|
return;
|
|
565
952
|
}
|
|
566
953
|
|
|
567
|
-
//
|
|
954
|
+
// action === 'enable-and-start': first install or unknown prior state.
|
|
568
955
|
const uid = process.getuid ? String(process.getuid()) : '';
|
|
569
956
|
if (uid) {
|
|
570
957
|
const bootstrap = runBin('launchctl', [
|