@cohortapp/agent-sdk 2.12.0 → 2.14.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/bin/maestro.mjs +6 -2
- package/docs/guides/front-door-session.md +86 -0
- package/lib/cli/design.mjs +185 -0
- package/lib/cli/design.test.mjs +270 -0
- package/lib/cli/global-setup-extras.mjs +44 -0
- package/lib/cli/global-setup-extras.test.mjs +95 -0
- package/lib/cli/session.mjs +11 -1
- package/lib/cli/session.test.mjs +17 -6
- package/lib/collective/global-config.mjs +5 -0
- package/lib/collective/global-config.test.mjs +5 -0
- package/lib/collective/vendor-skills.mjs +305 -0
- package/lib/collective/vendor-skills.test.mjs +306 -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/prompts/parallelism.mjs +79 -0
- package/lib/prompts/parallelism.test.mjs +177 -0
- package/lib/telemetry/collect.mjs +357 -5
- package/lib/telemetry/collect.test.mjs +285 -0
- package/package.json +1 -1
- package/plugins/maestro-skills/plugin.json +4 -0
- package/plugins/maestro-skills/skills/cohort-design.md +153 -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/scripts/ci/check-skill-packs.mjs +388 -0
- package/scripts/ci/check-skill-packs.test.mjs +495 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
- package/scripts/daemon/agent-daemon.mjs +108 -0
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +61 -2
- package/scripts/daemon/cadence-consumer.mjs +46 -22
- package/scripts/daemon/prompt-builder.mjs +19 -3
- package/scripts/local-triggers/autoupdate.test.mjs +33 -3
- 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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parallelism.test.mjs — the fleet-wide parallelism directive (WP-M7 mechanic 5).
|
|
3
|
+
*
|
|
4
|
+
* Three things are pinned here, and each has a distinct failure it prevents:
|
|
5
|
+
*
|
|
6
|
+
* 1. THE BYTES. The directive is quoted verbatim in the design spec and is the
|
|
7
|
+
* one place a fleet-wide behaviour change is auditable. A silent reword —
|
|
8
|
+
* dropping the file-scope sentence, say — would turn "maximum safe
|
|
9
|
+
* parallelism" into two agents editing one file. So the expected text is
|
|
10
|
+
* written out longhand below, NOT derived from the constant.
|
|
11
|
+
* 2. IDEMPOTENCE. Two seams apply it (`session spawn` and the daemon's
|
|
12
|
+
* prompt-builder) and they compose. A doubled directive reads as emphasis
|
|
13
|
+
* to a model, which is exactly how the safety sentence loses to the
|
|
14
|
+
* permission sentence.
|
|
15
|
+
* 3. BOTH WIRINGS. A constant nothing calls is worth nothing; the two seams
|
|
16
|
+
* are driven for real here.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { test } from "node:test";
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
|
|
22
|
+
import { PARALLELISM_DIRECTIVE, withParallelism, countParallelism } from "./parallelism.mjs";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The directive, longhand. Deliberately NOT built from the import: a test that
|
|
26
|
+
* compares a constant to itself proves nothing about the wording.
|
|
27
|
+
*/
|
|
28
|
+
const EXPECTED = [
|
|
29
|
+
"You are allowed to run multiple sub-agents concurrently.",
|
|
30
|
+
"",
|
|
31
|
+
"When the plan contains independent tasks, dispatch them together in a single parallel batch rather than waiting for each task to finish before starting the next.",
|
|
32
|
+
"",
|
|
33
|
+
"Only run tasks sequentially when one task genuinely depends on the output of another.",
|
|
34
|
+
"",
|
|
35
|
+
"Before dispatching any batch, check the planned file scope for each task and ensure that no two agents are assigned to edit the same file. If tasks would overlap on a file, sequence those tasks or redefine their scope to eliminate the conflict.",
|
|
36
|
+
"",
|
|
37
|
+
"Default to maximum safe parallelism.",
|
|
38
|
+
].join("\n");
|
|
39
|
+
|
|
40
|
+
test("PARALLELISM_DIRECTIVE is the spec's text, byte for byte", () => {
|
|
41
|
+
assert.equal(PARALLELISM_DIRECTIVE, EXPECTED);
|
|
42
|
+
// The two sentences that carry the whole point, named individually so a
|
|
43
|
+
// partial reword fails with a readable diff rather than one giant blob.
|
|
44
|
+
assert.match(PARALLELISM_DIRECTIVE, /single parallel batch/);
|
|
45
|
+
assert.match(PARALLELISM_DIRECTIVE, /no two agents are assigned to edit the same file/);
|
|
46
|
+
assert.ok(!PARALLELISM_DIRECTIVE.endsWith("\n"), "no trailing newline — the joiner adds the separator");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("withParallelism prepends once and is idempotent", () => {
|
|
50
|
+
const prompt = "Reply to the message below.";
|
|
51
|
+
const once = withParallelism(prompt);
|
|
52
|
+
assert.ok(once.startsWith(PARALLELISM_DIRECTIVE), "the directive leads");
|
|
53
|
+
assert.ok(once.endsWith(prompt), "the caller's prompt survives intact");
|
|
54
|
+
assert.equal(countParallelism(once), 1);
|
|
55
|
+
|
|
56
|
+
const twice = withParallelism(once);
|
|
57
|
+
assert.equal(twice, once, "a second application changes nothing");
|
|
58
|
+
assert.equal(countParallelism(twice), 1);
|
|
59
|
+
|
|
60
|
+
const thrice = withParallelism(withParallelism(withParallelism(prompt)));
|
|
61
|
+
assert.equal(countParallelism(thrice), 1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("withParallelism tolerates an empty or non-string prompt", () => {
|
|
65
|
+
assert.equal(withParallelism(""), PARALLELISM_DIRECTIVE);
|
|
66
|
+
assert.equal(withParallelism(" \n "), PARALLELISM_DIRECTIVE);
|
|
67
|
+
assert.equal(withParallelism(undefined), PARALLELISM_DIRECTIVE);
|
|
68
|
+
assert.equal(withParallelism(null), PARALLELISM_DIRECTIVE);
|
|
69
|
+
assert.equal(withParallelism(42), PARALLELISM_DIRECTIVE);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a prompt that already carries the directive mid-body is not re-led", () => {
|
|
73
|
+
const embedded = `Context first.\n\n${PARALLELISM_DIRECTIVE}\n\nThen the task.`;
|
|
74
|
+
assert.equal(withParallelism(embedded), embedded);
|
|
75
|
+
assert.equal(countParallelism(withParallelism(embedded)), 1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("countParallelism counts non-overlapping occurrences", () => {
|
|
79
|
+
assert.equal(countParallelism(""), 0);
|
|
80
|
+
assert.equal(countParallelism(null), 0);
|
|
81
|
+
assert.equal(countParallelism("nothing here"), 0);
|
|
82
|
+
assert.equal(countParallelism(`${PARALLELISM_DIRECTIVE}\n\n${PARALLELISM_DIRECTIVE}`), 2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── the two wired seams ─────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
test("maestro session spawn leads every peer prompt with the directive, exactly once", async () => {
|
|
88
|
+
const { buildSpawnArgs } = await import("../cli/session.mjs");
|
|
89
|
+
const prompt = "Sweep the backlog and file what you find.";
|
|
90
|
+
const args = buildSpawnArgs({ first: "nova", slug: "sweep", prompt, env: {}, allowedTools: [] });
|
|
91
|
+
|
|
92
|
+
assert.equal(args[0], "--name");
|
|
93
|
+
assert.equal(args[1], "nova-sweep");
|
|
94
|
+
const sent = args[args.length - 1];
|
|
95
|
+
assert.ok(sent.startsWith(PARALLELISM_DIRECTIVE), "the peer's prompt is led by the directive");
|
|
96
|
+
assert.ok(sent.endsWith(prompt));
|
|
97
|
+
assert.equal(countParallelism(sent), 1);
|
|
98
|
+
|
|
99
|
+
// The composing case: a prompt that already carries it is not doubled.
|
|
100
|
+
const pre = withParallelism(prompt);
|
|
101
|
+
const again = buildSpawnArgs({ first: "nova", slug: "sweep", prompt: pre, env: {}, allowedTools: [] });
|
|
102
|
+
assert.equal(countParallelism(again[again.length - 1]), 1);
|
|
103
|
+
assert.equal(again[again.length - 1], pre);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("the daemon's prompt-builder leads every sub-session prompt with the directive, exactly once", async (t) => {
|
|
107
|
+
const { mkdtempSync, mkdirSync, writeFileSync } = await import("node:fs");
|
|
108
|
+
const { join } = await import("node:path");
|
|
109
|
+
const { tmpdir } = await import("node:os");
|
|
110
|
+
|
|
111
|
+
const root = mkdtempSync(join(tmpdir(), "maestro-parallelism-prompt-"));
|
|
112
|
+
mkdirSync(join(root, "config"), { recursive: true });
|
|
113
|
+
writeFileSync(join(root, "config", "agent.json"), JSON.stringify({ firstName: "Nova" }));
|
|
114
|
+
const prevAgentDir = process.env.AGENT_DIR;
|
|
115
|
+
process.env.AGENT_DIR = root;
|
|
116
|
+
t.after(() => {
|
|
117
|
+
if (prevAgentDir === undefined) delete process.env.AGENT_DIR;
|
|
118
|
+
else process.env.AGENT_DIR = prevAgentDir;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const url = new URL("../../scripts/daemon/prompt-builder.mjs", import.meta.url);
|
|
122
|
+
const pb = await import(`${url.href}?parallelism=${Math.random()}`);
|
|
123
|
+
const prompt = await pb.buildPrompt(
|
|
124
|
+
{ sender: "Ana", content: "Three unrelated things need doing.", channel: "slack" },
|
|
125
|
+
{ action: "respond", priority: "normal", summary: "three things" },
|
|
126
|
+
{ type: "inbox" },
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
assert.ok(prompt.startsWith(PARALLELISM_DIRECTIVE), "the directive leads the built prompt");
|
|
130
|
+
assert.equal(countParallelism(prompt), 1);
|
|
131
|
+
assert.match(prompt, /Three unrelated things need doing\./, "the item's own content is still there");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("the persona's precedence sentence names itself, so leading the prompt with the directive cannot mis-scope it", async (t) => {
|
|
135
|
+
// The sentence used to read "The section above … anything below". That was
|
|
136
|
+
// true only while the persona was the first thing in the prompt; once the
|
|
137
|
+
// directive leads, "above" has two candidate referents and the directive
|
|
138
|
+
// itself falls outside the rule (it is above, not below). Nothing else pins
|
|
139
|
+
// this ordering, so it is pinned here, beside the change that broke it.
|
|
140
|
+
const { mkdtempSync, mkdirSync, writeFileSync } = await import("node:fs");
|
|
141
|
+
const { join } = await import("node:path");
|
|
142
|
+
const { tmpdir } = await import("node:os");
|
|
143
|
+
|
|
144
|
+
const root = mkdtempSync(join(tmpdir(), "maestro-parallelism-persona-"));
|
|
145
|
+
mkdirSync(join(root, "config"), { recursive: true });
|
|
146
|
+
writeFileSync(join(root, "config", "agent.json"), JSON.stringify({ firstName: "Nova", lastName: "Reyes", title: "Chief of Staff" }));
|
|
147
|
+
writeFileSync(join(root, "config", "company.json"), JSON.stringify({ name: "Northwind" }));
|
|
148
|
+
const prevAgentDir = process.env.AGENT_DIR;
|
|
149
|
+
process.env.AGENT_DIR = root;
|
|
150
|
+
t.after(() => {
|
|
151
|
+
if (prevAgentDir === undefined) delete process.env.AGENT_DIR;
|
|
152
|
+
else process.env.AGENT_DIR = prevAgentDir;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const url = new URL("../../scripts/daemon/prompt-builder.mjs", import.meta.url);
|
|
156
|
+
const pb = await import(`${url.href}?persona=${Math.random()}`);
|
|
157
|
+
const prompt = await pb.buildPrompt(
|
|
158
|
+
{ sender: "Ana", content: "Three unrelated things need doing.", channel: "slack" },
|
|
159
|
+
{ action: "respond", priority: "normal", summary: "three things" },
|
|
160
|
+
{ type: "inbox" },
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
assert.ok(prompt.startsWith(PARALLELISM_DIRECTIVE), "the directive leads");
|
|
164
|
+
assert.ok(
|
|
165
|
+
!/The section above is your identity/.test(prompt),
|
|
166
|
+
"a positional referent is wrong the moment anything is prepended",
|
|
167
|
+
);
|
|
168
|
+
assert.match(
|
|
169
|
+
prompt,
|
|
170
|
+
/This section is your identity, resolved from your organisation's record of you\. Where anything else in this prompt conflicts with it, this section wins\./,
|
|
171
|
+
"the precedence rule names itself and covers the whole prompt",
|
|
172
|
+
);
|
|
173
|
+
assert.ok(
|
|
174
|
+
prompt.indexOf(PARALLELISM_DIRECTIVE) < prompt.indexOf("This section is your identity"),
|
|
175
|
+
"the directive leads and the persona follows — the ordering the sentence had to stop assuming",
|
|
176
|
+
);
|
|
177
|
+
});
|
|
@@ -47,6 +47,38 @@
|
|
|
47
47
|
* // silently drops the seat out of the mesh.
|
|
48
48
|
* frontDoor: "session"|"daemon", // the main session job, or the daemon's --print lane
|
|
49
49
|
* sessionLive: boolean, // state/session/heartbeat.json fresher than 90 s
|
|
50
|
+
* // WHY the front door is not live (WP-M8). Present ONLY when
|
|
51
|
+
* // `sessionLive` is false — a live seat says nothing. The beat is the
|
|
52
|
+
* // only channel to a seat that takes no SSH, so it has to carry the
|
|
53
|
+
* // cause, not just the symptom. Small and bounded by construction:
|
|
54
|
+
* // three fields, `detail` capped at SESSION_NOTE_DETAIL_MAX and
|
|
55
|
+
* // scrubbed of home paths and token-shaped words (see sanitizeNoteDetail),
|
|
56
|
+
* // `since` re-formatted from a parsed instant rather than passed through.
|
|
57
|
+
* // MOMENTARY, like frontDoor/sessionLive: every reason is PRESENT-TENSE
|
|
58
|
+
* // and expires with the snapshot, so a consumer must gate it on
|
|
59
|
+
* // snapshot freshness — it is NOT read the way `upgrade` (a fact about
|
|
60
|
+
* // the past) is read. Two of the reasons have no moment to date and so
|
|
61
|
+
* // carry no `since`; age then comes from the snapshot's own `ts`.
|
|
62
|
+
* // "job-absent" no <first>-session plist in ~/Library/LaunchAgents.
|
|
63
|
+
* // A statement of CONFIGURATION, not necessarily a
|
|
64
|
+
* // fault: a seat whose front door is deliberately the
|
|
65
|
+
* // daemon --print lane reports this on every beat.
|
|
66
|
+
* // Emitted only from a hard "not there", never from
|
|
67
|
+
* // a directory we could not read. No `since`.
|
|
68
|
+
* // "awaiting-input" state/session/attention.json exists AND has not
|
|
69
|
+
* // been superseded by a later heartbeat: the session
|
|
70
|
+
* // is up but has never beaten, so it is sitting on a
|
|
71
|
+
* // first-run trust/permission dialog nobody answered.
|
|
72
|
+
* // `since` and `detail` carry the supervisor's own
|
|
73
|
+
* // attentionRecord moment + hint (truncated).
|
|
74
|
+
* // "heartbeat-stale" a heartbeat exists but has aged out — the supervisor
|
|
75
|
+
* // died or the mux session ended. `since` is the last beat.
|
|
76
|
+
* // "heartbeat-unreadable" the file is there and will not parse: the job
|
|
77
|
+
* // may well have beaten, so this is NOT "never beaten".
|
|
78
|
+
* // "never-beaten" no heartbeat file at all and no watchdog record yet.
|
|
79
|
+
* // `detail` claims the job is installed only when its
|
|
80
|
+
* // presence was actually established. No `since`.
|
|
81
|
+
* sessionNote?: { reason: string, since?: ISO8601, detail?: string },
|
|
50
82
|
* // LAST UPGRADE — the outcome autoupdate.sh recorded in
|
|
51
83
|
* // state/autoupdate/last.json (WP-M6); absent until a first attempt.
|
|
52
84
|
* // Also on `machine` (open record), so hq's fleet view can show it next
|
|
@@ -92,6 +124,7 @@ import { probeClaude as defaultProbeClaude } from "../setup/claude-probe.mjs";
|
|
|
92
124
|
import { list as listPresence } from "../collective/presence.mjs";
|
|
93
125
|
import { billableUsd } from "../cost/ledger-row.mjs";
|
|
94
126
|
import { liveClaudeStats } from "../resource-governor.mjs";
|
|
127
|
+
import { agentFirstName } from "../session/identity.mjs";
|
|
95
128
|
|
|
96
129
|
/** Default temperature probe ceiling — used only as a guard in alerts; here we just report. */
|
|
97
130
|
const VALID_STATES = new Set(["active", "idle", "busy", "error", "offline"]);
|
|
@@ -1031,6 +1064,216 @@ export function sessionLiveness(hb, nowMs, opts = {}) {
|
|
|
1031
1064
|
return { frontDoor: "session", sessionLive: live };
|
|
1032
1065
|
}
|
|
1033
1066
|
|
|
1067
|
+
/** Hard cap on `machine.sessionNote.detail` — the beat is a heartbeat, not a log. */
|
|
1068
|
+
export const SESSION_NOTE_DETAIL_MAX = 200;
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Absolute paths that could name a person, a home or a private temp dir.
|
|
1072
|
+
*
|
|
1073
|
+
* A path segment may CONTAIN a space — `/Users/Olivia Chen/…` is an ordinary
|
|
1074
|
+
* macOS home — so the match does not simply stop at the first whitespace, which
|
|
1075
|
+
* left `<path> Chen/olivia-ai/state` (a surname and the seat's layout) standing
|
|
1076
|
+
* in the note. A space is swallowed only when what follows it is still a path
|
|
1077
|
+
* (more non-space characters and then a `/`), so ordinary prose after a path
|
|
1078
|
+
* survives: `blocked in /Users/maya and then retry` redacts only the path.
|
|
1079
|
+
*/
|
|
1080
|
+
const NOTE_PATH_RE = /(?:~|\/(?:Users|home|private|var\/folders))\/(?:[^\s"'`]|[ \t](?=[^\s"'`]*\/))*/g;
|
|
1081
|
+
/** Token-shaped words: an api-key prefix, or any long opaque run (uuids included). */
|
|
1082
|
+
const NOTE_TOKEN_RE = /\b(?:sk-[A-Za-z0-9_-]{8,}|[A-Za-z0-9][A-Za-z0-9_-]{31,})\b/g;
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Pure: make a free-text note safe and small enough to ride every beat.
|
|
1086
|
+
*
|
|
1087
|
+
* The text this scrubs is OUR OWN (the supervisor's attention hint), so it is
|
|
1088
|
+
* not a sanitiser standing between us and an attacker — it is the guarantee the
|
|
1089
|
+
* contract makes to hq and to every operator reading a fleet view: a note never
|
|
1090
|
+
* carries a home directory, a session id, a token or a file path, and never
|
|
1091
|
+
* grows past {@link SESSION_NOTE_DETAIL_MAX}. The hint names an attach command
|
|
1092
|
+
* (`tmux attach -t =maestro-alex`), which is exactly the actionable part and
|
|
1093
|
+
* survives; the paths and ids around it do not.
|
|
1094
|
+
*
|
|
1095
|
+
* @param {unknown} text
|
|
1096
|
+
* @param {number} [max]
|
|
1097
|
+
* @returns {string} "" when there is nothing safe left to say
|
|
1098
|
+
*/
|
|
1099
|
+
export function sanitizeNoteDetail(text, max = SESSION_NOTE_DETAIL_MAX) {
|
|
1100
|
+
if (typeof text !== "string" || !text) return "";
|
|
1101
|
+
const cap = Number.isFinite(max) && max > 0 ? Math.floor(max) : SESSION_NOTE_DETAIL_MAX;
|
|
1102
|
+
let out = text.replace(/[\u0000-\u001F\u007F]+/g, " ");
|
|
1103
|
+
out = out.replace(NOTE_PATH_RE, "<path>");
|
|
1104
|
+
out = out.replace(NOTE_TOKEN_RE, "<redacted>");
|
|
1105
|
+
out = out.replace(/\s+/g, " ").trim();
|
|
1106
|
+
if (out.length > cap) out = `${out.slice(0, cap - 1).trimEnd()}…`;
|
|
1107
|
+
return out;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** ms-or-ISO `ts` off a heartbeat record -> epoch ms, else null. */
|
|
1111
|
+
function heartbeatTs(hb) {
|
|
1112
|
+
if (!hb || typeof hb !== "object") return null;
|
|
1113
|
+
const ts = typeof hb.ts === "number" ? hb.ts : Date.parse(hb.ts);
|
|
1114
|
+
return Number.isFinite(ts) ? ts : null;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Pure: an ISO-8601 string -> epoch ms, else null.
|
|
1119
|
+
*
|
|
1120
|
+
* `since` rides the beat as the note's only structured field, and it used to
|
|
1121
|
+
* ride VERBATIM behind a bare `Date.parse` finiteness check — which V8's legacy
|
|
1122
|
+
* date parser passes for a string carrying trailing prose, so a home path or a
|
|
1123
|
+
* token-shaped run could travel on the one field {@link sanitizeNoteDetail}
|
|
1124
|
+
* does not cover. Parsing to ms here and re-formatting through `toISOString()`
|
|
1125
|
+
* makes `since` bounded and canonical by CONSTRUCTION rather than by trusting
|
|
1126
|
+
* the file it came from.
|
|
1127
|
+
*/
|
|
1128
|
+
function instantMs(v) {
|
|
1129
|
+
if (typeof v !== "string" || !v) return null;
|
|
1130
|
+
const ms = Date.parse(v);
|
|
1131
|
+
return Number.isFinite(ms) ? ms : null;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* The supervisor watchdog's vocabulary (`lib/session/first-run#heartbeatSilence`)
|
|
1136
|
+
* in plain words.
|
|
1137
|
+
*
|
|
1138
|
+
* Its `"no-heartbeat"` means "the session is UP and has never beaten"; the
|
|
1139
|
+
* beat's own reasons of nearly the same spelling mean something else, and the
|
|
1140
|
+
* two met inside one payload (`{reason:"awaiting-input", detail:"no-heartbeat: …"}`),
|
|
1141
|
+
* which reads as a contradiction to anything that shows reason and detail side
|
|
1142
|
+
* by side. An unrecognised value still passes through verbatim — a word we do
|
|
1143
|
+
* not know is better than a word we invent.
|
|
1144
|
+
*/
|
|
1145
|
+
const ATTENTION_WHY = {
|
|
1146
|
+
"no-heartbeat": "up, never beaten",
|
|
1147
|
+
"stale-heartbeat": "no beat since this launch",
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Pure: WHY the front door is not live — the beat's `machine.sessionNote`.
|
|
1152
|
+
*
|
|
1153
|
+
* THE GAP (WP-M8): after 2.12.0, seats A010 and A016 beat
|
|
1154
|
+
* `frontDoor:"daemon", sessionLive:false` for days and nothing on the beat
|
|
1155
|
+
* could tell a job that was never installed from a session sitting on an
|
|
1156
|
+
* unanswered first-run dialog from a supervisor that had died — three states an
|
|
1157
|
+
* operator fixes three different ways. No seat in the fleet accepts SSH, so the
|
|
1158
|
+
* beat is the only channel; the seat already knew the answer
|
|
1159
|
+
* (`state/session/attention.json`), it just never left the machine.
|
|
1160
|
+
*
|
|
1161
|
+
* ORDER, and why each step outranks the next:
|
|
1162
|
+
*
|
|
1163
|
+
* 1. `job-absent` first: a seat with no job cannot have a meaningful heartbeat
|
|
1164
|
+
* or attention record, and a stale `attention.json` left behind by a
|
|
1165
|
+
* previous install must not mask "the job is gone". It is a statement of
|
|
1166
|
+
* CONFIGURATION, not necessarily a fault — a seat whose front door is
|
|
1167
|
+
* deliberately the daemon --print lane reports it on every beat, and the
|
|
1168
|
+
* detail says so rather than accusing the seat.
|
|
1169
|
+
* 2. `awaiting-input` next — but ONLY while the record still describes the
|
|
1170
|
+
* present. `scripts/session/supervisor.mjs` clears `attention.json` at the
|
|
1171
|
+
* START of the next launch and never when the human answers the dialog, so
|
|
1172
|
+
* a record can outlive its truth by days: the session gets answered, beats
|
|
1173
|
+
* for a week, then dies. A heartbeat LATER than the record is proof the
|
|
1174
|
+
* dialog was answered, and the record is then superseded — otherwise the
|
|
1175
|
+
* beat hands the operator a week-old attach command for a mux session that
|
|
1176
|
+
* no longer exists while the real fault stays invisible.
|
|
1177
|
+
* 3. `heartbeat-stale` / `heartbeat-unreadable` / `never-beaten` last, from
|
|
1178
|
+
* what the heartbeat file itself can prove.
|
|
1179
|
+
*
|
|
1180
|
+
* Every emitted `detail` must be TRUE of the state it describes: an unknown is
|
|
1181
|
+
* never reported as an absence (`job-absent` comes only from a hard `false`)
|
|
1182
|
+
* and, symmetrically, never as a presence (`never-beaten` says the job is
|
|
1183
|
+
* installed only when it was actually checked).
|
|
1184
|
+
*
|
|
1185
|
+
* Everything is INJECTED — no clock, no fs, no launchctl. Every malformed
|
|
1186
|
+
* input resolves to a coarser answer or to `null`; nothing here throws.
|
|
1187
|
+
*
|
|
1188
|
+
* @param {object} a
|
|
1189
|
+
* @param {object|null} [a.heartbeat] parsed state/session/heartbeat.json (null when absent/corrupt)
|
|
1190
|
+
* @param {boolean} [a.heartbeatUnreadable] the file was THERE but would not parse (a different fault from "never beaten")
|
|
1191
|
+
* @param {object|null} [a.attention] parsed state/session/attention.json (lib/session/first-run#attentionRecord)
|
|
1192
|
+
* @param {boolean|null} [a.jobInstalled] is the `<first>-session` launchd job present? null = COULD NOT TELL (reported as neither an absence nor a presence)
|
|
1193
|
+
* @param {string} [a.label] the launchd label, for the job-absent detail
|
|
1194
|
+
* @param {number} a.now epoch ms
|
|
1195
|
+
* @param {number} [a.staleMs] heartbeat staleness bound (default {@link SESSION_STALE_MS})
|
|
1196
|
+
* @returns {{reason:string, since?:string, detail?:string}|null} null when the front door is live (or `now` is unusable)
|
|
1197
|
+
*/
|
|
1198
|
+
export function sessionNote(a = {}) {
|
|
1199
|
+
const o = a && typeof a === "object" ? a : {};
|
|
1200
|
+
const now = Number(o.now);
|
|
1201
|
+
if (!Number.isFinite(now)) return null;
|
|
1202
|
+
const staleMs = Number.isFinite(o.staleMs) ? o.staleMs : SESSION_STALE_MS;
|
|
1203
|
+
const hb = o.heartbeat && typeof o.heartbeat === "object" && !Array.isArray(o.heartbeat) ? o.heartbeat : null;
|
|
1204
|
+
|
|
1205
|
+
// A live front door has nothing to explain — the note is an EXCEPTION field.
|
|
1206
|
+
if (sessionLiveness(hb, now, { staleMs }).sessionLive) return null;
|
|
1207
|
+
|
|
1208
|
+
// 1. The job is not on this seat at all. `false` only; `null` is "unknown",
|
|
1209
|
+
// and an unknown must never be reported as an absence.
|
|
1210
|
+
if (o.jobInstalled === false) {
|
|
1211
|
+
const label = typeof o.label === "string" && o.label.trim() ? o.label.trim() : "";
|
|
1212
|
+
return {
|
|
1213
|
+
reason: "job-absent",
|
|
1214
|
+
detail: sanitizeNoteDetail(
|
|
1215
|
+
`no ${label || "main-session"} launchd job on this seat — the daemon --print lane is the front door (by configuration, or the session job was never installed)`,
|
|
1216
|
+
),
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
const hbMs = heartbeatTs(hb);
|
|
1221
|
+
|
|
1222
|
+
// 2. The supervisor's watchdog has already named it: alive, never beaten —
|
|
1223
|
+
// a first-run trust/permission dialog with nobody attached to answer.
|
|
1224
|
+
// Unless a LATER heartbeat proves the dialog was answered and the record
|
|
1225
|
+
// is simply one the supervisor never got to clear (see ORDER above). A
|
|
1226
|
+
// record with no usable `since` cannot be ordered against the heartbeat,
|
|
1227
|
+
// and a heartbeat with a real timestamp is the harder evidence, so that
|
|
1228
|
+
// too counts as superseded.
|
|
1229
|
+
const att = o.attention && typeof o.attention === "object" && !Array.isArray(o.attention) ? o.attention : null;
|
|
1230
|
+
const attMs = att ? instantMs(att.since) : null;
|
|
1231
|
+
const superseded = att !== null && hbMs !== null && (attMs === null || hbMs > attMs);
|
|
1232
|
+
if (att && !superseded) {
|
|
1233
|
+
const note = { reason: "awaiting-input" };
|
|
1234
|
+
if (attMs !== null) note.since = new Date(attMs).toISOString();
|
|
1235
|
+
const raw = typeof att.reason === "string" ? att.reason.trim() : "";
|
|
1236
|
+
const why = raw ? (ATTENTION_WHY[raw] || raw) : "";
|
|
1237
|
+
const hint = typeof att.hint === "string" ? att.hint : "";
|
|
1238
|
+
const detail = sanitizeNoteDetail(why && hint ? `${why}: ${hint}` : why || hint);
|
|
1239
|
+
if (detail) note.detail = detail;
|
|
1240
|
+
return note;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// 3. It beat once and stopped: the supervisor died, or the mux session ended.
|
|
1244
|
+
if (hbMs !== null) {
|
|
1245
|
+
const ageMs = now - hbMs;
|
|
1246
|
+
return {
|
|
1247
|
+
reason: "heartbeat-stale",
|
|
1248
|
+
since: new Date(hbMs).toISOString(),
|
|
1249
|
+
detail: ageMs < 0 ? "last beat is in the future (clock skew)" : `no beat for ${Math.round(ageMs / 1000)} s`,
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// 4. A heartbeat file is THERE but says nothing usable. Not the same fault as
|
|
1254
|
+
// "never beaten" — the job may well have beaten and the file be corrupt —
|
|
1255
|
+
// so it gets its own reason and its own fix.
|
|
1256
|
+
if (hb || o.heartbeatUnreadable === true) {
|
|
1257
|
+
return {
|
|
1258
|
+
reason: "heartbeat-unreadable",
|
|
1259
|
+
detail: hb
|
|
1260
|
+
? "the heartbeat file is present but carries no readable timestamp"
|
|
1261
|
+
: "the heartbeat file is present but could not be parsed",
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// 5. Nothing on disk at all: a job that has never beaten and whose watchdog
|
|
1266
|
+
// has not fired yet. What we say about the job depends on whether its
|
|
1267
|
+
// presence was actually established — `true`, and only `true`, licenses
|
|
1268
|
+
// "the session job is installed".
|
|
1269
|
+
return {
|
|
1270
|
+
reason: "never-beaten",
|
|
1271
|
+
detail: o.jobInstalled === true
|
|
1272
|
+
? "the session job is installed but has never written a heartbeat"
|
|
1273
|
+
: "no heartbeat from the session job, and its presence on this seat could not be checked",
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1034
1277
|
/**
|
|
1035
1278
|
* Pure: the beat's `machine.upgrade` from state/autoupdate/last.json
|
|
1036
1279
|
* (`{from,to,at,ok,healthy,reason}` as autoupdate.sh writes it). Only the four
|
|
@@ -1053,10 +1296,87 @@ function readAutoupdateLast(agentRoot) {
|
|
|
1053
1296
|
return safeReadJson(join(resolve(agentRoot), "state", "autoupdate", "last.json"));
|
|
1054
1297
|
}
|
|
1055
1298
|
|
|
1056
|
-
/**
|
|
1057
|
-
|
|
1299
|
+
/**
|
|
1300
|
+
* Read state/session/heartbeat.json, distinguishing ABSENT from PRESENT-BUT-
|
|
1301
|
+
* UNPARSEABLE.
|
|
1302
|
+
*
|
|
1303
|
+
* `safeReadJson` maps both to `null`, which is right for liveness (neither is a
|
|
1304
|
+
* live beat) and wrong for the note: a corrupt file told the operator the job
|
|
1305
|
+
* "has never written a heartbeat" when in fact it had, and the fix for the two
|
|
1306
|
+
* is not the same. The two facts are separated here and rejoined in
|
|
1307
|
+
* {@link sessionNote}.
|
|
1308
|
+
*
|
|
1309
|
+
* @returns {{heartbeat:object|null, unreadable:boolean}}
|
|
1310
|
+
*/
|
|
1311
|
+
function readSessionHeartbeatState(agentRoot) {
|
|
1312
|
+
if (!agentRoot) return { heartbeat: null, unreadable: false };
|
|
1313
|
+
let text;
|
|
1314
|
+
try {
|
|
1315
|
+
text = readFileSync(join(resolve(agentRoot), "state", "session", "heartbeat.json"), "utf8");
|
|
1316
|
+
} catch {
|
|
1317
|
+
return { heartbeat: null, unreadable: false }; // no file: the job has never beaten
|
|
1318
|
+
}
|
|
1319
|
+
try {
|
|
1320
|
+
const v = JSON.parse(text);
|
|
1321
|
+
if (v && typeof v === "object" && !Array.isArray(v)) return { heartbeat: v, unreadable: false };
|
|
1322
|
+
return { heartbeat: null, unreadable: true }; // a JSON scalar is not a heartbeat
|
|
1323
|
+
} catch {
|
|
1324
|
+
return { heartbeat: null, unreadable: true };
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/** Read state/session/attention.json (the supervisor's watchdog record); null when absent or malformed. */
|
|
1329
|
+
function readSessionAttention(agentRoot) {
|
|
1058
1330
|
if (!agentRoot) return null;
|
|
1059
|
-
return safeReadJson(join(resolve(agentRoot), "state", "session", "
|
|
1331
|
+
return safeReadJson(join(resolve(agentRoot), "state", "session", "attention.json"));
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* The launchd label of this seat's main-session job, as
|
|
1336
|
+
* `scripts/local-triggers/generate-plists.sh` and `maestro session` spell it.
|
|
1337
|
+
* "" when the first name cannot be derived.
|
|
1338
|
+
*/
|
|
1339
|
+
function sessionJobLabel(agentRoot, first) {
|
|
1340
|
+
// NOT lower-cased: `agentFirstName` deliberately leaves its directory-name
|
|
1341
|
+
// fallback as-is (`~/Maya-ai` -> `Maya`) because the generator does, so
|
|
1342
|
+
// lower-casing an INJECTED first here built a label the plist file does not
|
|
1343
|
+
// carry — and a label that does not match is how a healthy seat gets accused
|
|
1344
|
+
// of `job-absent`, the one verdict that must come from a hard fact.
|
|
1345
|
+
const f = typeof first === "string" && first.trim() ? first.trim() : (agentRoot ? agentFirstName(resolve(agentRoot)) : "");
|
|
1346
|
+
return f ? `ai.maestro.${f}-session` : "";
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* Is the main-session launchd job on this seat? A filename scan of
|
|
1351
|
+
* ~/Library/LaunchAgents — a readdir, NOT a `launchctl list` fork, because this
|
|
1352
|
+
* runs on every presence beat.
|
|
1353
|
+
*
|
|
1354
|
+
* Returns `null` for "could not tell" (no label, no home, an unreadable
|
|
1355
|
+
* directory, a non-darwin box) and only ever `false` when the directory was
|
|
1356
|
+
* read and the label was genuinely not in it: `sessionNote` reports an absence
|
|
1357
|
+
* only from a `false`, never from an unknown.
|
|
1358
|
+
*
|
|
1359
|
+
* @returns {boolean|null}
|
|
1360
|
+
*/
|
|
1361
|
+
function sessionJobInstalled(o, label) {
|
|
1362
|
+
if (typeof o.sessionJobInstalled === "boolean") return o.sessionJobInstalled;
|
|
1363
|
+
if (!label) return null;
|
|
1364
|
+
let names = o.launchAgents;
|
|
1365
|
+
if (!Array.isArray(names)) {
|
|
1366
|
+
try {
|
|
1367
|
+
const osImpl = o.os || os;
|
|
1368
|
+
const home = typeof osImpl.homedir === "function" ? String(osImpl.homedir() || "") : "";
|
|
1369
|
+
if (!home) return null;
|
|
1370
|
+
names = readdirSync(join(home, "Library", "LaunchAgents"));
|
|
1371
|
+
} catch {
|
|
1372
|
+
return null; // no directory, no permission, not a Mac — unknown, not absent
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
// Case-insensitively: HFS+/APFS are case-preserving but case-INsensitive by
|
|
1376
|
+
// default, so `ai.maestro.Maya-session.plist` and the lower-cased label are
|
|
1377
|
+
// the same file to launchd. A case difference must not read as an absence.
|
|
1378
|
+
const want = label.toLowerCase();
|
|
1379
|
+
return names.some((n) => String(n).replace(/\.plist$/, "").toLowerCase() === want);
|
|
1060
1380
|
}
|
|
1061
1381
|
|
|
1062
1382
|
// ---------------------------------------------------------------------------
|
|
@@ -1357,12 +1677,39 @@ export async function collectStatus(o = {}) {
|
|
|
1357
1677
|
// and `machine` open, so this is the only place a new seat-level fact can
|
|
1358
1678
|
// land without a lock-step hq deploy. `session` stays `null` when idle.
|
|
1359
1679
|
let door = { frontDoor: "daemon", sessionLive: false };
|
|
1680
|
+
let heartbeat = null;
|
|
1681
|
+
let heartbeatUnreadable = false;
|
|
1360
1682
|
try {
|
|
1361
|
-
|
|
1362
|
-
|
|
1683
|
+
if (opt.heartbeat !== undefined) heartbeat = opt.heartbeat;
|
|
1684
|
+
else {
|
|
1685
|
+
const hbState = readSessionHeartbeatState(opt.agentRoot);
|
|
1686
|
+
heartbeat = hbState.heartbeat;
|
|
1687
|
+
heartbeatUnreadable = hbState.unreadable;
|
|
1688
|
+
}
|
|
1689
|
+
door = sessionLiveness(heartbeat, nowMs, { staleMs: opt.sessionStaleMs });
|
|
1363
1690
|
} catch { /* defaults: daemon front door, not live */ }
|
|
1364
1691
|
machine = { ...machine, ...door };
|
|
1365
1692
|
|
|
1693
|
+
// 4b-ii. WHY it is not live (WP-M8) — `machine.sessionNote`, present ONLY
|
|
1694
|
+
// when `sessionLive` is false. The decision is the pure `sessionNote`; this
|
|
1695
|
+
// is just the reads that feed it, and it is fail-open in the same way every
|
|
1696
|
+
// other probe here is: a throw anywhere drops the field, never the beat.
|
|
1697
|
+
if (door.sessionLive !== true) {
|
|
1698
|
+
try {
|
|
1699
|
+
const label = typeof opt.sessionLabel === "string" ? opt.sessionLabel : sessionJobLabel(opt.agentRoot, opt.agentFirst);
|
|
1700
|
+
const note = sessionNote({
|
|
1701
|
+
heartbeat,
|
|
1702
|
+
heartbeatUnreadable: opt.heartbeatUnreadable !== undefined ? opt.heartbeatUnreadable === true : heartbeatUnreadable,
|
|
1703
|
+
attention: opt.attention !== undefined ? opt.attention : readSessionAttention(opt.agentRoot),
|
|
1704
|
+
jobInstalled: sessionJobInstalled(opt, label),
|
|
1705
|
+
label,
|
|
1706
|
+
now: nowMs,
|
|
1707
|
+
staleMs: opt.sessionStaleMs,
|
|
1708
|
+
});
|
|
1709
|
+
if (note) machine.sessionNote = note;
|
|
1710
|
+
} catch { /* no sessionNote — the beat still carries frontDoor/sessionLive */ }
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1366
1713
|
// 4c. last upgrade outcome (WP-M6) — `machine.upgrade`, absent until the
|
|
1367
1714
|
// first autoupdate attempt; a corrupt file drops the field, never the beat.
|
|
1368
1715
|
try {
|
|
@@ -1409,6 +1756,11 @@ export const _internals = {
|
|
|
1409
1756
|
collectTailnetIp,
|
|
1410
1757
|
readSdkVersion,
|
|
1411
1758
|
sessionLiveness,
|
|
1759
|
+
sessionNote,
|
|
1760
|
+
sanitizeNoteDetail,
|
|
1761
|
+
sessionJobLabel,
|
|
1762
|
+
sessionJobInstalled,
|
|
1763
|
+
readSessionAttention,
|
|
1412
1764
|
upgradeSummary,
|
|
1413
1765
|
detectClaudeAuth,
|
|
1414
1766
|
resetAuthProbeCache,
|