@cohortapp/agent-sdk 2.11.15 → 2.13.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/.env.example +37 -22
- package/README.md +2 -0
- package/bin/maestro.mjs +117 -39
- package/bin/maestro.test.mjs +175 -5
- package/docs/guides/front-door-session.md +313 -0
- package/docs/guides/mac-mini.md +100 -28
- package/docs/guides/org-onboarding.md +1 -1
- package/docs/guides/setup-wizard.md +9 -5
- package/docs/runbooks/cohort-cutover.md +11 -1
- package/docs/runbooks/mac-mini-bootstrap.md +38 -63
- package/lib/cadence-bus-requeue.test.mjs +83 -0
- package/lib/cadence-bus.mjs +43 -7
- package/lib/channels/inbox-item.mjs +59 -2
- package/lib/cli/board.mjs +285 -0
- package/lib/cli/board.test.mjs +227 -0
- package/lib/cli/design.mjs +185 -0
- package/lib/cli/design.test.mjs +270 -0
- package/lib/cli/doctor-checks.mjs +441 -0
- package/lib/cli/doctor-checks.test.mjs +336 -0
- package/lib/cli/global-setup-extras.mjs +454 -0
- package/lib/cli/global-setup-extras.test.mjs +462 -0
- package/lib/cli/inbox.mjs +304 -0
- package/lib/cli/inbox.test.mjs +230 -0
- package/lib/cli/session-ack.mjs +63 -0
- package/lib/cli/session-ack.test.mjs +63 -0
- package/lib/cli/session.mjs +760 -0
- package/lib/cli/session.test.mjs +613 -0
- package/lib/collective/global-config.mjs +209 -6
- package/lib/collective/global-config.test.mjs +145 -0
- package/lib/collective/global-skills.mjs +145 -0
- package/lib/collective/global-skills.test.mjs +126 -0
- package/lib/collective/presence.mjs +4 -3
- package/lib/collective/vendor-skills.mjs +305 -0
- package/lib/collective/vendor-skills.test.mjs +306 -0
- package/lib/comms/send-gate.mjs +115 -0
- package/lib/comms/send-gate.test.mjs +113 -0
- package/lib/design/design-md.mjs +793 -0
- package/lib/design/design-md.test.mjs +318 -0
- package/lib/design/fixtures/DESIGN.golden.md +238 -0
- package/lib/design/fixtures/PRODUCT.golden.md +67 -0
- package/lib/design/fixtures/foundation.json +133 -0
- package/lib/design/refresh-gate.mjs +154 -0
- package/lib/design/refresh-gate.test.mjs +144 -0
- package/lib/design/write.mjs +275 -0
- package/lib/design/write.test.mjs +241 -0
- package/lib/feature-init.mjs +2 -2
- package/lib/mcp/server.test.mjs +9 -4
- package/lib/model-router/spawn.test.mjs +21 -0
- package/lib/org/board-mine-cache.mjs +99 -0
- package/lib/org/board-mine-cache.test.mjs +53 -0
- package/lib/org/board.mjs +11 -0
- package/lib/org/board.test.mjs +11 -1
- package/lib/org/client.mjs +36 -0
- package/lib/org/client.test.mjs +46 -0
- package/lib/org/inbound/directedness.mjs +18 -2
- package/lib/org/inbound/directedness.test.mjs +58 -0
- package/lib/org/inbound/index.mjs +8 -1
- package/lib/org/inbound/index.test.mjs +22 -0
- package/lib/org/mesh-directives.test.mjs +110 -0
- package/lib/org/mesh.mjs +61 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +52 -0
- package/lib/org/protocol.test.mjs +12 -1
- package/lib/org/registry.mjs +3 -2
- package/lib/org/tool-surface.mjs +120 -0
- package/lib/org/tool-surface.test.mjs +118 -5
- package/lib/prompts/parallelism.mjs +79 -0
- package/lib/prompts/parallelism.test.mjs +177 -0
- package/lib/security/external-content.mjs +1 -1
- package/lib/security/external-content.test.mjs +17 -0
- package/lib/session/config.mjs +137 -0
- package/lib/session/config.test.mjs +92 -0
- package/lib/session/feed-core.mjs +229 -0
- package/lib/session/feed-core.test.mjs +198 -0
- package/lib/session/first-run.mjs +126 -0
- package/lib/session/first-run.test.mjs +121 -0
- package/lib/session/frontdoor.mjs +266 -0
- package/lib/session/frontdoor.test.mjs +205 -0
- package/lib/session/handoffs.mjs +295 -0
- package/lib/session/handoffs.test.mjs +183 -0
- package/lib/session/identity.mjs +220 -0
- package/lib/session/identity.test.mjs +180 -0
- package/lib/session/inbox-claims.mjs +434 -0
- package/lib/session/inbox-claims.test.mjs +286 -0
- package/lib/session/launch-args.mjs +161 -0
- package/lib/session/launch-args.test.mjs +157 -0
- package/lib/session/liveness.mjs +174 -0
- package/lib/session/liveness.test.mjs +100 -0
- package/lib/session/status-summary.mjs +172 -0
- package/lib/session/status-summary.test.mjs +118 -0
- package/lib/session-permissions.mjs +39 -3
- package/lib/session-permissions.test.mjs +20 -0
- package/lib/setup/claude-probe.mjs +161 -24
- package/lib/setup/claude-probe.test.mjs +187 -0
- package/lib/setup/sections/learning.mjs +2 -1
- package/lib/setup/sections/model.mjs +104 -24
- package/lib/setup/sections/model.test.mjs +240 -0
- package/lib/setup/sections/org.mjs +27 -2
- package/lib/setup/sections/org.test.mjs +35 -2
- package/lib/setup/sections/verify.mjs +5 -0
- package/lib/setup/state.mjs +30 -10
- package/lib/setup/state.test.mjs +24 -1
- package/lib/singleton.js +11 -3
- package/lib/singleton.test.mjs +16 -0
- package/lib/subagents/lock.mjs +1 -1
- package/lib/telemetry/collect.mjs +270 -6
- package/lib/telemetry/collect.test.mjs +196 -1
- package/lib/upgrade/global-refresh.mjs +108 -0
- package/lib/upgrade/global-refresh.test.mjs +65 -0
- package/lib/upgrade/launchd-reconcile.mjs +327 -0
- package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
- package/lib/upgrade/post-steps.mjs +151 -0
- package/lib/upgrade/post-steps.test.mjs +200 -0
- package/lib/upgrade/verify.mjs +215 -0
- package/lib/upgrade/verify.test.mjs +164 -0
- package/lib/voice/outbound.mjs +3 -2
- package/lib/voice/post-call-brief.mjs +2 -1
- package/lib/voice/session-rotation.mjs +6 -1
- package/lib/voice/session-rotation.test.mjs +114 -0
- package/package.json +3 -3
- package/plugins/maestro-skills/plugin.json +25 -1
- package/plugins/maestro-skills/skills/board-work.md +63 -0
- package/plugins/maestro-skills/skills/cohort-design.md +153 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
- package/plugins/maestro-skills/skills/main-session.md +102 -0
- package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
- package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
- package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
- package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
- package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
- package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
- package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
- package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
- package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
- package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
- package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
- package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
- package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
- package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
- package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
- package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
- package/scaffold/CLAUDE.md +24 -0
- package/scripts/ci/check-durable-write-seam.mjs +147 -0
- package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
- package/scripts/ci/check-skill-packs.mjs +388 -0
- package/scripts/ci/check-skill-packs.test.mjs +495 -0
- package/scripts/ci/check.mjs +6 -0
- package/scripts/collective/hook-runner.mjs +39 -4
- package/scripts/collective/hook-runner.test.mjs +85 -2
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
- package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
- package/scripts/daemon/agent-daemon.mjs +249 -10
- package/scripts/daemon/agent-daemon.test.mjs +73 -0
- package/scripts/daemon/assurance-e2e.test.mjs +141 -6
- package/scripts/daemon/assurance.mjs +461 -37
- package/scripts/daemon/assurance.test.mjs +408 -43
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +393 -0
- package/scripts/daemon/cadence-consumer.mjs +289 -89
- package/scripts/daemon/cadence-handlers.mjs +53 -0
- package/scripts/daemon/classifier.mjs +1 -1
- package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
- package/scripts/daemon/dispatcher.mjs +127 -19
- package/scripts/daemon/health.mjs +12 -1
- package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
- package/scripts/daemon/inbox-deferral.mjs +6 -0
- package/scripts/daemon/lib/self-echo.mjs +201 -0
- package/scripts/daemon/lib/self-echo.test.mjs +153 -0
- package/scripts/daemon/maestro-daemon.mjs +3 -0
- package/scripts/daemon/prompt-builder.mjs +19 -3
- package/scripts/daemon/responder.mjs +51 -40
- package/scripts/daemon/sdk-version.mjs +51 -0
- package/scripts/daemon/sdk-version.test.mjs +31 -0
- package/scripts/hooks/pre-send-audit.sh +97 -4
- package/scripts/hooks/pre-send-audit.test.mjs +140 -1
- package/scripts/local-triggers/autoupdate.sh +243 -19
- package/scripts/local-triggers/autoupdate.test.mjs +518 -0
- package/scripts/local-triggers/generate-plists.sh +24 -1
- package/scripts/local-triggers/generate-plists.test.mjs +49 -11
- package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
- package/scripts/org/send-orgmail.mjs +27 -3
- package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
- package/scripts/poller/slack-poller.mjs +13 -1
- package/scripts/poller/utils.mjs +46 -1
- package/scripts/poller-launchd/install.sh +19 -11
- package/scripts/poller-launchd/install.test.mjs +243 -0
- package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
- package/scripts/poller-launchd/migrate.sh +66 -0
- package/scripts/poller-launchd/poller.plist.template +4 -2
- package/scripts/session/feed.mjs +237 -0
- package/scripts/session/feed.test.mjs +196 -0
- package/scripts/session/supervisor-sh.test.mjs +218 -0
- package/scripts/session/supervisor.mjs +328 -0
- package/scripts/session/supervisor.sh +141 -0
- package/scripts/session/supervisor.test.mjs +482 -0
- package/scripts/setup/configure-macos.sh +250 -55
- package/scripts/setup/configure-macos.test.mjs +306 -0
- package/scripts/setup/init-agent.sh +112 -7
- package/scripts/setup/init-agent.test.mjs +220 -1
- package/scripts/vendor/skill-packs.mjs +354 -0
- package/scripts/vendor/sync-skill-packs.mjs +242 -0
- package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
- package/scripts/watchdog/memory-watchdog.sh +37 -1
- package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
- package/scripts/setup/boot-claude-session.sh +0 -94
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
* state: "active"|"idle"|"busy"|"error"|"offline",
|
|
13
13
|
* activity: string, // human-readable ("handling inbox" / "session: <taskClass>" / "idle")
|
|
14
14
|
* subAgentsRunning: number, // running claude child processes (best-effort)
|
|
15
|
-
* session: { id?, taskClass?, model? } | null,
|
|
15
|
+
* session: { id?, taskClass?, model? } | null, // the running WORK session; NULL when idle
|
|
16
|
+
* // (readers take null as "no work session" — keep it so).
|
|
17
|
+
* // hq validates this object STRICTLY: a key it does not
|
|
18
|
+
* // know rejects the whole beat, so nothing new goes here.
|
|
16
19
|
* machine: {
|
|
17
20
|
* // MOMENTARY — true at `ts`, expires with the snapshot
|
|
18
21
|
* loadavg: [n,n,n],
|
|
@@ -31,6 +34,25 @@
|
|
|
31
34
|
* device?: string, // "Mac mini M4 Pro"
|
|
32
35
|
* ram?: string, // "24 GB"
|
|
33
36
|
* cores?: number,
|
|
37
|
+
* // IDENTITY — where the seat is and what it runs (re-read every beat,
|
|
38
|
+
* // never served from the inventory cache: an autoupdate changes the
|
|
39
|
+
* // version and a tailnet re-join changes the IP without touching the chip)
|
|
40
|
+
* hostname?: string, // os.hostname()
|
|
41
|
+
* tailnetIp?: string, // `tailscale ip -4`, fail-open
|
|
42
|
+
* sdkVersion?: string, // installed @cohortapp/agent-sdk version — how a fleet upgrade is verified
|
|
43
|
+
* // FRONT DOOR — who receives inbound on this seat and whether it is alive.
|
|
44
|
+
* // Lives on `machine` (hq stores it as an OPEN record, so a new key lands
|
|
45
|
+
* // the moment the daemon emits it) and NOT on `session`, whose hq schema
|
|
46
|
+
* // is `.strict()` — an unknown key there rejects the whole beat and
|
|
47
|
+
* // silently drops the seat out of the mesh.
|
|
48
|
+
* frontDoor: "session"|"daemon", // the main session job, or the daemon's --print lane
|
|
49
|
+
* sessionLive: boolean, // state/session/heartbeat.json fresher than 90 s
|
|
50
|
+
* // LAST UPGRADE — the outcome autoupdate.sh recorded in
|
|
51
|
+
* // state/autoupdate/last.json (WP-M6); absent until a first attempt.
|
|
52
|
+
* // Also on `machine` (open record), so hq's fleet view can show it next
|
|
53
|
+
* // to sdkVersion without a schema change. `ok` is the attempt's verdict:
|
|
54
|
+
* // true = healthy on `to`; false = install failed or rolled back to `from`.
|
|
55
|
+
* upgrade?: { at: ISO8601, from: string, to: string, ok: boolean },
|
|
34
56
|
* },
|
|
35
57
|
* claudeAuth: "ok"|"relogin_required"|"unknown",
|
|
36
58
|
* alerts: [{ id, severity, kind, detail }],
|
|
@@ -60,9 +82,12 @@
|
|
|
60
82
|
|
|
61
83
|
import os from "node:os";
|
|
62
84
|
import { execFile } from "node:child_process";
|
|
63
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync
|
|
64
|
-
import { join, resolve } from "node:path";
|
|
85
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
86
|
+
import { join, resolve, dirname } from "node:path";
|
|
65
87
|
import { fileURLToPath } from "node:url";
|
|
88
|
+
import { createRequire } from "node:module";
|
|
89
|
+
import { writeJsonAtomic } from "../fs-atomic.mjs";
|
|
90
|
+
import { probeClaude as defaultProbeClaude } from "../setup/claude-probe.mjs";
|
|
66
91
|
|
|
67
92
|
import { list as listPresence } from "../collective/presence.mjs";
|
|
68
93
|
import { billableUsd } from "../cost/ledger-row.mjs";
|
|
@@ -383,6 +408,10 @@ export async function collectMachine(o = {}) {
|
|
|
383
408
|
if (inv.device) machine.device = inv.device;
|
|
384
409
|
if (inv.ram) machine.ram = inv.ram;
|
|
385
410
|
if (Number.isFinite(inv.cores)) machine.cores = inv.cores;
|
|
411
|
+
// IDENTITY (§3.8) — where the seat is and what it runs.
|
|
412
|
+
if (inv.hostname) machine.hostname = inv.hostname;
|
|
413
|
+
if (inv.tailnetIp) machine.tailnetIp = inv.tailnetIp;
|
|
414
|
+
if (inv.sdkVersion) machine.sdkVersion = inv.sdkVersion;
|
|
386
415
|
}
|
|
387
416
|
|
|
388
417
|
// spend24h — the agent's own rolling 24h LLM spend, off the cost ledger.
|
|
@@ -847,10 +876,17 @@ export async function collectInventory(o = {}) {
|
|
|
847
876
|
? join(resolve(o.agentRoot), "state", "telemetry", "inventory.json")
|
|
848
877
|
: null;
|
|
849
878
|
|
|
879
|
+
// IDENTITY — hostname / tailnetIp / sdkVersion — is re-read on every call and
|
|
880
|
+
// never cached: an autoupdate changes the version and a tailnet re-join
|
|
881
|
+
// changes the IP without touching the chip the cache is keyed on. This is the
|
|
882
|
+
// field the fleet view reads to confirm a rollout, so a stale answer here
|
|
883
|
+
// would hide exactly the thing it exists to show.
|
|
884
|
+
const identity = await collectIdentity(o, osImpl);
|
|
885
|
+
|
|
850
886
|
if (cachePath && o.inventoryCache !== false) {
|
|
851
887
|
const cached = safeReadJson(cachePath);
|
|
852
888
|
if (cached && cached.key === key && cached.inv && typeof cached.inv === "object") {
|
|
853
|
-
return cached.inv;
|
|
889
|
+
return { ...cached.inv, ...identity };
|
|
854
890
|
}
|
|
855
891
|
}
|
|
856
892
|
|
|
@@ -875,10 +911,152 @@ export async function collectInventory(o = {}) {
|
|
|
875
911
|
if (cachePath && Object.keys(inv).length > 0) {
|
|
876
912
|
try {
|
|
877
913
|
mkdirSync(join(resolve(o.agentRoot), "state", "telemetry"), { recursive: true });
|
|
878
|
-
|
|
914
|
+
writeJsonAtomic(cachePath, { key, inv });
|
|
879
915
|
} catch { /* cache is an optimisation; never fatal */ }
|
|
880
916
|
}
|
|
881
|
-
return inv;
|
|
917
|
+
return { ...inv, ...identity };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ---------------------------------------------------------------------------
|
|
921
|
+
// Identity — where the seat is and what it runs (hostname / tailnetIp / sdkVersion)
|
|
922
|
+
// ---------------------------------------------------------------------------
|
|
923
|
+
|
|
924
|
+
/** Where the tailscale CLI lives, in the order we try it. */
|
|
925
|
+
const TAILSCALE_BINS = Object.freeze([
|
|
926
|
+
"tailscale", // PATH (standalone / homebrew)
|
|
927
|
+
"/usr/local/bin/tailscale",
|
|
928
|
+
"/opt/homebrew/bin/tailscale",
|
|
929
|
+
"/Applications/Tailscale.app/Contents/MacOS/Tailscale", // App Store / .pkg install
|
|
930
|
+
]);
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* The machine's tailnet IPv4 via `tailscale ip -4`, trying each known binary
|
|
934
|
+
* location in turn. Fail-open → "" (no Tailscale, not joined, or the CLI is
|
|
935
|
+
* slow); the field simply drops out of the beat.
|
|
936
|
+
* @returns {Promise<string>}
|
|
937
|
+
*/
|
|
938
|
+
export async function collectTailnetIp(o = {}) {
|
|
939
|
+
const execFileImpl = o.execFile || execFile;
|
|
940
|
+
const timeoutMs = Number.isFinite(o.tailscaleTimeoutMs) ? o.tailscaleTimeoutMs : 2000;
|
|
941
|
+
for (const bin of o.tailscaleBins || TAILSCALE_BINS) {
|
|
942
|
+
const out = await execFileSafe(execFileImpl, bin, ["ip", "-4"], timeoutMs);
|
|
943
|
+
const m = String(out || "").match(/\b(\d{1,3}(?:\.\d{1,3}){3})\b/);
|
|
944
|
+
if (m) return m[1];
|
|
945
|
+
}
|
|
946
|
+
return "";
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* The installed @cohortapp/agent-sdk version. Two honest sources, in order:
|
|
951
|
+
* 1. the agent's own dependency — `require.resolve("@cohortapp/agent-sdk/package.json")`
|
|
952
|
+
* from `<agentRoot>/package.json` (the copy `maestro upgrade` installs
|
|
953
|
+
* from, so a rollout shows up here as soon as `npm install` lands);
|
|
954
|
+
* 2. the package this very file ships in — walk up from lib/telemetry/ to the
|
|
955
|
+
* nearest package.json NAMED @cohortapp/agent-sdk (a global install, or a
|
|
956
|
+
* `~/maestro` checkout running its own code).
|
|
957
|
+
* "" when neither resolves; the field drops out rather than lying.
|
|
958
|
+
* @param {string} [agentRoot]
|
|
959
|
+
* @returns {string}
|
|
960
|
+
*/
|
|
961
|
+
export function readSdkVersion(agentRoot) {
|
|
962
|
+
if (agentRoot) {
|
|
963
|
+
try {
|
|
964
|
+
const req = createRequire(join(resolve(agentRoot), "package.json"));
|
|
965
|
+
const pkg = JSON.parse(readFileSync(req.resolve("@cohortapp/agent-sdk/package.json"), "utf8"));
|
|
966
|
+
if (pkg && typeof pkg.version === "string") return pkg.version;
|
|
967
|
+
} catch { /* not a dependency of this agent — try the package we ship in */ }
|
|
968
|
+
}
|
|
969
|
+
try {
|
|
970
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
971
|
+
for (let i = 0; i < 6; i += 1) {
|
|
972
|
+
const p = join(dir, "package.json");
|
|
973
|
+
if (existsSync(p)) {
|
|
974
|
+
const pkg = safeReadJson(p);
|
|
975
|
+
if (pkg && pkg.name === "@cohortapp/agent-sdk" && typeof pkg.version === "string") return pkg.version;
|
|
976
|
+
}
|
|
977
|
+
const up = dirname(dir);
|
|
978
|
+
if (up === dir) break;
|
|
979
|
+
dir = up;
|
|
980
|
+
}
|
|
981
|
+
} catch { /* fall through to "" */ }
|
|
982
|
+
return "";
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** hostname / tailnetIp / sdkVersion — each independently fail-open. */
|
|
986
|
+
async function collectIdentity(o, osImpl) {
|
|
987
|
+
const out = {};
|
|
988
|
+
try {
|
|
989
|
+
const h = typeof osImpl.hostname === "function" ? String(osImpl.hostname() || "").trim() : "";
|
|
990
|
+
if (h) out.hostname = h;
|
|
991
|
+
} catch { /* no hostname */ }
|
|
992
|
+
if (o.tailnet !== false) {
|
|
993
|
+
try {
|
|
994
|
+
const ip = typeof o.tailnetIp === "string" ? o.tailnetIp : await collectTailnetIp(o);
|
|
995
|
+
if (ip) out.tailnetIp = ip;
|
|
996
|
+
} catch { /* no tailnetIp */ }
|
|
997
|
+
}
|
|
998
|
+
try {
|
|
999
|
+
const v = typeof o.sdkVersion === "string" ? o.sdkVersion : readSdkVersion(o.agentRoot);
|
|
1000
|
+
if (v) out.sdkVersion = v;
|
|
1001
|
+
} catch { /* no sdkVersion */ }
|
|
1002
|
+
return out;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// ---------------------------------------------------------------------------
|
|
1006
|
+
// Front door — is the main session job the one receiving inbound, and is it live?
|
|
1007
|
+
// ---------------------------------------------------------------------------
|
|
1008
|
+
|
|
1009
|
+
/** A main-session heartbeat older than this is not live. */
|
|
1010
|
+
export const SESSION_STALE_MS = 90_000;
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Pure: derive `{frontDoor, sessionLive}` from the heartbeat the main session's
|
|
1014
|
+
* feed writes every 15 s (`state/session/heartbeat.json` `{pid, ppid, sessionId,
|
|
1015
|
+
* name, ts, feedVersion}`). No heartbeat file at all means the session job is
|
|
1016
|
+
* not installed on this seat → the daemon is the front door. A file that exists
|
|
1017
|
+
* but is stale means the job IS the front door and is currently down (the
|
|
1018
|
+
* daemon falls back to its --print lane meanwhile) — a different fact, reported
|
|
1019
|
+
* as such.
|
|
1020
|
+
* @param {object|null} hb parsed heartbeat, or null when absent/unreadable
|
|
1021
|
+
* @param {number} nowMs
|
|
1022
|
+
* @param {{staleMs?:number}} [opts]
|
|
1023
|
+
* @returns {{frontDoor:"session"|"daemon", sessionLive:boolean}}
|
|
1024
|
+
*/
|
|
1025
|
+
export function sessionLiveness(hb, nowMs, opts = {}) {
|
|
1026
|
+
if (!hb || typeof hb !== "object") return { frontDoor: "daemon", sessionLive: false };
|
|
1027
|
+
const staleMs = Number.isFinite(opts.staleMs) ? opts.staleMs : SESSION_STALE_MS;
|
|
1028
|
+
const ts = typeof hb.ts === "number" ? hb.ts : Date.parse(hb.ts);
|
|
1029
|
+
const age = nowMs - ts;
|
|
1030
|
+
const live = Number.isFinite(age) && age >= 0 && age <= staleMs;
|
|
1031
|
+
return { frontDoor: "session", sessionLive: live };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Pure: the beat's `machine.upgrade` from state/autoupdate/last.json
|
|
1036
|
+
* (`{from,to,at,ok,healthy,reason}` as autoupdate.sh writes it). Only the four
|
|
1037
|
+
* fields the fleet view reads; anything malformed → null, and the field drops
|
|
1038
|
+
* out rather than lying.
|
|
1039
|
+
* @param {object|null} last
|
|
1040
|
+
* @returns {{at:string, from:string, to:string, ok:boolean}|null}
|
|
1041
|
+
*/
|
|
1042
|
+
export function upgradeSummary(last) {
|
|
1043
|
+
if (!last || typeof last !== "object") return null;
|
|
1044
|
+
const at = typeof last.at === "string" ? last.at : "";
|
|
1045
|
+
const to = typeof last.to === "string" ? last.to : "";
|
|
1046
|
+
if (!at || !to) return null;
|
|
1047
|
+
return { at, from: typeof last.from === "string" ? last.from : "", to, ok: last.ok === true };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** state/autoupdate/last.json, or null. */
|
|
1051
|
+
function readAutoupdateLast(agentRoot) {
|
|
1052
|
+
if (!agentRoot) return null;
|
|
1053
|
+
return safeReadJson(join(resolve(agentRoot), "state", "autoupdate", "last.json"));
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** Read state/session/heartbeat.json; null when absent or malformed. */
|
|
1057
|
+
function readSessionHeartbeat(agentRoot) {
|
|
1058
|
+
if (!agentRoot) return null;
|
|
1059
|
+
return safeReadJson(join(resolve(agentRoot), "state", "session", "heartbeat.json"));
|
|
882
1060
|
}
|
|
883
1061
|
|
|
884
1062
|
// ---------------------------------------------------------------------------
|
|
@@ -1004,10 +1182,71 @@ export function detectClaudeAuth(o = {}) {
|
|
|
1004
1182
|
const text = tailFile(file, 64 * 1024);
|
|
1005
1183
|
if (scanForRelogin(text)) { sawRelogin = true; break; }
|
|
1006
1184
|
}
|
|
1185
|
+
// A fresh auth failure in the logs is the strongest evidence there is: it
|
|
1186
|
+
// means a real spawn was just refused, whatever `claude auth status` thinks.
|
|
1007
1187
|
if (sawRelogin) return "relogin_required";
|
|
1188
|
+
|
|
1189
|
+
// Subscription mode: ask the SAME probe setup/doctor use (§3.9), so the beat,
|
|
1190
|
+
// the wizard and the doctor cannot disagree about one machine. "unknown"
|
|
1191
|
+
// from the probe (CLI missing/slow) defers to the log evidence — and so does
|
|
1192
|
+
// an UNVERIFIED "ok": `claude auth status` says loggedIn for any token value,
|
|
1193
|
+
// so only a verified ok (a real call) may flip this beat from unknown to ok.
|
|
1194
|
+
// The one state claudeAuth exists to catch — a seat whose token is garbage —
|
|
1195
|
+
// is thus reported exactly as it was before the probe existed: by the logs.
|
|
1196
|
+
const probed = probeAuthForBeat(o, nowMs);
|
|
1197
|
+
if (probed === "ok" || probed === "relogin_required") return probed;
|
|
1198
|
+
|
|
1008
1199
|
return sawAny ? "ok" : "unknown";
|
|
1009
1200
|
}
|
|
1010
1201
|
|
|
1202
|
+
/**
|
|
1203
|
+
* Module-scoped memo for the beat's auth probe. WHY: `collectStatus` runs on
|
|
1204
|
+
* every 30 s presence beat and the probe forks `claude auth status`; one fork a
|
|
1205
|
+
* beat is affordable but pointless — the answer changes on the order of days.
|
|
1206
|
+
* The memo holds ONE entry per agentRoot for AUTH_PROBE_TTL_MS and applies only
|
|
1207
|
+
* to the default probe (an injected `o.probeClaude` is never memoised, so tests
|
|
1208
|
+
* and callers that want a fresh answer get one). Concurrency: single-threaded
|
|
1209
|
+
* per process; a stale read across two daemons is harmless (same answer).
|
|
1210
|
+
* Reset seam: `_internals.resetAuthProbeCache()`.
|
|
1211
|
+
*/
|
|
1212
|
+
const AUTH_PROBE_TTL_MS = 5 * 60 * 1000;
|
|
1213
|
+
const _authProbeMemo = new Map();
|
|
1214
|
+
function resetAuthProbeCache() { _authProbeMemo.clear(); }
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Only fork the probe when the agent is on the subscription path — the api-key
|
|
1218
|
+
* path is curl-pinged by doctor, not by the beat. `.env` or the daemon's
|
|
1219
|
+
* ambient env (`o.env`, default process.env — launchd hands the headless seat
|
|
1220
|
+
* its token that way) both count, as they do for the probe itself.
|
|
1221
|
+
*/
|
|
1222
|
+
function subscriptionModeFromEnv(agentRoot, env) {
|
|
1223
|
+
let body = "";
|
|
1224
|
+
try { body = readFileSync(join(resolve(agentRoot), ".env"), "utf8"); } catch { body = ""; /* no .env → the ambient env decides */ }
|
|
1225
|
+
if (/^CLAUDE_CODE_OAUTH_TOKEN=.+/m.test(body) || /^MAESTRO_PREFER_SUBSCRIPTION_AUTH=(1|true|yes)\s*$/im.test(body)) return true;
|
|
1226
|
+
const e = env || {};
|
|
1227
|
+
return !!(e.CLAUDE_CODE_OAUTH_TOKEN && String(e.CLAUDE_CODE_OAUTH_TOKEN).trim()) || /^(1|true|yes)$/i.test(String(e.MAESTRO_PREFER_SUBSCRIPTION_AUTH || "").trim());
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function probeAuthForBeat(o, nowMs) {
|
|
1231
|
+
if (o.probeClaude === false) return "unknown";
|
|
1232
|
+
const agentRoot = resolve(o.agentRoot);
|
|
1233
|
+
if (!subscriptionModeFromEnv(agentRoot, o.env || process.env)) return "unknown";
|
|
1234
|
+
const injected = typeof o.probeClaude === "function";
|
|
1235
|
+
if (!injected) {
|
|
1236
|
+
const hit = _authProbeMemo.get(agentRoot);
|
|
1237
|
+
if (hit && nowMs - hit.at < AUTH_PROBE_TTL_MS && nowMs >= hit.at) return hit.auth;
|
|
1238
|
+
}
|
|
1239
|
+
let auth = "unknown";
|
|
1240
|
+
try {
|
|
1241
|
+
const r = (injected ? o.probeClaude : defaultProbeClaude)(agentRoot);
|
|
1242
|
+
// A reported-but-unverified ok is NOT evidence (see detectClaudeAuth).
|
|
1243
|
+
if (r && r.auth === "relogin_required") auth = "relogin_required";
|
|
1244
|
+
else if (r && r.auth === "ok" && r.verified === true) auth = "ok";
|
|
1245
|
+
} catch { auth = "unknown"; /* the probe is diagnostic; never fail the beat */ }
|
|
1246
|
+
if (!injected) _authProbeMemo.set(agentRoot, { at: nowMs, auth });
|
|
1247
|
+
return auth;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1011
1250
|
function scanForRelogin(text) {
|
|
1012
1251
|
const lower = String(text || "").toLowerCase();
|
|
1013
1252
|
return RELOGIN_FINGERPRINTS.some((f) => lower.includes(f));
|
|
@@ -1111,6 +1350,26 @@ export async function collectStatus(o = {}) {
|
|
|
1111
1350
|
if (claudeAuth === "relogin_required") state = "error";
|
|
1112
1351
|
else if (state === "idle" && subAgentsRunning > 0) state = "busy";
|
|
1113
1352
|
|
|
1353
|
+
// 4b. front door (§3.8) — always present, even when no work session runs,
|
|
1354
|
+
// because "who receives inbound and is it alive" is a fact about the SEAT,
|
|
1355
|
+
// not about a task in flight. It therefore rides `machine`: hq's
|
|
1356
|
+
// presence.beat validator keeps `session` strict (`{id,taskClass,model}`)
|
|
1357
|
+
// and `machine` open, so this is the only place a new seat-level fact can
|
|
1358
|
+
// land without a lock-step hq deploy. `session` stays `null` when idle.
|
|
1359
|
+
let door = { frontDoor: "daemon", sessionLive: false };
|
|
1360
|
+
try {
|
|
1361
|
+
const hb = opt.heartbeat !== undefined ? opt.heartbeat : readSessionHeartbeat(opt.agentRoot);
|
|
1362
|
+
door = sessionLiveness(hb, nowMs, { staleMs: opt.sessionStaleMs });
|
|
1363
|
+
} catch { /* defaults: daemon front door, not live */ }
|
|
1364
|
+
machine = { ...machine, ...door };
|
|
1365
|
+
|
|
1366
|
+
// 4c. last upgrade outcome (WP-M6) — `machine.upgrade`, absent until the
|
|
1367
|
+
// first autoupdate attempt; a corrupt file drops the field, never the beat.
|
|
1368
|
+
try {
|
|
1369
|
+
const up = upgradeSummary(opt.autoupdateLast !== undefined ? opt.autoupdateLast : readAutoupdateLast(opt.agentRoot));
|
|
1370
|
+
if (up) machine.upgrade = up;
|
|
1371
|
+
} catch { /* no upgrade field */ }
|
|
1372
|
+
|
|
1114
1373
|
const status = {
|
|
1115
1374
|
state,
|
|
1116
1375
|
activity: act.activity || "idle",
|
|
@@ -1147,7 +1406,12 @@ export const _internals = {
|
|
|
1147
1406
|
parseSystemProfilerHardware,
|
|
1148
1407
|
ramLabel,
|
|
1149
1408
|
collectSpend24h,
|
|
1409
|
+
collectTailnetIp,
|
|
1410
|
+
readSdkVersion,
|
|
1411
|
+
sessionLiveness,
|
|
1412
|
+
upgradeSummary,
|
|
1150
1413
|
detectClaudeAuth,
|
|
1414
|
+
resetAuthProbeCache,
|
|
1151
1415
|
scanForRelogin,
|
|
1152
1416
|
countSubAgents,
|
|
1153
1417
|
execFileSafe,
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { test } from "node:test";
|
|
14
14
|
import assert from "node:assert/strict";
|
|
15
|
-
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
15
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs";
|
|
16
16
|
import { tmpdir } from "node:os";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
parseSystemProfilerHardware,
|
|
33
33
|
ramLabel,
|
|
34
34
|
collectSpend24h,
|
|
35
|
+
collectInventory,
|
|
35
36
|
_internals,
|
|
36
37
|
} from "./collect.mjs";
|
|
37
38
|
|
|
@@ -738,3 +739,197 @@ test("memUsedPct ignores an out-of-range memory_pressure reading", async () => {
|
|
|
738
739
|
const m = await collectMachine({ execFile, os: osImpl, platform: "darwin" });
|
|
739
740
|
assert.equal(m.memUsedPct, 60, "a nonsense percentage falls back rather than propagating");
|
|
740
741
|
});
|
|
742
|
+
|
|
743
|
+
// ---------------------------------------------------------------------------
|
|
744
|
+
// Fleet identity + front door (§3.8): hostname / tailnetIp / sdkVersion ride
|
|
745
|
+
// the inventory; machine.frontDoor + machine.sessionLive come off the main
|
|
746
|
+
// session's heartbeat file. This is how a fleet-wide upgrade is verified.
|
|
747
|
+
//
|
|
748
|
+
// WHY `machine` and not `session`: hq's presence.beat validator is
|
|
749
|
+
// `session: z.object({id,taskClass,model}).strict()` — an unknown key there
|
|
750
|
+
// rejects the WHOLE beat (BAD_REQUEST before any write) and the seat drops out
|
|
751
|
+
// of the mesh. `machine` is `z.record(z.unknown())` by design, so a new field
|
|
752
|
+
// lands the moment the daemon emits it. `session` stays `null` when no work
|
|
753
|
+
// session runs — consumers read null as "no work session", and that contract
|
|
754
|
+
// is pinned here.
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
|
|
757
|
+
test("collectInventory emits hostname, tailnetIp (tailscale ip -4) and sdkVersion", async () => {
|
|
758
|
+
const osImpl = fakeOs({ cpus: 12, totalmem: 24 * 1024 ** 3 });
|
|
759
|
+
osImpl.hostname = () => "ethans-mac-mini.local";
|
|
760
|
+
const inv = await collectInventory({
|
|
761
|
+
os: osImpl,
|
|
762
|
+
execFile: fakeExecFile({ "/usr/sbin/system_profiler": SP_HARDWARE, tailscale: "100.95.116.68\n" }),
|
|
763
|
+
sdkVersion: "2.12.0",
|
|
764
|
+
});
|
|
765
|
+
assert.equal(inv.hostname, "ethans-mac-mini.local");
|
|
766
|
+
assert.equal(inv.tailnetIp, "100.95.116.68");
|
|
767
|
+
assert.equal(inv.sdkVersion, "2.12.0");
|
|
768
|
+
assert.equal(inv.device, "Mac mini M4 Pro");
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
test("collectInventory falls back to the Tailscale.app binary and is fail-open without any", async () => {
|
|
772
|
+
const osImpl = fakeOs({});
|
|
773
|
+
osImpl.hostname = () => "h";
|
|
774
|
+
const appBin = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
775
|
+
const viaApp = await collectInventory({
|
|
776
|
+
os: osImpl, sdkVersion: "x",
|
|
777
|
+
execFile: fakeExecFile({ [appBin]: "100.68.98.127\nfd7a::1\n" }),
|
|
778
|
+
inventoryCache: false,
|
|
779
|
+
});
|
|
780
|
+
assert.equal(viaApp.tailnetIp, "100.68.98.127");
|
|
781
|
+
const none = await collectInventory({ os: osImpl, sdkVersion: "x", execFile: fakeExecFile({}), inventoryCache: false });
|
|
782
|
+
assert.equal(none.tailnetIp, undefined, "no tailscale → the field drops out, nothing throws");
|
|
783
|
+
assert.equal(none.hostname, "h");
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("sdkVersion is read from the installed @cohortapp/agent-sdk package.json, not from the cache", async () => {
|
|
787
|
+
const root = mkdtempSync(join(tmpdir(), "inv-sdk-"));
|
|
788
|
+
try {
|
|
789
|
+
const osImpl = fakeOs({});
|
|
790
|
+
osImpl.hostname = () => "h";
|
|
791
|
+
const first = await collectInventory({ agentRoot: root, os: osImpl, execFile: fakeExecFile({}), sdkVersion: "2.11.17" });
|
|
792
|
+
assert.equal(first.sdkVersion, "2.11.17");
|
|
793
|
+
// Same chip/RAM key → the device inventory is served from the cache, but the
|
|
794
|
+
// version (which an autoupdate changes without touching the hardware) and
|
|
795
|
+
// the tailnet IP are re-read every time.
|
|
796
|
+
const second = await collectInventory({ agentRoot: root, os: osImpl, execFile: fakeExecFile({ tailscale: "100.1.1.1\n" }), sdkVersion: "2.12.0" });
|
|
797
|
+
assert.equal(second.sdkVersion, "2.12.0");
|
|
798
|
+
assert.equal(second.tailnetIp, "100.1.1.1");
|
|
799
|
+
assert.equal(second.hostname, "h");
|
|
800
|
+
// The real resolver: this checkout IS the SDK, so it reports its own version.
|
|
801
|
+
const real = _internals.readSdkVersion();
|
|
802
|
+
assert.match(real, /^\d+\.\d+\.\d+/);
|
|
803
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test("sessionLiveness: a heartbeat younger than 90 s is live; older, missing or malformed is not", () => {
|
|
807
|
+
const { sessionLiveness } = _internals;
|
|
808
|
+
const now = Date.parse("2026-09-08T12:00:00Z");
|
|
809
|
+
assert.deepEqual(sessionLiveness({ ts: "2026-09-08T11:59:30Z", pid: 1, sessionId: "s" }, now), { frontDoor: "session", sessionLive: true });
|
|
810
|
+
assert.deepEqual(sessionLiveness({ ts: "2026-09-08T11:58:29Z" }, now), { frontDoor: "session", sessionLive: false });
|
|
811
|
+
assert.deepEqual(sessionLiveness({ ts: now - 89_000 }, now), { frontDoor: "session", sessionLive: true });
|
|
812
|
+
assert.deepEqual(sessionLiveness(null, now), { frontDoor: "daemon", sessionLive: false });
|
|
813
|
+
assert.deepEqual(sessionLiveness({ ts: "not a date" }, now), { frontDoor: "session", sessionLive: false });
|
|
814
|
+
assert.deepEqual(sessionLiveness({ ts: "2026-09-08T11:59:30Z" }, now, { staleMs: 10_000 }), { frontDoor: "session", sessionLive: false });
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test("collectStatus: machine carries frontDoor + sessionLive from state/session/heartbeat.json; session stays null when idle", async () => {
|
|
818
|
+
const root = mkdtempSync(join(tmpdir(), "front-door-"));
|
|
819
|
+
try {
|
|
820
|
+
const now = Date.parse("2026-09-08T12:00:00Z");
|
|
821
|
+
const osImpl = fakeOs({});
|
|
822
|
+
osImpl.hostname = () => "seat.local";
|
|
823
|
+
const base = { agentRoot: root, now, os: osImpl, execFile: fakeExecFile({}), subAgentsRunning: 0, powermetrics: false, disk: false, spend: false };
|
|
824
|
+
// No heartbeat file: the daemon is the front door. No work session → null.
|
|
825
|
+
const noHb = await collectStatus(base);
|
|
826
|
+
assert.equal(noHb.machine.frontDoor, "daemon");
|
|
827
|
+
assert.equal(noHb.machine.sessionLive, false);
|
|
828
|
+
assert.equal(noHb.session, null, "an idle seat beats session: null (hq's strict session schema; activity.mjs reads null as 'no work session')");
|
|
829
|
+
// Fresh heartbeat → live main session.
|
|
830
|
+
mkdirSync(join(root, "state", "session"), { recursive: true });
|
|
831
|
+
writeFileSync(join(root, "state", "session", "heartbeat.json"), JSON.stringify({ pid: 4242, sessionId: "abc", name: "ethan-main", ts: "2026-09-08T11:59:50Z" }));
|
|
832
|
+
const live = await collectStatus(base);
|
|
833
|
+
assert.equal(live.machine.frontDoor, "session");
|
|
834
|
+
assert.equal(live.machine.sessionLive, true);
|
|
835
|
+
assert.equal(live.session, null);
|
|
836
|
+
// Stale (> 90 s) → not live, but the front door is still the session job.
|
|
837
|
+
writeFileSync(join(root, "state", "session", "heartbeat.json"), JSON.stringify({ pid: 4242, ts: "2026-09-08T11:50:00Z" }));
|
|
838
|
+
const stale = await collectStatus(base);
|
|
839
|
+
assert.equal(stale.machine.frontDoor, "session");
|
|
840
|
+
assert.equal(stale.machine.sessionLive, false);
|
|
841
|
+
// A running work session keeps EXACTLY the keys hq's strict validator knows.
|
|
842
|
+
const busy = await collectStatus({ ...base, sessions: [{ sessionId: "s1", kind: "interactive", intent: "session: inbox", model: "sonnet", lastBeat: "2026-09-08T11:59:55Z" }] });
|
|
843
|
+
assert.equal(busy.session.id, "s1");
|
|
844
|
+
assert.equal(busy.session.taskClass, "inbox");
|
|
845
|
+
const allowed = new Set(["id", "taskClass", "model"]);
|
|
846
|
+
for (const k of Object.keys(busy.session)) assert.ok(allowed.has(k), `session.${k} would be rejected by hq's strict presence.beat schema`);
|
|
847
|
+
assert.equal(busy.machine.frontDoor, "session");
|
|
848
|
+
// Inventory identity rides machine.
|
|
849
|
+
assert.equal(typeof live.machine.hostname, "string");
|
|
850
|
+
assert.match(live.machine.sdkVersion, /^\d+\.\d+\.\d+/);
|
|
851
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
test("detectClaudeAuth: in subscription mode the shared probe decides; logs still win for relogin", () => {
|
|
855
|
+
const root = mkdtempSync(join(tmpdir(), "auth-probe-"));
|
|
856
|
+
try {
|
|
857
|
+
writeFileSync(join(root, ".env"), "CLAUDE_CODE_OAUTH_TOKEN=t\nMAESTRO_PREFER_SUBSCRIPTION_AUTH=1\n");
|
|
858
|
+
const calls = [];
|
|
859
|
+
// An UNVERIFIED "ok" (the CLI merely reports a login — it says loggedIn for
|
|
860
|
+
// any token value) must not flip the beat from unknown to ok: that is the
|
|
861
|
+
// one state claudeAuth exists to catch. Only a verified ok is an ok.
|
|
862
|
+
const probeClaude = (agentRoot) => { calls.push(agentRoot); return { ok: true, mode: "subscription", auth: "ok", verified: false, name: "x" }; };
|
|
863
|
+
assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW, probeClaude, env: {} }), "unknown", "reported-not-verified ok + no logs → unknown, as before the probe existed");
|
|
864
|
+
assert.deepEqual(calls, [root]);
|
|
865
|
+
assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW, env: {}, probeClaude: () => ({ ok: true, auth: "ok", verified: true }) }), "ok");
|
|
866
|
+
// The probe says relogin → relogin, even with no logs at all.
|
|
867
|
+
assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW, env: {}, probeClaude: () => ({ ok: false, auth: "relogin_required" }) }), "relogin_required");
|
|
868
|
+
// Probe unknown → fall back to what the logs say (nothing here → unknown).
|
|
869
|
+
assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW, env: {}, probeClaude: () => ({ ok: true, auth: "unknown" }) }), "unknown");
|
|
870
|
+
// Clean recent logs + an unverified ok → ok (the logs are the evidence, as before).
|
|
871
|
+
mkdirSync(join(root, "logs", "sessions"), { recursive: true });
|
|
872
|
+
const p = join(root, "logs", "sessions", "s.log");
|
|
873
|
+
writeFileSync(p, "spawn ok, 3 turns, done");
|
|
874
|
+
const t = new Date(NOW - 60_000);
|
|
875
|
+
utimesSync(p, t, t);
|
|
876
|
+
assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW, probeClaude, env: {} }), "ok");
|
|
877
|
+
// A recent relogin fingerprint in the logs wins over a probe that says ok.
|
|
878
|
+
writeFileSync(p, "… OAuth token has expired …");
|
|
879
|
+
utimesSync(p, t, t);
|
|
880
|
+
assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW, probeClaude, env: {} }), "relogin_required");
|
|
881
|
+
// Not in subscription mode → the probe is never forked from the beat.
|
|
882
|
+
writeFileSync(join(root, ".env"), "ANTHROPIC_API_KEY=sk\n");
|
|
883
|
+
const before = calls.length;
|
|
884
|
+
detectClaudeAuth({ agentRoot: root, now: NOW, probeClaude, env: {} });
|
|
885
|
+
assert.equal(calls.length, before);
|
|
886
|
+
// A token only in the daemon's ambient env (launchd) is subscription mode too
|
|
887
|
+
// (clean logs first — a relogin fingerprint decides before any probe runs).
|
|
888
|
+
writeFileSync(p, "spawn ok"); utimesSync(p, t, t);
|
|
889
|
+
writeFileSync(join(root, ".env"), "");
|
|
890
|
+
detectClaudeAuth({ agentRoot: root, now: NOW, probeClaude, env: { CLAUDE_CODE_OAUTH_TOKEN: "t" } });
|
|
891
|
+
assert.equal(calls.length, before + 1);
|
|
892
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
// ---------------------------------------------------------------------------
|
|
896
|
+
// Last upgrade outcome (WP-M6): machine.upgrade = {at, from, to, ok} from
|
|
897
|
+
// state/autoupdate/last.json — fail-open, absent until a first attempt.
|
|
898
|
+
// ---------------------------------------------------------------------------
|
|
899
|
+
|
|
900
|
+
test("upgradeSummary: the four fleet-view fields from autoupdate's last.json; malformed → null", () => {
|
|
901
|
+
const { upgradeSummary } = _internals;
|
|
902
|
+
assert.deepEqual(
|
|
903
|
+
upgradeSummary({ from: "2.11.17", to: "2.12.0", at: "2026-09-08T12:00:00Z", ok: true, healthy: true, reason: "" }),
|
|
904
|
+
{ at: "2026-09-08T12:00:00Z", from: "2.11.17", to: "2.12.0", ok: true },
|
|
905
|
+
);
|
|
906
|
+
assert.deepEqual(
|
|
907
|
+
upgradeSummary({ from: "2.11.17", to: "2.12.0", at: "2026-09-08T12:00:00Z", ok: false, healthy: true, reason: "unhealthy-rolled-back" }),
|
|
908
|
+
{ at: "2026-09-08T12:00:00Z", from: "2.11.17", to: "2.12.0", ok: false },
|
|
909
|
+
);
|
|
910
|
+
assert.equal(upgradeSummary(null), null);
|
|
911
|
+
assert.equal(upgradeSummary({ ok: true }), null, "no at/to → nothing to report");
|
|
912
|
+
assert.equal(upgradeSummary("2.12.0"), null);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
test("collectStatus: machine.upgrade rides the beat when state/autoupdate/last.json exists, and is absent (not null) when it does not or is corrupt", async () => {
|
|
916
|
+
const root = mkdtempSync(join(tmpdir(), "tel-upgrade-"));
|
|
917
|
+
try {
|
|
918
|
+
const base = { agentRoot: root, os: fakeOs(), execFile: fakeExecFile({}), powermetrics: false, disk: false, spend: false, inventory: false, tailnet: false, sdkVersion: "2.12.0", withAlerts: false, now: Date.parse("2026-09-08T12:00:00Z") };
|
|
919
|
+
const none = await collectStatus(base);
|
|
920
|
+
assert.ok(!("upgrade" in none.machine), "absent until a first autoupdate attempt");
|
|
921
|
+
|
|
922
|
+
mkdirSync(join(root, "state", "autoupdate"), { recursive: true });
|
|
923
|
+
writeFileSync(join(root, "state", "autoupdate", "last.json"), JSON.stringify({ from: "2.11.17", to: "2.12.0", at: "2026-09-08T11:30:00Z", ok: true, healthy: true, reason: "" }));
|
|
924
|
+
const some = await collectStatus(base);
|
|
925
|
+
assert.deepEqual(some.machine.upgrade, { at: "2026-09-08T11:30:00Z", from: "2.11.17", to: "2.12.0", ok: true });
|
|
926
|
+
assert.equal(some.session, null, "nothing new lands on the strictly-validated session object");
|
|
927
|
+
|
|
928
|
+
writeFileSync(join(root, "state", "autoupdate", "last.json"), "{not json");
|
|
929
|
+
const corrupt = await collectStatus(base);
|
|
930
|
+
assert.ok(!("upgrade" in corrupt.machine));
|
|
931
|
+
|
|
932
|
+
const injected = await collectStatus({ ...base, autoupdateLast: { from: "1", to: "2", at: "2026-09-08T00:00:00Z", ok: false } });
|
|
933
|
+
assert.equal(injected.machine.upgrade.ok, false);
|
|
934
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
935
|
+
});
|