atris 3.37.1 → 3.40.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.
Files changed (164) hide show
  1. package/AGENTS.md +25 -6
  2. package/README.md +2 -0
  3. package/atris/PERSONA.md +11 -11
  4. package/atris.md +17 -2
  5. package/ax +160 -34
  6. package/bin/atris.js +78 -6
  7. package/commands/activate.js +16 -3
  8. package/commands/agent-spawn.js +13 -18
  9. package/commands/autoland.js +76 -47
  10. package/commands/autopilot-front.js +0 -4
  11. package/commands/autopilot.js +0 -12
  12. package/commands/bench.js +10 -12
  13. package/commands/brain.js +0 -7
  14. package/commands/brief.js +0 -2
  15. package/commands/business-sync.js +0 -1
  16. package/commands/business.js +452 -12
  17. package/commands/chat-scan.js +5 -7
  18. package/commands/clean.js +31 -2
  19. package/commands/close.js +46 -11
  20. package/commands/cloud.js +0 -1
  21. package/commands/codex-goal.js +8 -12
  22. package/commands/computer.js +6 -5
  23. package/commands/console.js +64 -11
  24. package/commands/decide.js +166 -0
  25. package/commands/deck.js +30 -12
  26. package/commands/dream.js +0 -7
  27. package/commands/drill.js +14 -25
  28. package/commands/engine.js +206 -26
  29. package/commands/experiments.js +0 -4
  30. package/commands/fleet-report.js +12 -4
  31. package/commands/game.js +261 -0
  32. package/commands/gm.js +17 -7
  33. package/commands/goal.js +0 -6
  34. package/commands/harvest.js +1 -5
  35. package/commands/improve.js +29 -8
  36. package/commands/init.js +41 -37
  37. package/commands/integrations.js +0 -7
  38. package/commands/land.js +77 -20
  39. package/commands/launchpad.js +1 -16
  40. package/commands/lesson.js +17 -7
  41. package/commands/lifecycle.js +5 -5
  42. package/commands/log.js +55 -5
  43. package/commands/loop-front.js +0 -2
  44. package/commands/loops.js +6 -5
  45. package/commands/meet.js +0 -2
  46. package/commands/member.js +622 -573
  47. package/commands/mission.js +790 -314
  48. package/commands/next.js +0 -1
  49. package/commands/now.js +0 -12
  50. package/commands/one-lap.js +8 -8
  51. package/commands/orb.js +0 -4
  52. package/commands/pack-craft.js +0 -1
  53. package/commands/pack.js +3599 -424
  54. package/commands/play.js +6 -6
  55. package/commands/probe.js +12 -6
  56. package/commands/pulse.js +88 -64
  57. package/commands/push.js +0 -4
  58. package/commands/radar.js +14 -11
  59. package/commands/recover.js +340 -0
  60. package/commands/release.js +10 -9
  61. package/commands/report.js +0 -1
  62. package/commands/router.js +320 -0
  63. package/commands/run-front.js +0 -2
  64. package/commands/search.js +0 -3
  65. package/commands/site-deploy.js +870 -0
  66. package/commands/site.js +11 -2
  67. package/commands/skill.js +7 -2
  68. package/commands/slop.js +237 -7
  69. package/commands/status.js +6 -0
  70. package/commands/stream.js +4 -20
  71. package/commands/study.js +0 -3
  72. package/commands/sync-checkout.js +51 -0
  73. package/commands/sync.js +11 -40
  74. package/commands/task.js +999 -595
  75. package/commands/taste.js +101 -0
  76. package/commands/teach.js +86 -0
  77. package/commands/team.js +83 -3
  78. package/commands/unknowns.js +0 -10
  79. package/commands/update.js +0 -1
  80. package/commands/vercel.js +4 -2
  81. package/commands/visualize.js +0 -5
  82. package/commands/voice.js +195 -0
  83. package/commands/watch.js +1 -28
  84. package/commands/wiki.js +42 -15
  85. package/commands/wish.js +0 -1
  86. package/commands/workflow.js +2 -2
  87. package/commands/worktree.js +88 -28
  88. package/commands/xp.js +28 -30
  89. package/commands/youtube.js +0 -2
  90. package/lib/accept-verify-gate.js +126 -0
  91. package/lib/activity-stream.js +0 -2
  92. package/lib/arg-parser.js +41 -0
  93. package/lib/auto-accept-certified.js +261 -3
  94. package/lib/autoland.js +66 -3
  95. package/lib/ax-auto-lane.js +68 -7
  96. package/lib/bench/context.js +0 -2
  97. package/lib/bench/report.js +0 -1
  98. package/lib/bench/runner.js +18 -11
  99. package/lib/brief-ledger.js +0 -6
  100. package/lib/business-simulate.js +382 -0
  101. package/lib/chat-log-scan.js +0 -5
  102. package/lib/checkout-sync.js +52 -0
  103. package/lib/claude-boot-block.js +48 -0
  104. package/lib/cloud-mission.js +0 -6
  105. package/lib/codex-flight.js +0 -1
  106. package/lib/conductor-artifacts.js +25 -0
  107. package/lib/context-gatherer.js +7 -2
  108. package/lib/deck-compose.js +0 -1
  109. package/lib/deck-history.js +0 -1
  110. package/lib/deck-layout.js +0 -5
  111. package/lib/deck-review.js +0 -7
  112. package/lib/deck-schema.js +0 -1
  113. package/lib/default-verifier.js +0 -2
  114. package/lib/engine-registry.js +157 -28
  115. package/lib/experiments/daily.js +0 -6
  116. package/lib/falsifier-probe.js +84 -0
  117. package/lib/fleet.js +275 -42
  118. package/lib/functional-owner.js +0 -3
  119. package/lib/git-spawn.js +15 -0
  120. package/lib/inspect-fields.js +0 -1
  121. package/lib/json-file.js +37 -0
  122. package/lib/known-commands.js +2 -2
  123. package/lib/learnings.js +0 -2
  124. package/lib/lesson-preflight.js +146 -0
  125. package/lib/loop-doctor.js +11 -19
  126. package/lib/manifest.js +0 -2
  127. package/lib/member-alive.js +0 -1
  128. package/lib/member-switches.js +0 -5
  129. package/lib/mission-artifact.js +0 -1
  130. package/lib/mission-human-asks.js +28 -0
  131. package/lib/mission-protected-lane.js +296 -0
  132. package/lib/mission-room.js +0 -2
  133. package/lib/mission-runtime-loop.js +0 -1
  134. package/lib/next-moves.js +0 -18
  135. package/lib/official-cli-integration.js +47 -6
  136. package/lib/orb-context.js +14 -4
  137. package/lib/orb-scorecard.js +0 -1
  138. package/lib/outbound-send-gate.js +0 -6
  139. package/lib/pack-capabilities.js +685 -0
  140. package/lib/permission-grants.js +0 -6
  141. package/lib/policy-lessons.js +0 -2
  142. package/lib/pulse.js +68 -15
  143. package/lib/review-integrity.js +0 -2
  144. package/lib/router-brain.js +51 -3
  145. package/lib/runner-command.js +0 -10
  146. package/lib/runtime-bootstrap.js +0 -1
  147. package/lib/scorecard.js +0 -1
  148. package/lib/self-drive.js +45 -14
  149. package/lib/state-detection.js +0 -6
  150. package/lib/task-db.js +137 -7
  151. package/lib/task-decision.js +50 -0
  152. package/lib/taste-lessons.js +153 -0
  153. package/lib/team-presence.js +0 -1
  154. package/lib/tool-result-encode.js +17 -1
  155. package/lib/voice-gate.js +129 -2
  156. package/lib/wiki.js +152 -9
  157. package/lib/wish-audit.js +21 -7
  158. package/lib/wish-delegate.js +27 -21
  159. package/lib/wish-stats.js +0 -5
  160. package/lib/wish-store.js +0 -1
  161. package/lib/workspace-safety.js +0 -2
  162. package/lib/zip.js +95 -8
  163. package/package.json +2 -1
  164. package/templates/business-starter/persona.md +9 -0
package/commands/play.js CHANGED
@@ -5,6 +5,7 @@ const os = require('os');
5
5
  const fs = require('fs');
6
6
  const { spawnSync } = require('child_process');
7
7
  const { getSessionProfile, loadCredentials } = require('../utils/auth');
8
+ const { hasFlag: hasExactFlag } = require('../lib/arg-parser');
8
9
 
9
10
  const AGENTXP_LEADERBOARD_URL = 'https://api.atris.ai/api/agentxp/leaderboard';
10
11
  const AGENTXP_GLOBAL_SYNC_RULE = 'Run atris login, then sync. Owner-provided sync tokens are guided-demo fallback only.';
@@ -38,8 +39,9 @@ function flag(args, name) {
38
39
  return null;
39
40
  }
40
41
 
41
- function hasFlag(args, name) {
42
- return args.includes(name) || args.some(arg => arg.startsWith(`${name}=`));
42
+ // This command treats --name=value as flag presence as well as a standalone flag.
43
+ function hasFlagOrValue(args, name) {
44
+ return hasExactFlag(args, name) || args.some(arg => arg.startsWith(`${name}=`));
43
45
  }
44
46
 
45
47
  function positional(args) {
@@ -284,7 +286,7 @@ function handleSourceLine(state) {
284
286
  }
285
287
 
286
288
  function ensureStarterMission(taskDb, db, workspaceRoot, player, tasks, args = []) {
287
- if (hasFlag(args, '--no-seed')) return { tasks, seeded: null };
289
+ if (hasFlagOrValue(args, '--no-seed')) return { tasks, seeded: null };
288
290
  if (selectMission(tasks, player)) return { tasks, seeded: null };
289
291
  fs.mkdirSync(path.join(workspaceRoot, 'atris'), { recursive: true });
290
292
 
@@ -476,7 +478,7 @@ async function playCommand(...args) {
476
478
  }
477
479
 
478
480
  const state = modeState(args);
479
- if (hasFlag(args, '--json')) {
481
+ if (hasFlagOrValue(args, '--json')) {
480
482
  console.log(JSON.stringify(state, null, 2));
481
483
  return;
482
484
  }
@@ -485,6 +487,4 @@ async function playCommand(...args) {
485
487
 
486
488
  module.exports = {
487
489
  playCommand,
488
- modeState,
489
- selectMission,
490
490
  };
package/commands/probe.js CHANGED
@@ -26,9 +26,10 @@
26
26
 
27
27
  const https = require('https');
28
28
  const http = require('http');
29
+ const path = require('path');
29
30
  const { getApiBaseUrl } = require('../utils/api');
30
31
  const { loadCredentials } = require('../utils/auth');
31
- const { encodeToolResult } = require('../lib/tool-result-encode');
32
+ const { buildToolResultBody } = require('../lib/tool-result-encode');
32
33
 
33
34
  // G2's honest blocked message (tool_policy_bench.MAX_TURNS_EXHAUSTED_MESSAGE)
34
35
  // starts with this — an answer that is only this marker is a dead-end.
@@ -188,10 +189,13 @@ function runTerminal(base, token, command, businessId) {
188
189
  // command runs in the mission's OWN workspace directory instead of the hosted
189
190
  // ai-computer. Without this, a mission tick in a local workspace read the
190
191
  // remote computer's files and reported local members (e.g. maze) as missing.
191
- function runLocalTerminal(command, cwd) {
192
+ function runLocalTerminal(command, cwd, environment = process.env, pathPrefix = '') {
192
193
  const { exec } = require('child_process');
193
194
  const { envWithNodeDir } = require('../lib/spawn-env');
194
- const env = envWithNodeDir(process.env);
195
+ const baseEnv = envWithNodeDir(environment);
196
+ const env = pathPrefix
197
+ ? { ...baseEnv, PATH: `${pathPrefix}${path.delimiter}${baseEnv.PATH || ''}` }
198
+ : baseEnv;
195
199
  return new Promise((resolve) => {
196
200
  exec(command, { cwd, env, timeout: 60000, maxBuffer: 4 * 1024 * 1024, shell: '/bin/bash' }, (error, stdout, stderr) => {
197
201
  resolve({
@@ -237,6 +241,8 @@ async function runAtris2Turn(opts = {}) {
237
241
  connectTimeoutMs = 60000,
238
242
  signal = null,
239
243
  localCwd = null,
244
+ localEnv = null,
245
+ localPathPrefix = '',
240
246
  } = opts;
241
247
  const t0 = Date.now();
242
248
  const out = { ok: false, text: '', engine: null, tools_run: 0, cli_ops: [], unsupported: [], error: null, duration_ms: 0 };
@@ -322,7 +328,7 @@ async function runAtris2Turn(opts = {}) {
322
328
  unsupported.push(`file_op:${op}`);
323
329
  } else {
324
330
  const term = localCwd
325
- ? await runLocalTerminal(cmd, localCwd)
331
+ ? await runLocalTerminal(cmd, localCwd, localEnv || process.env, localPathPrefix)
326
332
  : await runTerminal(base, token, cmd, business);
327
333
  const ok = term.exit_code === 0;
328
334
  out = ok
@@ -341,7 +347,7 @@ async function runAtris2Turn(opts = {}) {
341
347
  unsupported.push(`cli_op:${op || '?'}`);
342
348
  } else {
343
349
  const term = localCwd
344
- ? await runLocalTerminal(cmd, localCwd)
350
+ ? await runLocalTerminal(cmd, localCwd, localEnv || process.env, localPathPrefix)
345
351
  : await runTerminal(base, token, cmd, business);
346
352
  out = atrisCliResult(cmd, term);
347
353
  cliOps.push(op || '?');
@@ -351,7 +357,7 @@ async function runAtris2Turn(opts = {}) {
351
357
  unsupported.push(`tool:${name || '?'}`);
352
358
  }
353
359
  await postJson(`${base}/atris2/turn/tool-result`, token,
354
- { call_id: ev.call_id || ev.id, result_base64: encodeToolResult(out) }, 30000);
360
+ buildToolResultBody(ev.call_id || ev.id, out), 30000);
355
361
  resetIdle();
356
362
  }).catch((e) => reject(e));
357
363
  } else if (et === 'result') {
package/commands/pulse.js CHANGED
@@ -2,16 +2,16 @@
2
2
 
3
3
  // `atris pulse` - the durable overnight self-improvement heartbeat for atris-cli.
4
4
  //
5
- // atris pulse tick run ONE self-improvement tick (what the cron calls)
5
+ // atris pulse tick run ONE self-improvement tick (what launchd calls)
6
6
  // atris pulse status liveness, reward sum, ghost-tick detection
7
- // atris pulse install write the OS-cron tick script + install the crontab line
8
- // atris pulse uninstall remove the crontab line
7
+ // atris pulse install write the tick script + install a launchd agent
8
+ // atris pulse uninstall remove the launchd agent
9
9
  // atris pulse run run N ticks in the foreground (manual / testing)
10
10
  //
11
11
  // One tick: lock -> 'started' receipt -> run mission engine -> verify -> write
12
12
  // 'finished' receipt (pulse_agi_loop_receipts.jsonl) + reward scorecard
13
13
  // (scorecards.jsonl, gated) -> release lock. The whole point is that this fires
14
- // from an OS cron, so it self-improves overnight without Claude Code open.
14
+ // from launchd, so it self-improves overnight without Claude Code open.
15
15
 
16
16
  const fs = require('fs');
17
17
  const os = require('os');
@@ -19,11 +19,10 @@ const path = require('path');
19
19
  const { spawnSync } = require('child_process');
20
20
  const pulse = require('../lib/pulse');
21
21
  const { DEFAULT_CLAUDE_RUNNER_MODEL } = require('../lib/runner-command');
22
+ const { hasFlag } = require('../lib/arg-parser');
22
23
 
23
- function hasFlag(args, name) {
24
- return args.includes(name);
25
- }
26
- function readFlag(args, name, fallback = null) {
24
+ // Preserve the existing rule that the next token is a value, even if it is a flag.
25
+ function readFollowingFlag(args, name, fallback = null) {
27
26
  const i = args.indexOf(name);
28
27
  if (i === -1 || i === args.length - 1) return fallback;
29
28
  return args[i + 1];
@@ -41,8 +40,8 @@ Durable overnight self-improvement heartbeat.
41
40
  Commands:
42
41
  tick Run one heartbeat tick
43
42
  status Show liveness, reward, and ghost-tick detection
44
- install Install the OS cron heartbeat
45
- uninstall Remove the OS cron heartbeat
43
+ install Install the launchd heartbeat
44
+ uninstall Remove the launchd heartbeat
46
45
  run Run bounded ticks in the foreground
47
46
 
48
47
  Options:
@@ -208,7 +207,7 @@ function tickCommand(args, root = process.cwd()) {
208
207
  const asJson = wantsJson(args);
209
208
  const noClaude = hasFlag(args, '--no-claude');
210
209
  const noVerify = hasFlag(args, '--no-verify');
211
- const verifyCmd = noVerify ? null : readFlag(args, '--verify', pulse.defaultVerifyCmd(root));
210
+ const verifyCmd = noVerify ? null : readFollowingFlag(args, '--verify', pulse.defaultVerifyCmd(root));
212
211
  const startedAt = Date.now();
213
212
 
214
213
  // Detect a previous tick that died mid-run before we take the lock.
@@ -298,7 +297,12 @@ function tickCommand(args, root = process.cwd()) {
298
297
  }
299
298
 
300
299
  const elapsedMs = Date.now() - startedAt;
301
- const reward = pulse.scoreTick({ verifyPassed: verify.passed, producedWork });
300
+ const reward = pulse.scoreTick({
301
+ verifyPassed: verify.passed,
302
+ producedWork,
303
+ actorOk: engine.ok,
304
+ actorReason: engine.reason,
305
+ });
302
306
  const changedTail = committed
303
307
  ? ' - committed'
304
308
  : (changedFiles.length ? ` - ${changedFiles.length} file(s) changed` : '');
@@ -492,13 +496,21 @@ function statusCommand(args, root = process.cwd(), options = {}) {
492
496
  const slot = pulse.resolvePulseSlot(root, options.homeDir ? { homeDir: options.homeDir } : {});
493
497
  const receipts = pulse.readPulseReceipts(root);
494
498
  const summary = pulse.summarizePulse(receipts);
495
- const installed = cronInstalled(slot.markers, options.spawnSync || spawnSync);
499
+ const label = `com.atris.pulse.${slot.marker.toLowerCase().replace(/^atris_pulse_/, '').replace(/[^a-z0-9]+/g, '-')}`;
500
+ const homeDir = options.homeDir || os.homedir();
501
+ const plistPath = path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`);
502
+ const legacyCronInstalled = cronInstalled(slot.markers, options.spawnSync || spawnSync);
503
+ const installed = fs.existsSync(plistPath) || legacyCronInstalled;
496
504
  const expiryBreadcrumb = installed ? null : readPulseExpiryBreadcrumb(root);
497
505
  const writeOutput = options.writeOutput || ((text) => process.stdout.write(text));
498
506
  const out = {
499
507
  ok: true,
500
508
  action: 'pulse_status',
501
509
  cron_installed: installed,
510
+ launchd_installed: fs.existsSync(plistPath),
511
+ legacy_cron_installed: legacyCronInstalled,
512
+ label,
513
+ plist_path: plistPath,
502
514
  expiry_breadcrumb: expiryBreadcrumb,
503
515
  state_home: slot.activeStateHome,
504
516
  marker: slot.activeMarker,
@@ -509,7 +521,7 @@ function statusCommand(args, root = process.cwd(), options = {}) {
509
521
  };
510
522
  if (!asJson) {
511
523
  writeOutput([
512
- `pulse: ${installed ? 'cron installed' : 'cron not installed (run: atris pulse install)'}`,
524
+ `pulse: ${installed ? 'scheduler installed' : 'scheduler not installed (run: atris pulse install)'}`,
513
525
  expiryBreadcrumb ? `heartbeat expired on ${expiryBreadcrumb.expired_at}; renew with: ${expiryBreadcrumb.renew_command}` : '',
514
526
  `state home: ${slot.activeStateHome} marker: ${slot.activeMarker}`,
515
527
  `ticks: ${summary.total_ticks} | reward: ${summary.reward_sum} | verify pass/fail: ${summary.verify_pass}/${summary.verify_fail}`,
@@ -564,7 +576,7 @@ function installCommand(args, root = process.cwd(), options = {}) {
564
576
  const commandSpawn = options.spawnSync || spawnSync;
565
577
  const writeOutput = options.writeOutput || ((text) => process.stdout.write(text));
566
578
  const slot = pulse.resolvePulseSlot(root, options.homeDir ? { homeDir: options.homeDir } : {});
567
- const cadenceInput = readFlag(args, '--cadence', pulse.DEFAULT_CADENCE_CRON);
579
+ const cadenceInput = readFollowingFlag(args, '--cadence', pulse.DEFAULT_CADENCE_CRON);
568
580
  let cron;
569
581
  try {
570
582
  cron = pulse.normalizeCronCadence(cadenceInput);
@@ -582,8 +594,8 @@ function installCommand(args, root = process.cwd(), options = {}) {
582
594
  let expiry;
583
595
  try {
584
596
  expiry = pulse.normalizeExpiryDuration({
585
- hours: readFlag(args, '--hours', null),
586
- days: readFlag(args, '--days', '7'),
597
+ hours: readFollowingFlag(args, '--hours', null),
598
+ days: readFollowingFlag(args, '--days', '7'),
587
599
  });
588
600
  } catch (error) {
589
601
  const out = {
@@ -591,23 +603,27 @@ function installCommand(args, root = process.cwd(), options = {}) {
591
603
  action: 'pulse_install',
592
604
  reason: 'invalid_expiry',
593
605
  detail: error && error.message ? error.message : String(error),
594
- hours: readFlag(args, '--hours', null),
595
- days: readFlag(args, '--days', '7'),
606
+ hours: readFollowingFlag(args, '--hours', null),
607
+ days: readFollowingFlag(args, '--days', '7'),
596
608
  };
597
609
  if (!asJson) process.stdout.write(`pulse install failed: ${out.detail}\n`);
598
610
  return emit(out, asJson);
599
611
  }
600
- const verifyCmd = readFlag(args, '--verify', pulse.defaultVerifyCmd(root));
601
- const model = readFlag(args, '--model', process.env.ATRIS_RUNNER_MODEL || process.env.ATRIS_CLAUDE_MODEL || DEFAULT_CLAUDE_RUNNER_MODEL);
602
- const runnerProfile = readFlag(args, '--runner-profile', process.env.ATRIS_RUNNER_PROFILE || '');
603
- const runnerBin = readFlag(args, '--runner-bin', process.env.ATRIS_RUNNER_BIN || process.env.ATRIS_CLAUDE_BIN || '');
604
- const runnerCommandTemplate = readFlag(args, '--runner-template', process.env.ATRIS_RUNNER_COMMAND_TEMPLATE || process.env.ATRIS_CLAUDE_COMMAND_TEMPLATE || '');
612
+ const verifyCmd = readFollowingFlag(args, '--verify', pulse.defaultVerifyCmd(root));
613
+ const model = readFollowingFlag(args, '--model', process.env.ATRIS_RUNNER_MODEL || process.env.ATRIS_CLAUDE_MODEL || DEFAULT_CLAUDE_RUNNER_MODEL);
614
+ const runnerProfile = readFollowingFlag(args, '--runner-profile', process.env.ATRIS_RUNNER_PROFILE || '');
615
+ const runnerBin = readFollowingFlag(args, '--runner-bin', process.env.ATRIS_RUNNER_BIN || process.env.ATRIS_CLAUDE_BIN || '');
616
+ const runnerCommandTemplate = readFollowingFlag(args, '--runner-template', process.env.ATRIS_RUNNER_COMMAND_TEMPLATE || process.env.ATRIS_CLAUDE_COMMAND_TEMPLATE || '');
605
617
  const deadlineEpoch = Math.floor(Date.now() / 1000) + expiry.seconds;
606
618
 
607
619
  fs.mkdirSync(slot.stateHome, { recursive: true });
608
620
  const scriptPath = path.join(slot.stateHome, 'tick.sh');
609
- // Resolve the real bin dirs the engine spawns by bare name, so cron's minimal
610
- // PATH doesn't silently break the worker spawn (claude lives in ~/.local/bin).
621
+ const label = `com.atris.pulse.${slot.marker.toLowerCase().replace(/^atris_pulse_/, '').replace(/[^a-z0-9]+/g, '-')}`;
622
+ const homeDir = options.homeDir || os.homedir();
623
+ const launchAgentsDir = path.join(homeDir, 'Library', 'LaunchAgents');
624
+ const plistPath = path.join(launchAgentsDir, `${label}.plist`);
625
+ // Resolve the real bin dirs the engine spawns by bare name, so launchd's
626
+ // minimal PATH doesn't break the worker spawn (claude lives in ~/.local/bin).
611
627
  const pathDirs = resolveEngineBinDirs([runnerBin], commandSpawn);
612
628
  const script = pulse.buildTickScript({
613
629
  root,
@@ -621,18 +637,26 @@ function installCommand(args, root = process.cwd(), options = {}) {
621
637
  runnerCommandTemplate,
622
638
  verifyCmd,
623
639
  pathDirs,
640
+ launchLabel: label,
641
+ launchPlistPath: plistPath,
624
642
  });
625
643
  fs.writeFileSync(scriptPath, script, { mode: 0o755 });
626
644
 
627
- const line = pulse.buildCrontabLine({ cron, scriptPath, marker: slot.marker });
628
- // Append our line to the existing crontab. Strip this repo's new marker and,
629
- // when the legacy tick.sh belongs to this root, strip the old single-slot marker too.
645
+ fs.mkdirSync(path.join(slot.stateHome, 'logs'), { recursive: true });
646
+ fs.mkdirSync(launchAgentsDir, { recursive: true });
647
+ fs.writeFileSync(plistPath, pulse.buildLaunchAgentPlist({ label, scriptPath, cron, stateHome: slot.stateHome }), 'utf8');
648
+
649
+ // Strip old cron entries while migrating existing installations.
630
650
  const existing = commandSpawn('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
631
651
  const prior = existing.status === 0 ? String(existing.stdout || '') : '';
632
652
  const cleaned = stripMarkedCrontabLines(prior, slot.markers);
633
- const next = `${cleaned ? cleaned + '\n' : ''}${line}\n`;
634
- const apply = commandSpawn('crontab', ['-'], { input: next, encoding: 'utf8', timeout: 10000 });
653
+ const legacyHad = slot.markers.some((marker) => prior.includes(marker));
654
+ if (existing.status === 0 && legacyHad) commandSpawn('crontab', ['-'], { input: cleaned ? `${cleaned}\n` : '', encoding: 'utf8', timeout: 10000 });
655
+ const uid = options.uid != null ? options.uid : process.getuid();
656
+ commandSpawn('launchctl', ['bootout', `gui/${uid}/${label}`], { encoding: 'utf8', timeout: 10000 });
657
+ const apply = commandSpawn('launchctl', ['bootstrap', `gui/${uid}`, plistPath], { encoding: 'utf8', timeout: 10000 });
635
658
  const expiryBreadcrumbCleared = apply.status === 0 && clearPulseExpiryBreadcrumb(root);
659
+ const cadenceMapping = pulse.launchIntervalFromCadence({ cron });
636
660
 
637
661
  const out = {
638
662
  ok: apply.status === 0,
@@ -641,7 +665,11 @@ function installCommand(args, root = process.cwd(), options = {}) {
641
665
  state_home: slot.stateHome,
642
666
  marker: slot.marker,
643
667
  legacy_migrated: slot.legacyMatches,
644
- crontab_line: line,
668
+ label,
669
+ plist_path: plistPath,
670
+ crontab_line: null,
671
+ removed_legacy_crontab: legacyHad,
672
+ cadence_fallback: cadenceMapping.fallback,
645
673
  cadence: cron,
646
674
  cadence_input: cadenceInput,
647
675
  expires_in_days: expiry.days,
@@ -656,46 +684,49 @@ function installCommand(args, root = process.cwd(), options = {}) {
656
684
  if (!asJson) {
657
685
  if (apply.status === 0) {
658
686
  writeOutput([
659
- `pulse installed. heartbeat fires '${cron}' against ${root}.`,
687
+ `pulse installed. launchd heartbeat fires '${cron}' against ${root}.`,
688
+ `label: ${label}`,
689
+ `plist: ${plistPath}`,
660
690
  `script: ${scriptPath}`,
691
+ cadenceMapping.fallback ? `cadence '${cron}' is not directly mappable; using 3600 seconds.` : '',
661
692
  `auto-expires in ${expiry.hours ? `${expiry.hours} hours` : `${expiry.days} days`}. stop early: atris pulse uninstall`,
662
- ].join('\n') + '\n');
693
+ ].filter(Boolean).join('\n') + '\n');
663
694
  } else {
664
- writeOutput(`pulse install failed to write crontab: ${apply.stderr || apply.status}\nscript written to ${scriptPath}; add this line to your crontab manually:\n${line}\n`);
695
+ writeOutput(`pulse install failed to bootstrap ${label}: ${apply.stderr || apply.status}\nplist: ${plistPath}\n`);
665
696
  }
666
697
  }
667
698
  return emit(out, asJson);
668
699
  }
669
700
 
670
- function uninstallCommand(args, root = process.cwd()) {
701
+ function uninstallCommand(args, root = process.cwd(), options = {}) {
671
702
  const asJson = wantsJson(args);
672
- const slot = pulse.resolvePulseSlot(root);
673
- const existing = spawnSync('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
674
- if (existing.status !== 0) {
675
- const out = {
676
- ok: true,
677
- action: 'pulse_uninstall',
678
- removed: false,
679
- reason: 'no_crontab',
680
- state_home: slot.activeStateHome,
681
- marker: slot.activeMarker,
682
- };
683
- if (!asJson) process.stdout.write('pulse: no crontab to clean.\n');
684
- return emit(out, asJson);
685
- }
703
+ const slot = pulse.resolvePulseSlot(root, options.homeDir ? { homeDir: options.homeDir } : {});
704
+ const commandSpawn = options.spawnSync || spawnSync;
705
+ const homeDir = options.homeDir || os.homedir();
706
+ const label = `com.atris.pulse.${slot.marker.toLowerCase().replace(/^atris_pulse_/, '').replace(/[^a-z0-9]+/g, '-')}`;
707
+ const plistPath = path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`);
708
+ const uid = options.uid != null ? options.uid : process.getuid();
709
+ commandSpawn('launchctl', ['bootout', `gui/${uid}/${label}`], { encoding: 'utf8', timeout: 10000 });
710
+ const plistHad = fs.existsSync(plistPath);
711
+ fs.rmSync(plistPath, { force: true });
712
+ const existing = commandSpawn('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
686
713
  const prior = String(existing.stdout || '');
687
714
  const had = slot.markers.some((marker) => prior.includes(marker));
688
715
  const cleaned = stripMarkedCrontabLines(prior, slot.markers);
689
- const apply = spawnSync('crontab', ['-'], { input: cleaned ? cleaned + '\n' : '', encoding: 'utf8', timeout: 10000 });
716
+ let apply = { status: 0 };
717
+ if (existing.status === 0 && had) apply = commandSpawn('crontab', ['-'], { input: cleaned ? cleaned + '\n' : '', encoding: 'utf8', timeout: 10000 });
690
718
  const out = {
691
719
  ok: apply.status === 0,
692
720
  action: 'pulse_uninstall',
693
- removed: had,
721
+ removed: plistHad || had,
722
+ label,
723
+ plist_path: plistPath,
724
+ removed_legacy_crontab: had,
694
725
  state_home: slot.activeStateHome,
695
726
  marker: slot.activeMarker,
696
727
  legacy_slot: slot.legacyMatches,
697
728
  };
698
- if (!asJson) process.stdout.write(had ? 'pulse uninstalled (crontab line removed).\n' : 'pulse: no heartbeat line found.\n');
729
+ if (!asJson) process.stdout.write(plistHad || had ? `pulse uninstalled (${label}).\n` : 'pulse: no heartbeat agent found.\n');
699
730
  return emit(out, asJson);
700
731
  }
701
732
 
@@ -703,7 +734,7 @@ function uninstallCommand(args, root = process.cwd()) {
703
734
 
704
735
  function runCommand(args, root = process.cwd()) {
705
736
  const asJson = wantsJson(args);
706
- const maxTicks = Math.max(1, Number(readFlag(args, '--max-ticks', '1')) || 1);
737
+ const maxTicks = Math.max(1, Number(readFollowingFlag(args, '--max-ticks', '1')) || 1);
707
738
  const passthrough = args.filter((a) => a !== '--max-ticks' && a !== String(maxTicks));
708
739
  const results = [];
709
740
  for (let i = 0; i < maxTicks; i++) {
@@ -717,6 +748,9 @@ function pulseCommand(argv = []) {
717
748
  const sub = argv[0];
718
749
  const rest = argv.slice(1);
719
750
  if (sub === '--help' || sub === '-h' || sub === 'help') return showHelp();
751
+ // `pulse <sub> --help` is a help request, not a run — never execute the
752
+ // subcommand (tick/status/etc. have real side effects) just to show usage.
753
+ if (rest.includes('--help') || rest.includes('-h')) return showHelp();
720
754
  switch (sub) {
721
755
  case 'tick':
722
756
  return tickCommand(rest);
@@ -740,21 +774,11 @@ function pulseCommand(argv = []) {
740
774
 
741
775
  module.exports = {
742
776
  pulseCommand,
743
- tickCommand,
744
777
  statusCommand,
745
778
  cronInstalled,
746
779
  crontabHasActiveMarker,
747
780
  pulseExpiryBreadcrumbPath,
748
- readPulseExpiryBreadcrumb,
749
- clearPulseExpiryBreadcrumb,
750
781
  installCommand,
751
- uninstallCommand,
752
782
  runCommand,
753
- runMissionEngine,
754
- runAutopilotTick,
755
- runEngine,
756
- gitChangedFiles,
757
- runVerify,
758
783
  refreshOrbPolicyLessons,
759
- stripMarkedCrontabLines,
760
784
  };
package/commands/push.js CHANGED
@@ -1055,7 +1055,6 @@ module.exports = {
1055
1055
  buildPushUploadBatches,
1056
1056
  shouldRetrySyncIndividually,
1057
1057
  resolvePushSourceDir,
1058
- canonicalWorkspaceRoot,
1059
1058
  basenameOfManifestPath,
1060
1059
  buildCloudHashMap,
1061
1060
  isBusinessWorkspaceRoot,
@@ -1066,9 +1065,6 @@ module.exports = {
1066
1065
  resolveChangedScopePaths,
1067
1066
  mergePushScopes,
1068
1067
  analyzePushSafety,
1069
- isNestedWorkspacePollutionPath,
1070
- isSyncReviewArtifactPath,
1071
- renderPushSafetyBlock,
1072
1068
  renderDriftBlock,
1073
1069
  needsForceBroadWorkspaceConfirm,
1074
1070
  promptConfirm,
package/commands/radar.js CHANGED
@@ -396,16 +396,21 @@ function loadLoop(missions, root, deps) {
396
396
  const events = readJsonLines(path.join(root, '.atris', 'state', 'mission_events.jsonl'), deps.readFile, deps.exists);
397
397
  const codexGoal = readJsonFile(path.join(root, '.atris', 'state', 'codex_goal.json'), deps, {}) || {};
398
398
  const tickEvents = events.filter(event => event.type === 'mission_tick');
399
+ // Live missions sit in planning/ready/running; counting only 'running'
400
+ // reported 0 while a worker was mid-flight (CLI-1183, seen live 2026-07-25).
401
+ const ACTIVE_MISSION_STATUSES = new Set(['planning', 'running', 'ready']);
402
+ const isActive = (mission) => ACTIVE_MISSION_STATUSES.has(String(mission.status || ''));
399
403
  return {
400
- running: missions.filter(mission => mission.status === 'running').length,
404
+ running: missions.filter(isActive).length,
405
+ paused: missions.filter(mission => mission.status === 'paused').length,
401
406
  always_on: missions.filter(mission => mission.always_on).length,
402
407
  stale: missions.filter(mission => mission.stale).length,
403
- no_verifier: missions.filter(mission => mission.status === 'running' && !mission.verifier).length,
408
+ no_verifier: missions.filter(mission => isActive(mission) && !mission.verifier).length,
404
409
  ticks: tickEvents.length,
405
410
  last_event_at: events[events.length - 1]?.at || null,
406
411
  codex_goal: codexGoal.goal?.objective || null,
407
412
  codex_goal_mission: codexGoal.goal?.mission_id || null,
408
- infinite_loop_risk: missions.some(mission => mission.stale || (mission.status === 'running' && !mission.verifier)),
413
+ infinite_loop_risk: missions.some(mission => mission.stale || (isActive(mission) && !mission.verifier)),
409
414
  };
410
415
  }
411
416
 
@@ -735,7 +740,11 @@ function summarize(tasks, missions, worktrees, agents) {
735
740
  review: count(tasks, t => t.status === 'review'),
736
741
  certifiedReview: count(tasks, t => t.status === 'review' && t.metadata && t.metadata.agent_certified),
737
742
  },
738
- missions: { running: count(missions, m => m.status === 'running'), stale: count(missions, m => m.stale) },
743
+ missions: {
744
+ running: count(missions, m => ['planning', 'running', 'ready'].includes(String(m.status || ''))),
745
+ paused: count(missions, m => m.status === 'paused'),
746
+ stale: count(missions, m => m.stale),
747
+ },
739
748
  worktrees: { total: worktrees.length, dirty: count(worktrees, w => Number(w.dirty) > 0) },
740
749
  };
741
750
  }
@@ -832,7 +841,7 @@ function renderRadar(data) {
832
841
  lines.push('');
833
842
  lines.push(`Agents: ${s.agents.active}/${s.agents.total} active`);
834
843
  lines.push(`Tasks: ${s.tasks.open} open, ${s.tasks.claimed} claimed, ${s.tasks.review} review (${s.tasks.certifiedReview} certified)`);
835
- lines.push(`Missions: ${s.missions.running} running, ${s.missions.stale} stale/no-verifier`);
844
+ lines.push(`Missions: ${s.missions.running} active, ${s.missions.paused || 0} paused, ${s.missions.stale} stale/no-verifier`);
836
845
  lines.push(`Worktrees: ${s.worktrees.total} registered, ${s.worktrees.dirty} dirty`);
837
846
  lines.push(`Next: ${data.next_action}`);
838
847
  if (data.os) {
@@ -1188,16 +1197,10 @@ function radarCommand(args = [], options = {}) {
1188
1197
  module.exports = {
1189
1198
  agentTypeForCommand,
1190
1199
  agentTopPayload,
1191
- branchIdentityAtCwd,
1192
1200
  collectRadar,
1193
- isInteractiveClaudeCommand,
1194
- loadAgentWorktreeSidecar,
1195
1201
  parsePsOutput,
1196
- parseSessionIdFromCommand,
1197
1202
  parseWorktrees,
1198
1203
  radarCommand,
1199
1204
  renderAgentTop,
1200
1205
  renderRadar,
1201
- resolveAgentTaskBinding,
1202
- taskForOwner,
1203
1206
  };