@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cleocode/cleo",
3
- "version": "2026.6.13",
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/animations": "2026.6.13",
34
- "@cleocode/cant": "2026.6.13",
35
- "@cleocode/core": "2026.6.13",
36
- "@cleocode/contracts": "2026.6.13",
37
- "@cleocode/caamp": "2026.6.13",
38
- "@cleocode/lafs": "2026.6.13",
39
- "@cleocode/nexus": "2026.6.13",
40
- "@cleocode/playbooks": "2026.6.13",
41
- "@cleocode/runtime": "2026.6.13",
42
- "@cleocode/paths": "2026.6.13",
43
- "@cleocode/worktree": "2026.6.13"
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
- /** Environment variable that disables daemon auto-start (CI/container path). */
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 user
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
- if (process.env[DAEMON_DISABLE_ENV] === '1') {
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: ${DAEMON_DISABLE_ENV}=1 — unit written but activation skipped (CI/container path).`,
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
- // Enable + start the service.
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
- if (process.env[DAEMON_DISABLE_ENV] === '1') {
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
- `CLEO: ${DAEMON_DISABLE_ENV}=1 — plist written but activation skipped (CI/container path).`,
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
- // Try bootstrap (macOS 10.13+) first; fall back to legacy launchctl load.
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', [