faberun 0.3.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/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The usage and cost ledger: `usage.jsonl`, one record per invocation, and the
|
|
3
|
+
* arithmetic that rolls it up onto a node.
|
|
4
|
+
*
|
|
5
|
+
* Reporting only. No control path reads this -- a spent allowance is handled by
|
|
6
|
+
* runtime re-tiering, never by a token or dollar cap -- and that is why a usage
|
|
7
|
+
* the provider did not report stays null instead of becoming a plausible zero.
|
|
8
|
+
*/
|
|
9
|
+
import { appendJsonl, writeJsonAtomic } from "./store.mjs";
|
|
10
|
+
import { basename, join } from "node:path";
|
|
11
|
+
import { errorMessage, stableJson } from "../util.mjs";
|
|
12
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
13
|
+
import { liveUsage } from "../harnesses/exec-jsonl/index.mjs";
|
|
14
|
+
|
|
15
|
+
import { priceUsage, readBoundedTail } from "../engine/process.mjs";
|
|
16
|
+
import { writeNode } from "../engine/state.mjs";
|
|
17
|
+
import { normalizeProviderResult } from "../harnesses/index.mjs";
|
|
18
|
+
|
|
19
|
+
// `priceUsage` is defined beside `invocationResult`, the second source point,
|
|
20
|
+
// and re-exported here so the ledger's public surface is unchanged. This module
|
|
21
|
+
// already imports `engine/process.mjs`, so the definition lives there to keep
|
|
22
|
+
// the two source points out of a runtime import cycle.
|
|
23
|
+
export { priceUsage };
|
|
24
|
+
|
|
25
|
+
/** @typedef {import("../engine/process.mjs").Invocation} Invocation */
|
|
26
|
+
/** @typedef {import("../engine/process.mjs").Job} Job */
|
|
27
|
+
/** @typedef {ReturnType<typeof import("../run/lock.mjs").acquire>} LockHandle */
|
|
28
|
+
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
29
|
+
/** @typedef {import("../harnesses/index.mjs").ProviderEnvelope} ProviderEnvelope */
|
|
30
|
+
/** @typedef {ProviderEnvelope & {costProvenance?: "priced"}} PricedEnvelope */
|
|
31
|
+
/** @typedef {{kind: "adopted"|"rejudge"|"restart"|"reconciled"|"exhausted"|"stalled", phase?: "worker"|"judge", result?: unknown, usage?: Usage, costUsd?: number|null, costProvenance?: "priced", exhaustedUntil?: string|null, error?: {code: string, message: string}|null, invocationId?: string, reason?: string}} RecoveryOutcome */
|
|
32
|
+
/** @typedef {import("../contract/index.mjs").Usage} Usage */
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extract the invocation's provider envelope from the bounded transcript tail
|
|
36
|
+
* and persist its usage into the matching invocation record. By default the
|
|
37
|
+
* usage is also accumulated into `state.usage` (the caller then transitions or
|
|
38
|
+
* continues); with `accumulate: false` only the invocation record is updated,
|
|
39
|
+
* for jobs whose node already reached a terminal state that already counted
|
|
40
|
+
* this spend.
|
|
41
|
+
*
|
|
42
|
+
* @param {Job} job
|
|
43
|
+
* @param {{accumulate?: boolean}} [options]
|
|
44
|
+
* @returns {PricedEnvelope}
|
|
45
|
+
*/
|
|
46
|
+
export function recordInvocationUsage(job, options = {}) {
|
|
47
|
+
const { state } = job;
|
|
48
|
+
/** @type {PricedEnvelope} */
|
|
49
|
+
let envelope;
|
|
50
|
+
let boundedStdout = "";
|
|
51
|
+
try {
|
|
52
|
+
boundedStdout = readBoundedTail(job.paths.stdout);
|
|
53
|
+
const boundedStderr = readBoundedTail(job.paths.stderr, 512 * 1024);
|
|
54
|
+
envelope = normalizeProviderResult(job.runtime, boundedStdout, job.exitCode, job.signal, {
|
|
55
|
+
preferStructured: job.phase === "judge",
|
|
56
|
+
stderr: boundedStderr,
|
|
57
|
+
});
|
|
58
|
+
} catch (error) {
|
|
59
|
+
envelope = {
|
|
60
|
+
status: "failed",
|
|
61
|
+
result: null,
|
|
62
|
+
continuationId: null,
|
|
63
|
+
usage: { inputTokens: null, outputTokens: null, cacheReadInputTokens: null },
|
|
64
|
+
costUsd: null,
|
|
65
|
+
error: { code: "invalid_output", message: errorMessage(error) },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// Failure envelopes carry zeroed usage (a killed provider emits no terminal
|
|
69
|
+
// event), yet its transcript holds real per-turn counters. Backfill the
|
|
70
|
+
// normalized usage components from the live meter so kills, timeouts, and
|
|
71
|
+
// scope failures still report what they spent, cache reads separated.
|
|
72
|
+
if (envelope.usage.inputTokens === null && boundedStdout) {
|
|
73
|
+
const observed = liveUsage(job.runtime.harness, boundedStdout);
|
|
74
|
+
if (observed.inputTokens !== null) {
|
|
75
|
+
envelope = { ...envelope, usage: { ...envelope.usage, inputTokens: observed.inputTokens, cacheReadInputTokens: observed.cacheReadInputTokens } };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Price only after the backfill has run: the counters this function persists
|
|
79
|
+
// and returns are the ones the price is derived from, and the envelope becomes
|
|
80
|
+
// the single priced object every later copy spreads from.
|
|
81
|
+
const priced = priceUsage(job.runtime, envelope.usage, envelope.costUsd);
|
|
82
|
+
envelope = { ...envelope, costUsd: priced.costUsd, costProvenance: priced.costProvenance };
|
|
83
|
+
state.invocations = (state.invocations ?? []).map((invocation) => invocation.id === job.invocation.id
|
|
84
|
+
? { ...invocation, usage: envelope.usage, costUsd: envelope.costUsd, costProvenance: envelope.costProvenance }
|
|
85
|
+
: invocation);
|
|
86
|
+
if (options.accumulate !== false) state.usage = addUsage(state.usage, envelope.usage);
|
|
87
|
+
return envelope;
|
|
88
|
+
}
|
|
89
|
+
const USAGE_LOG_NAME = "usage.jsonl";
|
|
90
|
+
/** @param {Usage|undefined} usage @returns {boolean} */
|
|
91
|
+
function hasMeasuredUsage(usage) {
|
|
92
|
+
return Boolean(usage && [usage.inputTokens, usage.outputTokens, usage.cacheReadInputTokens]
|
|
93
|
+
.some((value) => typeof value === "number" && Number.isFinite(value)));
|
|
94
|
+
}
|
|
95
|
+
/** @param {NodeSnapshot} state @returns {Usage} */
|
|
96
|
+
export function invocationUsage(state) {
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
return (state.invocations ?? []).reduce((total, invocation) => {
|
|
99
|
+
if (invocation.id && seen.has(invocation.id)) return total;
|
|
100
|
+
if (invocation.id) seen.add(invocation.id);
|
|
101
|
+
return addUsage(total, invocation.usage);
|
|
102
|
+
}, /** @type {Usage} */ ({ inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 }));
|
|
103
|
+
}
|
|
104
|
+
/** @param {NodeSnapshot} state @returns {number|undefined} */
|
|
105
|
+
export function invocationCost(state) {
|
|
106
|
+
const costs = /** @type {number[]} */ ((state.invocations ?? [])
|
|
107
|
+
.map((invocation) => invocation.costUsd)
|
|
108
|
+
.filter((cost) => typeof cost === "number" && Number.isFinite(cost)));
|
|
109
|
+
return costs.length ? costs.reduce((total, cost) => total + cost, 0) : undefined;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Invocation ids already present in the run's usage.jsonl. The append path
|
|
113
|
+
* uses this to stay idempotent across resume and replay.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} runDir
|
|
116
|
+
* @returns {Set<string>}
|
|
117
|
+
*/
|
|
118
|
+
export function usageRecordIds(runDir) {
|
|
119
|
+
const ids = new Set();
|
|
120
|
+
const path = join(runDir, USAGE_LOG_NAME);
|
|
121
|
+
if (!existsSync(path)) return ids;
|
|
122
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
123
|
+
if (!line.trim()) continue;
|
|
124
|
+
try {
|
|
125
|
+
const record = JSON.parse(line);
|
|
126
|
+
const value = record && typeof record === "object" && !Array.isArray(record)
|
|
127
|
+
? /** @type {Record<string, unknown>} */ (record)
|
|
128
|
+
: null;
|
|
129
|
+
if (value && typeof value.invocationId === "string") ids.add(value.invocationId);
|
|
130
|
+
} catch {
|
|
131
|
+
// A truncated tail line is repaired by appendJsonl on the next write.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return ids;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Append one usage.jsonl record for a worker or judge invocation. Usage is a
|
|
138
|
+
* reporting record only: no control path reads this file to gate work.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} runDir
|
|
141
|
+
* @param {Invocation|undefined|null} invocation
|
|
142
|
+
*/
|
|
143
|
+
export function appendUsageRecord(runDir, invocation) {
|
|
144
|
+
if (!invocation?.id || usageRecordIds(runDir).has(invocation.id)) return;
|
|
145
|
+
const usage = /** @type {Usage} */ (invocation.usage ?? { inputTokens: null, outputTokens: null, cacheReadInputTokens: null });
|
|
146
|
+
appendJsonl(join(runDir, USAGE_LOG_NAME), {
|
|
147
|
+
invocationId: invocation.id,
|
|
148
|
+
runId: invocation.runId ?? basename(runDir),
|
|
149
|
+
nodeId: invocation.nodeId ?? null,
|
|
150
|
+
attempt: invocation.attempt ?? null,
|
|
151
|
+
role: invocation.role ?? null,
|
|
152
|
+
runtimeId: invocation.runtimeId ?? null,
|
|
153
|
+
model: invocation.model ?? null,
|
|
154
|
+
inputTokens: typeof usage.inputTokens === "number" ? usage.inputTokens : null,
|
|
155
|
+
cacheReadInputTokens: typeof usage.cacheReadInputTokens === "number" ? usage.cacheReadInputTokens : null,
|
|
156
|
+
outputTokens: typeof usage.outputTokens === "number" ? usage.outputTokens : null,
|
|
157
|
+
costUsd: typeof invocation.costUsd === "number" ? invocation.costUsd : null,
|
|
158
|
+
// A persisted `priced` marker wins; otherwise the pre-Phase-4 rule applies
|
|
159
|
+
// unchanged: a reported number is `provider`, absence is `unknown`.
|
|
160
|
+
costProvenance: invocation.costProvenance ?? (typeof invocation.costUsd === "number" ? "provider" : "unknown"),
|
|
161
|
+
startedAt: invocation.startedAt ?? null,
|
|
162
|
+
finishedAt: invocation.closedAt ?? null,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Recovery can discover usage after the run synchronized its records. Attach
|
|
167
|
+
* it to the authoritative invocation first, then write the updated usage
|
|
168
|
+
* record. The invocation id makes repeated resumes idempotent.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} runDir
|
|
171
|
+
* @param {NodeSnapshot} state
|
|
172
|
+
* @param {RecoveryOutcome|null|undefined} recovery
|
|
173
|
+
* @param {LockHandle} lock
|
|
174
|
+
* @returns {Promise<void>}
|
|
175
|
+
*/
|
|
176
|
+
export async function persistRecoveryUsage(runDir, state, recovery, lock) {
|
|
177
|
+
if (!recovery?.invocationId) return;
|
|
178
|
+
const current = state.invocations?.find((invocation) => invocation.id === recovery.invocationId);
|
|
179
|
+
if (!current) return;
|
|
180
|
+
const usage = hasMeasuredUsage(current.usage) ? current.usage : recovery.usage;
|
|
181
|
+
// Cost and provenance are selected together from whichever source wins the
|
|
182
|
+
// numeric-cost predicate: an independent fallback could attach a stray
|
|
183
|
+
// recovery provenance to an already-settled cost that was never priced.
|
|
184
|
+
const priced = typeof current.costUsd === "number"
|
|
185
|
+
? { costUsd: current.costUsd, costProvenance: current.costProvenance }
|
|
186
|
+
: { costUsd: recovery.costUsd ?? null, costProvenance: recovery.costProvenance };
|
|
187
|
+
const changed = stableJson(current.usage) !== stableJson(usage)
|
|
188
|
+
|| current.costUsd !== priced.costUsd
|
|
189
|
+
|| (current.costProvenance ?? null) !== (priced.costProvenance ?? null);
|
|
190
|
+
if (changed) {
|
|
191
|
+
state.invocations = (state.invocations ?? []).map((invocation) => invocation.id === current.id
|
|
192
|
+
? { ...invocation, usage, costUsd: priced.costUsd, costProvenance: priced.costProvenance }
|
|
193
|
+
: invocation);
|
|
194
|
+
state.usage = invocationUsage(state);
|
|
195
|
+
writeNode(runDir, state, lock);
|
|
196
|
+
}
|
|
197
|
+
const updated = state.invocations?.find((invocation) => invocation.id === current.id);
|
|
198
|
+
if (updated) appendUsageRecord(runDir, updated);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* @param {Usage|undefined} left
|
|
202
|
+
* @param {Usage|undefined} right
|
|
203
|
+
* @returns {Usage}
|
|
204
|
+
*/
|
|
205
|
+
function addUsage(left, right) {
|
|
206
|
+
return {
|
|
207
|
+
inputTokens: (left?.inputTokens ?? 0) + (right?.inputTokens ?? 0),
|
|
208
|
+
outputTokens: (left?.outputTokens ?? 0) + (right?.outputTokens ?? 0),
|
|
209
|
+
cacheReadInputTokens: (left?.cacheReadInputTokens ?? 0) + (right?.cacheReadInputTokens ?? 0),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/** @returns {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}} */
|
|
213
|
+
export function emptyUsage() {
|
|
214
|
+
return { inputTokens: null, outputTokens: null, cacheReadInputTokens: null };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The named artifact the fresh-session hypothesis is recorded to. The
|
|
219
|
+
* measurement is the acceptance, not a threshold met: a cohort with no gate
|
|
220
|
+
* revision is recorded as absent rather than compared against an empty set.
|
|
221
|
+
*/
|
|
222
|
+
export const CACHE_READ_PER_REVISION_ARTIFACT = "cacheReadPerRevision.json";
|
|
223
|
+
|
|
224
|
+
/** @typedef {{runCount: number, runsWithGateRevision: number, gateRevisions: number, retryWorkerInvocations: number, cacheReadTokens: number, cacheReadPerRevision: number|null}} CacheReadCohort */
|
|
225
|
+
/** @typedef {{before: CacheReadCohort, after: CacheReadCohort}} CacheReadCohorts */
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* `cacheReadPerRevision` over one cohort of run directories:
|
|
229
|
+
*
|
|
230
|
+
* cacheReadPerRevision =
|
|
231
|
+
* cache-read tokens attributable to gate revisions
|
|
232
|
+
* / number of gate revisions
|
|
233
|
+
*
|
|
234
|
+
* A gate revision is one `state.revisions` increment — a judge or mechanical
|
|
235
|
+
* rejection that re-dispatched the worker. The persisted ledger does not link
|
|
236
|
+
* an invocation to the rejection that caused it, so the attribution is the
|
|
237
|
+
* declared approximation: the cache-read tokens recorded on worker invocations
|
|
238
|
+
* after the first attempt of their node, which are the retries that include
|
|
239
|
+
* those gate revisions. A cohort with no gate revision reports
|
|
240
|
+
* `cacheReadPerRevision: null`, never a divide-by-zero.
|
|
241
|
+
*
|
|
242
|
+
* @param {string[]} runDirs
|
|
243
|
+
* @returns {CacheReadCohort}
|
|
244
|
+
*/
|
|
245
|
+
export function measureCacheReadPerRevision(runDirs) {
|
|
246
|
+
let gateRevisions = 0;
|
|
247
|
+
let retryWorkerInvocations = 0;
|
|
248
|
+
let cacheReadTokens = 0;
|
|
249
|
+
let runsWithGateRevision = 0;
|
|
250
|
+
for (const runDir of runDirs) {
|
|
251
|
+
let runRevisions = 0;
|
|
252
|
+
for (const name of listNodeSnapshotFiles(runDir)) {
|
|
253
|
+
let state;
|
|
254
|
+
try {
|
|
255
|
+
state = JSON.parse(readFileSync(join(runDir, "nodes", name), "utf8"));
|
|
256
|
+
} catch {
|
|
257
|
+
// A torn or unreadable snapshot contributes nothing; a measurement
|
|
258
|
+
// never fails because one run was mid-write.
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (typeof state.revisions === "number" && Number.isFinite(state.revisions)) runRevisions += state.revisions;
|
|
262
|
+
for (const invocation of Array.isArray(state.invocations) ? state.invocations : []) {
|
|
263
|
+
if (invocation?.role !== "worker") continue;
|
|
264
|
+
if (!(typeof invocation.attempt === "number" && invocation.attempt > 1)) continue;
|
|
265
|
+
retryWorkerInvocations += 1;
|
|
266
|
+
const cacheRead = invocation.usage?.cacheReadInputTokens;
|
|
267
|
+
if (typeof cacheRead === "number" && Number.isFinite(cacheRead)) cacheReadTokens += cacheRead;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (runRevisions > 0) {
|
|
271
|
+
gateRevisions += runRevisions;
|
|
272
|
+
runsWithGateRevision += 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
runCount: runDirs.length,
|
|
277
|
+
runsWithGateRevision,
|
|
278
|
+
gateRevisions,
|
|
279
|
+
retryWorkerInvocations,
|
|
280
|
+
cacheReadTokens,
|
|
281
|
+
cacheReadPerRevision: gateRevisions > 0 ? cacheReadTokens / gateRevisions : null,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The two declared cohorts and the recorded comparison. `before` is the runs
|
|
287
|
+
* preceding the change commit and `after` those following it; when the after
|
|
288
|
+
* cohort records no gate revision the artifact says so explicitly rather than
|
|
289
|
+
* presenting an empty comparison. `changeCommit` is recorded so the cohort
|
|
290
|
+
* boundary is auditable.
|
|
291
|
+
*
|
|
292
|
+
* @param {CacheReadCohort} before
|
|
293
|
+
* @param {CacheReadCohort} after
|
|
294
|
+
* @param {{changeCommit?: string|null, generatedAt?: string, runDirs?: {before: string[], after: string[]}}} [context]
|
|
295
|
+
* @returns {Record<string, unknown>}
|
|
296
|
+
*/
|
|
297
|
+
export function cacheReadPerRevisionArtifact(before, after, context = {}) {
|
|
298
|
+
const measured = after.gateRevisions > 0;
|
|
299
|
+
return {
|
|
300
|
+
schemaVersion: 1,
|
|
301
|
+
metric: "cacheReadPerRevision",
|
|
302
|
+
definition: "cache-read tokens attributable to gate revisions divided by the number of gate revisions",
|
|
303
|
+
attribution: "gateRevisions is the sum of persisted node revisions; cacheReadTokens is the cache-read tokens on worker invocations after the first attempt, the retries that include those gate revisions",
|
|
304
|
+
changeCommit: context.changeCommit ?? null,
|
|
305
|
+
generatedAt: context.generatedAt ?? new Date().toISOString(),
|
|
306
|
+
status: measured ? "measured" : "no_post_change_revision",
|
|
307
|
+
note: measured
|
|
308
|
+
? "the after cohort records at least one gate revision; before and after are compared below"
|
|
309
|
+
: "no post-change gate revision exists: the after cohort records zero gate revisions, so no comparison is fabricated",
|
|
310
|
+
before,
|
|
311
|
+
after,
|
|
312
|
+
...(context.runDirs ? { runDirs: context.runDirs } : {}),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Write the measurement to its named artifact path.
|
|
318
|
+
*
|
|
319
|
+
* @param {string} artifactPath
|
|
320
|
+
* @param {CacheReadCohort} before
|
|
321
|
+
* @param {CacheReadCohort} after
|
|
322
|
+
* @param {{changeCommit?: string|null, generatedAt?: string, runDirs?: {before: string[], after: string[]}}} [context]
|
|
323
|
+
* @returns {string} the artifact path
|
|
324
|
+
*/
|
|
325
|
+
export function writeCacheReadPerRevisionArtifact(artifactPath, before, after, context = {}) {
|
|
326
|
+
writeJsonAtomic(artifactPath, cacheReadPerRevisionArtifact(before, after, context));
|
|
327
|
+
return artifactPath;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** @param {string} runDir @returns {string[]} */
|
|
331
|
+
function listNodeSnapshotFiles(runDir) {
|
|
332
|
+
try {
|
|
333
|
+
return readdirSync(join(runDir, "nodes")).filter((name) => name.endsWith(".json"));
|
|
334
|
+
} catch {
|
|
335
|
+
return [];
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator harness registry: how to launch an interactive harness, how to
|
|
3
|
+
* recognize it from the environment, and whether it can render ambient state.
|
|
4
|
+
*
|
|
5
|
+
* The seat is where a human talks to a harness; it is not a second worker
|
|
6
|
+
* engine. These five entries are the whole operator surface and each one
|
|
7
|
+
* declares exactly three facts. The worker registry in `harnesses/` answers a
|
|
8
|
+
* different question with a much larger interface, and keeping the two apart is
|
|
9
|
+
* why this is one declarative file instead of a folder per harness.
|
|
10
|
+
*
|
|
11
|
+
* `canRenderAmbient` is true only for claude, the single harness with a
|
|
12
|
+
* statusLine surface (TECH-SPEC 2026-09-12 §3.2). An `envMarker` is the
|
|
13
|
+
* variable the harness leaves in the shell it starts, so the registry can name
|
|
14
|
+
* the harness a seat is already inside without probing a process table.
|
|
15
|
+
*
|
|
16
|
+
* @typedef {Readonly<{argv: readonly string[], envMarker: string, canRenderAmbient: boolean}>} OperatorHarness
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** @type {Readonly<Record<string, OperatorHarness>>} */
|
|
20
|
+
export const OPERATOR_HARNESSES = Object.freeze({
|
|
21
|
+
claude: Object.freeze({ argv: Object.freeze(["claude"]), envMarker: "CLAUDECODE", canRenderAmbient: true }),
|
|
22
|
+
codex: Object.freeze({ argv: Object.freeze(["codex"]), envMarker: "CODEX_SANDBOX", canRenderAmbient: false }),
|
|
23
|
+
zcode: Object.freeze({ argv: Object.freeze(["zcode"]), envMarker: "ZCODE_MODEL", canRenderAmbient: false }),
|
|
24
|
+
dsh: Object.freeze({ argv: Object.freeze(["dsh"]), envMarker: "DSH_PERMISSION_MODE", canRenderAmbient: false }),
|
|
25
|
+
agy: Object.freeze({ argv: Object.freeze(["agy"]), envMarker: "AGY_MODEL", canRenderAmbient: false }),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} name
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
export function canRenderAmbient(name) {
|
|
33
|
+
const harness = /** @type {OperatorHarness|undefined} */ (OPERATOR_HARNESSES[name]);
|
|
34
|
+
return harness?.canRenderAmbient === true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The argv a seat launches for one harness, with an optional instruction
|
|
39
|
+
* appended as a single positional argument. `seat switch` passes the operator
|
|
40
|
+
* brief here so a fresh harness starts by reading what the previous one knew,
|
|
41
|
+
* and the registry stays the one place that knows each harness's base command.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} name
|
|
44
|
+
* @param {string|null} [instruction]
|
|
45
|
+
* @returns {string[]|null}
|
|
46
|
+
*/
|
|
47
|
+
export function launchArgv(name, instruction = null) {
|
|
48
|
+
const harness = /** @type {OperatorHarness|undefined} */ (OPERATOR_HARNESSES[name]);
|
|
49
|
+
if (!harness) return null;
|
|
50
|
+
return instruction ? [...harness.argv, instruction] : [...harness.argv];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The operator harness this process is running inside, or null. Detection is
|
|
55
|
+
* marker-based on purpose: the markers above are the only environment facts a
|
|
56
|
+
* harness guarantees, and reading them costs nothing.
|
|
57
|
+
*
|
|
58
|
+
* @param {Record<string, string|undefined>} [env]
|
|
59
|
+
* @returns {string|null}
|
|
60
|
+
*/
|
|
61
|
+
export function detectOperatorHarness(env = process.env) {
|
|
62
|
+
for (const [name, harness] of Object.entries(OPERATOR_HARNESSES)) {
|
|
63
|
+
if (env[harness.envMarker] !== undefined) return name;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The operator harness a pane's current command names, or null while the pane
|
|
70
|
+
* is still a shell or has moved on to something the registry does not know.
|
|
71
|
+
* The registry's launch argv is the contract: whatever `argv[0]` is, that is
|
|
72
|
+
* the command a live seat of that harness shows.
|
|
73
|
+
*
|
|
74
|
+
* @param {string|null|undefined} command
|
|
75
|
+
* @returns {string|null}
|
|
76
|
+
*/
|
|
77
|
+
export function detectOperatorHarnessByCommand(command) {
|
|
78
|
+
const name = String(command ?? "").split(/[\\/]/u).at(-1) ?? "";
|
|
79
|
+
for (const [harness, entry] of Object.entries(OPERATOR_HARNESSES)) {
|
|
80
|
+
if (entry.argv[0] === name) return harness;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seat facade the CLI calls: `startSeat`, `attachSeat`, `seatStatus`,
|
|
3
|
+
* `stopSeat`, `switchSeat`.
|
|
4
|
+
*
|
|
5
|
+
* It composes the tmux session layer with the operator harness registry and
|
|
6
|
+
* executes no tmux itself; every tmux fact comes back from `tmux.mjs` as an
|
|
7
|
+
* explicit result. The seat is where a human talks to a harness, never the
|
|
8
|
+
* engine that drives a run. `switchSeat` does write one durable file -- the
|
|
9
|
+
* operator brief under the campaign directory -- but nothing here reads or
|
|
10
|
+
* writes a run's lock, status or node snapshots, so swapping the pane cannot
|
|
11
|
+
* touch the controller that drives the run.
|
|
12
|
+
*/
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
import { OPERATOR_HARNESSES, canRenderAmbient, detectOperatorHarness, detectOperatorHarnessByCommand, launchArgv } from "./harnesses.mjs";
|
|
15
|
+
import { SEAT_SESSION, createSeatWindow, listSeatWindows, respawnSeatWindow, stopSeatSession, stopSeatWindow, tmuxAvailability } from "./tmux.mjs";
|
|
16
|
+
import { BRIEF_FILE } from "../campaign/layout.mjs";
|
|
17
|
+
import { briefFromState, materializeBrief } from "../campaign/brief.mjs";
|
|
18
|
+
import { readProjectionState } from "../campaign/projection.mjs";
|
|
19
|
+
import { resolveCampaign } from "../campaign/index.mjs";
|
|
20
|
+
import { errorMessage } from "../util.mjs";
|
|
21
|
+
|
|
22
|
+
/** `seat status --json` payload version. */
|
|
23
|
+
const SEAT_STATUS_SCHEMA_VERSION = 1;
|
|
24
|
+
|
|
25
|
+
/** @typedef {import("./tmux.mjs").SeatWindow} SeatWindow */
|
|
26
|
+
/** @typedef {{campaign: string, harness: string|null, canRenderAmbient: boolean, command: string|null, index: number|null}} SeatStatusEntry */
|
|
27
|
+
/** @typedef {{ok: boolean, available: boolean, reason: string|null, session: string, campaign: string, harness: string|null, window: string|null, command: string|null, attachCommand: string|null, stderr: string, message: string|null}} SeatStartResult */
|
|
28
|
+
/** @typedef {{ok: boolean, available: boolean, reason: string|null, session: string, campaign: string|null, harness: string|null, window: string|null, command: string|null, brief: string|null, attachCommand: string|null, stderr: string, message: string|null}} SeatSwitchResult */
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {{campaign: string, harness?: string, cwd?: string}} options
|
|
32
|
+
* @returns {SeatStartResult}
|
|
33
|
+
*/
|
|
34
|
+
export function startSeat(options) {
|
|
35
|
+
const campaign = options.campaign;
|
|
36
|
+
if (!campaign) return startFailure("campaign_required", "", null, "a campaign id is required");
|
|
37
|
+
const harness = options.harness ?? detectOperatorHarness();
|
|
38
|
+
if (!harness || !Object.prototype.hasOwnProperty.call(OPERATOR_HARNESSES, harness)) {
|
|
39
|
+
const known = Object.keys(OPERATOR_HARNESSES).join(", ");
|
|
40
|
+
return startFailure("unknown_harness", campaign, harness ?? null, `unknown harness ${harness ?? "(none detected)"}; choose one of ${known}`);
|
|
41
|
+
}
|
|
42
|
+
const created = createSeatWindow({
|
|
43
|
+
session: SEAT_SESSION,
|
|
44
|
+
window: campaign,
|
|
45
|
+
argv: /** @type {string[]} */ (launchArgv(harness)),
|
|
46
|
+
harness,
|
|
47
|
+
cwd: resolve(options.cwd ?? "."),
|
|
48
|
+
});
|
|
49
|
+
if (!created.ok) {
|
|
50
|
+
const message = created.available
|
|
51
|
+
? `tmux could not create the seat window: ${created.reason ?? "tmux_command_failed"}`
|
|
52
|
+
: "tmux is not available; cannot start the seat";
|
|
53
|
+
return { ...startFailure(created.reason ?? "tmux_command_failed", campaign, harness, message), available: created.available, stderr: created.stderr };
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
available: true,
|
|
58
|
+
reason: null,
|
|
59
|
+
session: SEAT_SESSION,
|
|
60
|
+
campaign,
|
|
61
|
+
harness,
|
|
62
|
+
window: campaign,
|
|
63
|
+
command: created.command,
|
|
64
|
+
attachCommand: attachCommandLine(SEAT_SESSION, campaign, null),
|
|
65
|
+
stderr: "",
|
|
66
|
+
message: null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {{campaign?: string, ssh?: string}} [options]
|
|
72
|
+
* @returns {{ok: boolean, available: boolean, reason: string|null, command: string|null, target: string, message: string|null}}
|
|
73
|
+
*/
|
|
74
|
+
export function attachSeat(options = {}) {
|
|
75
|
+
const target = options.campaign ? `${SEAT_SESSION}:${options.campaign}` : SEAT_SESSION;
|
|
76
|
+
// An SSH attach runs tmux on the far side, so the local binary is irrelevant.
|
|
77
|
+
if (options.ssh) {
|
|
78
|
+
return { ok: true, available: true, reason: null, command: attachCommandLine(SEAT_SESSION, options.campaign ?? null, options.ssh), target, message: null };
|
|
79
|
+
}
|
|
80
|
+
const availability = tmuxAvailability();
|
|
81
|
+
if (!availability.available) {
|
|
82
|
+
return { ok: false, available: false, reason: "tmux_unavailable", command: null, target, message: "tmux is not available; cannot reattach to the seat" };
|
|
83
|
+
}
|
|
84
|
+
return { ok: true, available: true, reason: null, command: attachCommandLine(SEAT_SESSION, options.campaign ?? null, null), target, message: null };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @returns {{schemaVersion: number, session: string, tmux: boolean, seats: SeatStatusEntry[], reason: string|null}}
|
|
89
|
+
*/
|
|
90
|
+
export function seatStatus() {
|
|
91
|
+
const listed = listSeatWindows(SEAT_SESSION);
|
|
92
|
+
if (!listed.available) {
|
|
93
|
+
return { schemaVersion: SEAT_STATUS_SCHEMA_VERSION, session: SEAT_SESSION, tmux: false, seats: [], reason: "tmux_unavailable" };
|
|
94
|
+
}
|
|
95
|
+
return { schemaVersion: SEAT_STATUS_SCHEMA_VERSION, session: SEAT_SESSION, tmux: true, seats: listed.windows.map(seatEntry), reason: listed.reason };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {{campaign?: string}} [options]
|
|
100
|
+
* @returns {{ok: boolean, available: boolean, reason: string|null, stopped: "window"|"session", stderr: string}}
|
|
101
|
+
*/
|
|
102
|
+
export function stopSeat(options = {}) {
|
|
103
|
+
const result = options.campaign ? stopSeatWindow(SEAT_SESSION, options.campaign) : stopSeatSession(SEAT_SESSION);
|
|
104
|
+
return { ok: result.ok, available: result.available, reason: result.reason, stopped: options.campaign ? "window" : "session", stderr: result.stderr };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Swap the harness in a campaign's seat window without touching the run. It
|
|
109
|
+
* materializes the operator brief from the campaign's durable facts, respawns
|
|
110
|
+
* the window on the new harness with that brief as its opening instruction,
|
|
111
|
+
* and returns. The controller lock, the run status and every node snapshot are
|
|
112
|
+
* never opened for writing: the brief lives under the campaign directory, and
|
|
113
|
+
* the only process this kills is the pane's.
|
|
114
|
+
*
|
|
115
|
+
* @param {{campaign?: string, harness?: string, cwd?: string}} options
|
|
116
|
+
* @returns {SeatSwitchResult}
|
|
117
|
+
*/
|
|
118
|
+
export function switchSeat(options) {
|
|
119
|
+
const cwd = resolve(options.cwd ?? ".");
|
|
120
|
+
const runsDir = join(cwd, ".runs");
|
|
121
|
+
let resolved;
|
|
122
|
+
try {
|
|
123
|
+
resolved = resolveCampaign(runsDir, options.campaign);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return switchFailure("campaign_not_found", options.campaign ?? null, options.harness ?? null, errorMessage(error));
|
|
126
|
+
}
|
|
127
|
+
const campaign = resolved.campaign.id;
|
|
128
|
+
const harness = options.harness;
|
|
129
|
+
if (!harness || !Object.prototype.hasOwnProperty.call(OPERATOR_HARNESSES, harness)) {
|
|
130
|
+
const known = Object.keys(OPERATOR_HARNESSES).join(", ");
|
|
131
|
+
return switchFailure("unknown_harness", campaign, harness ?? null, `unknown harness ${harness ?? "(none)"}; choose one of ${known}`);
|
|
132
|
+
}
|
|
133
|
+
const briefPath = join(resolved.path, BRIEF_FILE);
|
|
134
|
+
try {
|
|
135
|
+
const { state } = readProjectionState(resolved.path, resolved.campaign);
|
|
136
|
+
materializeBrief(resolved.path, briefFromState(resolved.campaign, state, []));
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return switchFailure("brief_failed", campaign, harness, `could not write the operator brief: ${errorMessage(error)}`);
|
|
139
|
+
}
|
|
140
|
+
const argv = /** @type {string[]} */ (launchArgv(harness, `Read ${briefPath} before continuing this campaign.`));
|
|
141
|
+
const respawned = respawnSeatWindow({ session: SEAT_SESSION, window: campaign, argv, harness, cwd });
|
|
142
|
+
if (!respawned.ok) {
|
|
143
|
+
const message = respawned.available
|
|
144
|
+
? `tmux could not respawn the seat window: ${respawned.reason ?? "tmux_command_failed"}`
|
|
145
|
+
: "tmux is not available; cannot switch the seat";
|
|
146
|
+
return { ...switchFailure(respawned.reason ?? "tmux_command_failed", campaign, harness, message), available: respawned.available, stderr: respawned.stderr };
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
available: true,
|
|
151
|
+
reason: null,
|
|
152
|
+
session: SEAT_SESSION,
|
|
153
|
+
campaign,
|
|
154
|
+
harness,
|
|
155
|
+
window: campaign,
|
|
156
|
+
command: respawned.command,
|
|
157
|
+
brief: briefPath,
|
|
158
|
+
attachCommand: attachCommandLine(SEAT_SESSION, campaign, null),
|
|
159
|
+
stderr: "",
|
|
160
|
+
message: null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The line to paste. `attach` must not run `tmux attach` itself: attaching
|
|
166
|
+
* from a child process nests sessions. The SSH form quotes the whole remote
|
|
167
|
+
* command so it survives the local shell.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} session
|
|
170
|
+
* @param {string|null} campaign
|
|
171
|
+
* @param {string|null} ssh
|
|
172
|
+
* @returns {string}
|
|
173
|
+
*/
|
|
174
|
+
function attachCommandLine(session, campaign, ssh) {
|
|
175
|
+
const attach = `tmux attach -t ${campaign ? `${session}:${campaign}` : session}`;
|
|
176
|
+
return ssh ? `ssh -t ${ssh} ${JSON.stringify(attach)}` : attach;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {SeatWindow} window
|
|
181
|
+
* @returns {SeatStatusEntry}
|
|
182
|
+
*/
|
|
183
|
+
function seatEntry(window) {
|
|
184
|
+
const harness = window.harness ?? detectOperatorHarnessByCommand(window.command);
|
|
185
|
+
return {
|
|
186
|
+
campaign: window.window,
|
|
187
|
+
harness,
|
|
188
|
+
canRenderAmbient: harness ? canRenderAmbient(harness) : false,
|
|
189
|
+
command: window.command,
|
|
190
|
+
index: window.index,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @param {string} reason
|
|
196
|
+
* @param {string} campaign
|
|
197
|
+
* @param {string|null} harness
|
|
198
|
+
* @param {string} message
|
|
199
|
+
* @returns {SeatStartResult}
|
|
200
|
+
*/
|
|
201
|
+
function startFailure(reason, campaign, harness, message) {
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
available: reason !== "tmux_unavailable",
|
|
205
|
+
reason,
|
|
206
|
+
session: SEAT_SESSION,
|
|
207
|
+
campaign,
|
|
208
|
+
harness,
|
|
209
|
+
window: null,
|
|
210
|
+
command: null,
|
|
211
|
+
attachCommand: null,
|
|
212
|
+
stderr: "",
|
|
213
|
+
message,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* @param {string} reason
|
|
219
|
+
* @param {string|null} campaign
|
|
220
|
+
* @param {string|null} harness
|
|
221
|
+
* @param {string} message
|
|
222
|
+
* @returns {SeatSwitchResult}
|
|
223
|
+
*/
|
|
224
|
+
function switchFailure(reason, campaign, harness, message) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
available: reason !== "tmux_unavailable",
|
|
228
|
+
reason,
|
|
229
|
+
session: SEAT_SESSION,
|
|
230
|
+
campaign,
|
|
231
|
+
harness,
|
|
232
|
+
window: null,
|
|
233
|
+
command: null,
|
|
234
|
+
brief: null,
|
|
235
|
+
attachCommand: null,
|
|
236
|
+
stderr: "",
|
|
237
|
+
message,
|
|
238
|
+
};
|
|
239
|
+
}
|