catherd-cli 1.3.0 → 1.5.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/README.md +144 -42
- package/catalog/ATTRIBUTION.md +12 -4
- package/catalog/models.json +8 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +150 -79
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +7 -1
- package/src/adapters/antigravity/index.ts +18 -3
- package/src/adapters/backend.ts +18 -2
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/index.ts +7 -1
- package/src/adapters/grok/home.ts +75 -20
- package/src/adapters/grok/index.ts +27 -8
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +53 -19
- package/src/domain/agents.ts +3 -1
- package/src/domain/brief.ts +91 -0
- package/src/domain/calibration.ts +7 -0
- package/src/domain/catalog.ts +12 -1
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +8 -3
- package/src/domain/errors.ts +4 -0
- package/src/domain/failover.ts +17 -5
- package/src/domain/gate-brief.ts +52 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/lane.ts +99 -3
- package/src/domain/notice.ts +14 -4
- package/src/domain/profile-rules.ts +129 -16
- package/src/domain/profile.ts +56 -5
- package/src/domain/record.ts +17 -2
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +56 -16
- package/src/domain/role-scope.ts +106 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/route.ts +73 -0
- package/src/domain/select.ts +177 -25
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/catalog-command.ts +18 -8
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +8 -3
- package/src/entry/doctor-command.ts +29 -2
- package/src/entry/host-arg.ts +56 -0
- package/src/entry/init-command.ts +20 -8
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +111 -10
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +8 -2
- package/src/entry/mcp/lane-tools.ts +55 -8
- package/src/entry/mcp/protocol-tools.ts +10 -4
- package/src/entry/mcp/push-tools.ts +16 -0
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +116 -0
- package/src/entry/mcp/run-tools.ts +25 -6
- package/src/entry/mcp/server.ts +224 -44
- package/src/entry/mcp/setup-tools.ts +11 -5
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +131 -16
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +157 -11
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +23 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +56 -16
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +3 -3
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +8 -4
- package/src/entry/tui/views/status.tsx +6 -2
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +86 -0
- package/src/infra/dispatch-dir.ts +3 -0
- package/src/infra/env.ts +15 -2
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- package/src/infra/host-context.ts +58 -0
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/paths.ts +35 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/sources/arena.ts +11 -3
- package/src/infra/sources/artificial-analysis.ts +8 -1
- package/src/infra/sources/cache.ts +2 -0
- package/src/infra/supervisor.ts +110 -18
- package/src/services/admission.ts +117 -26
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/budget.ts +26 -6
- package/src/services/catalog-service.ts +20 -12
- package/src/services/credentials.ts +11 -6
- package/src/services/dispatch-service.ts +220 -22
- package/src/services/dispatches.ts +36 -3
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +172 -38
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +13 -3
- package/src/services/gate-env.ts +218 -0
- package/src/services/gate-service.ts +216 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +258 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +377 -72
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +122 -23
- package/src/services/ports.ts +39 -4
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +117 -30
- package/src/services/profile-store.ts +31 -7
- package/src/services/protocol.ts +119 -25
- package/src/services/provenance.ts +16 -12
- package/src/services/questions.ts +15 -0
- package/src/services/reconcile.ts +22 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +150 -37
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +153 -19
- package/src/services/run-store.ts +119 -19
- package/src/services/runs-page.ts +45 -9
- package/src/services/scratch.ts +60 -0
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +34 -16
- package/src/services/setup.ts +31 -10
- package/src/services/source-derive.ts +28 -5
- package/src/services/source-sync.ts +9 -2
- package/src/services/standins.ts +40 -6
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +86 -11
- package/src/services/treat-likes.ts +41 -12
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +267 -0
- package/src/services/workspace-service.ts +254 -0
- package/src/services/workspace-store.ts +95 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { HostSessionRef } from "../domain/host.ts";
|
|
2
|
+
import { runCli } from "./cli.ts";
|
|
3
|
+
import { scrubSecrets } from "./env.ts";
|
|
4
|
+
|
|
5
|
+
export interface QueueCapability {
|
|
6
|
+
cli: boolean;
|
|
7
|
+
server: "supported" | "unsupported" | "unverified";
|
|
8
|
+
reason: string | null;
|
|
9
|
+
/** false when nothing was probed yet: `cli` then says nothing about the CLI */
|
|
10
|
+
checked?: false;
|
|
11
|
+
}
|
|
12
|
+
export type QueueSendResult =
|
|
13
|
+
| { outcome: "accepted"; msgId: string }
|
|
14
|
+
| { outcome: "not-submitted"; reason: string }
|
|
15
|
+
| { outcome: "ambiguous"; reason: string };
|
|
16
|
+
|
|
17
|
+
const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
|
18
|
+
const uuid = new RegExp(`^${UUID}$`, "i");
|
|
19
|
+
const receipt = new RegExp(`^Queued message (${UUID}) for thread (${UUID})\\.$`);
|
|
20
|
+
const TIMEOUT_MS = 10000;
|
|
21
|
+
|
|
22
|
+
export async function queueCapability(
|
|
23
|
+
env: Record<string, string | undefined>,
|
|
24
|
+
run: typeof runCli = runCli,
|
|
25
|
+
): Promise<QueueCapability> {
|
|
26
|
+
try {
|
|
27
|
+
const help = await run("codex", ["queue", "--help"], {
|
|
28
|
+
timeoutMs: TIMEOUT_MS,
|
|
29
|
+
env: scrubSecrets({ ...process.env, ...env }),
|
|
30
|
+
});
|
|
31
|
+
if (
|
|
32
|
+
!help?.ok ||
|
|
33
|
+
!/\bqueue\b/.test(help.out) ||
|
|
34
|
+
!["--thread", "--message", "--remote"].every((flag) => new RegExp(`${flag}\\s`).test(help.out))
|
|
35
|
+
)
|
|
36
|
+
return {
|
|
37
|
+
cli: false,
|
|
38
|
+
server: "unverified",
|
|
39
|
+
reason: "Native codex queue with --remote is unavailable; update Codex and use peek/result.",
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
cli: true,
|
|
43
|
+
server: "unverified",
|
|
44
|
+
reason: "Native server queue method enumeration is unavailable; help proves CLI support only.",
|
|
45
|
+
};
|
|
46
|
+
} catch {
|
|
47
|
+
return {
|
|
48
|
+
cli: false,
|
|
49
|
+
server: "unverified",
|
|
50
|
+
reason: "Could not inspect native codex queue; use peek/result.",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const PROBE_TTL_MS = 60_000;
|
|
56
|
+
let known: { at: number; capability: QueueCapability } | null = null;
|
|
57
|
+
let probing: Promise<void> | null = null;
|
|
58
|
+
|
|
59
|
+
/** The capability a one-shot command reports without probing: it never waits, and doctor checks it. */
|
|
60
|
+
export const UNCHECKED_QUEUE: QueueCapability = {
|
|
61
|
+
cli: false,
|
|
62
|
+
server: "unverified",
|
|
63
|
+
reason: "Native codex queue is not checked by this command; catherd doctor checks it.",
|
|
64
|
+
checked: false,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The last known queue capability, without waiting on the Codex CLI: peek and status never wait (spec §3.7).
|
|
69
|
+
* A missing or stale answer starts one probe in the background; until it lands the capability reads unchecked.
|
|
70
|
+
*/
|
|
71
|
+
export function knownQueueCapability(
|
|
72
|
+
env: Record<string, string | undefined>,
|
|
73
|
+
run: typeof runCli = runCli,
|
|
74
|
+
now: number = Date.now(),
|
|
75
|
+
): QueueCapability {
|
|
76
|
+
if (!probing && (!known || now - known.at >= PROBE_TTL_MS))
|
|
77
|
+
probing = queueCapability(env, run)
|
|
78
|
+
.then((capability) => {
|
|
79
|
+
known = { at: now, capability };
|
|
80
|
+
})
|
|
81
|
+
.finally(() => {
|
|
82
|
+
probing = null;
|
|
83
|
+
});
|
|
84
|
+
return (
|
|
85
|
+
known?.capability ?? {
|
|
86
|
+
cli: false,
|
|
87
|
+
server: "unverified",
|
|
88
|
+
reason: "Native codex queue is being checked; peek again for the result.",
|
|
89
|
+
checked: false,
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Settles the background probe knownQueueCapability started, if any (a test seam). */
|
|
95
|
+
export async function queueProbeSettled(): Promise<void> {
|
|
96
|
+
await probing;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Settles any background probe and forgets its answer (a test seam). */
|
|
100
|
+
export async function resetQueueProbe(): Promise<void> {
|
|
101
|
+
await probing;
|
|
102
|
+
known = null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function sendToCodexQueue(
|
|
106
|
+
target: HostSessionRef,
|
|
107
|
+
content: string,
|
|
108
|
+
env: Record<string, string | undefined>,
|
|
109
|
+
run: typeof runCli = runCli,
|
|
110
|
+
): Promise<QueueSendResult> {
|
|
111
|
+
if (target.host !== "codex" || target.sessionId.length !== 36 || !uuid.test(target.sessionId))
|
|
112
|
+
return {
|
|
113
|
+
outcome: "not-submitted",
|
|
114
|
+
reason: "Queue requires a validated original Codex thread UUID; use peek/result.",
|
|
115
|
+
};
|
|
116
|
+
if (!content.trim()) return { outcome: "not-submitted", reason: "Queue message is empty." };
|
|
117
|
+
const capability = await queueCapability(env, run);
|
|
118
|
+
if (!capability.cli || capability.server === "unsupported")
|
|
119
|
+
return {
|
|
120
|
+
outcome: "not-submitted",
|
|
121
|
+
reason: capability.reason ?? "Native queue is unavailable; use peek/result.",
|
|
122
|
+
};
|
|
123
|
+
try {
|
|
124
|
+
const args = ["queue", "--remote", "unix://", "--thread", target.sessionId, "--message", content];
|
|
125
|
+
const result = await run("codex", args, {
|
|
126
|
+
timeoutMs: TIMEOUT_MS,
|
|
127
|
+
env: scrubSecrets({ ...process.env, ...env }),
|
|
128
|
+
logArgs: [...args.slice(0, -1), "[redacted]"],
|
|
129
|
+
});
|
|
130
|
+
if (result === null)
|
|
131
|
+
return { outcome: "not-submitted", reason: "Codex executable is unavailable; use peek/result." };
|
|
132
|
+
const match = result.ok ? receipt.exec(result.out.trim()) : null;
|
|
133
|
+
if (match && match[2]?.toLowerCase() === target.sessionId.toLowerCase())
|
|
134
|
+
return { outcome: "accepted", msgId: match[1]! };
|
|
135
|
+
if (
|
|
136
|
+
!result.ok &&
|
|
137
|
+
!result.out.trim() &&
|
|
138
|
+
(/^Error: the remote app server does not support thread\/queue\/add; update or restart the remote app server(?:\n|$)/.test(
|
|
139
|
+
result.err,
|
|
140
|
+
) ||
|
|
141
|
+
/^error: (?:unrecognized subcommand 'queue'|unexpected argument '--remote' found)(?:\n|$)/.test(
|
|
142
|
+
result.err,
|
|
143
|
+
) ||
|
|
144
|
+
/^Error: failed to connect to remote app server at `unix:\/\/[^`\n]+`: [^\n]+(?:\n|$)/.test(
|
|
145
|
+
result.err,
|
|
146
|
+
))
|
|
147
|
+
)
|
|
148
|
+
return {
|
|
149
|
+
outcome: "not-submitted",
|
|
150
|
+
reason:
|
|
151
|
+
"Native queue refused before submission; check CLI/server support and the existing native endpoint, then use peek/result.",
|
|
152
|
+
};
|
|
153
|
+
return {
|
|
154
|
+
outcome: "ambiguous",
|
|
155
|
+
reason:
|
|
156
|
+
"Native queue invocation has no verified receipt; use peek/result. Retrying may duplicate input.",
|
|
157
|
+
};
|
|
158
|
+
} catch {
|
|
159
|
+
return {
|
|
160
|
+
outcome: "ambiguous",
|
|
161
|
+
reason:
|
|
162
|
+
"Native queue invocation failed without a verified receipt; use peek/result. Retrying may duplicate input.",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
4
|
+
import { sessionKey, type HostSessionRef } from "../domain/host.ts";
|
|
5
|
+
import { awaitsCollect, dispatchPaths } from "./dispatch-dir.ts";
|
|
6
|
+
import { withFileLockSync } from "./filelock.ts";
|
|
7
|
+
import { readVersioned, writeJsonAtomic } from "./store.ts";
|
|
8
|
+
|
|
9
|
+
const AttemptSchema = z
|
|
10
|
+
.looseObject({
|
|
11
|
+
attemptId: z.string().min(1),
|
|
12
|
+
target: z.object({
|
|
13
|
+
host: z.enum(["codex", "claude-code"]),
|
|
14
|
+
sessionId: z.string().min(1),
|
|
15
|
+
hostSessionId: z.string().nullable(),
|
|
16
|
+
name: z.string().nullable(),
|
|
17
|
+
}),
|
|
18
|
+
eventIds: z.array(z.string().min(1)).min(1),
|
|
19
|
+
at: z.iso.datetime(),
|
|
20
|
+
status: z.enum(["submitting", "accepted", "ambiguous", "failed"]),
|
|
21
|
+
msgId: z.string().nullable(),
|
|
22
|
+
reason: z.string().nullable(),
|
|
23
|
+
})
|
|
24
|
+
.refine((a) => a.status !== "accepted" || Boolean(a.msgId?.trim()), "accepted attempt needs a receipt");
|
|
25
|
+
const DeliverySchema = z.looseObject({ schema: z.literal(1), attempts: z.array(AttemptSchema) });
|
|
26
|
+
export type DeliveryAttempt = z.infer<typeof AttemptSchema>;
|
|
27
|
+
/** How a delivery to a role's thread is recorded (spec 1.5 plan 21); `status` warns about it. */
|
|
28
|
+
export const ROLE_THREAD_REFUSAL = "refused: the target is a role's thread";
|
|
29
|
+
|
|
30
|
+
export type DeliveryState = "pending" | "enqueue-accepted" | "ambiguous" | "collected";
|
|
31
|
+
|
|
32
|
+
export function readDelivery(dir: string): DeliveryAttempt[] {
|
|
33
|
+
const file = dispatchPaths(dir).delivery;
|
|
34
|
+
return existsSync(file)
|
|
35
|
+
? readVersioned(file, DeliverySchema, 1, {
|
|
36
|
+
fix: "Use peek/result; delivery evidence is unreadable and retry may duplicate input.",
|
|
37
|
+
}).attempts
|
|
38
|
+
: [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Short shared lock: distinct stalled/finished event claims must not lose each other's updates. */
|
|
42
|
+
export function writeDeliveryAttempt(dir: string, attempt: DeliveryAttempt): void {
|
|
43
|
+
const parsed = AttemptSchema.safeParse(attempt);
|
|
44
|
+
if (!parsed.success) throw new CatherdError("E_INPUT_INVALID", "Invalid delivery attempt");
|
|
45
|
+
const file = dispatchPaths(dir).delivery;
|
|
46
|
+
withFileLockSync(file, () => {
|
|
47
|
+
const document = existsSync(file)
|
|
48
|
+
? readVersioned(file, DeliverySchema, 1)
|
|
49
|
+
: { schema: 1 as const, attempts: [] as DeliveryAttempt[] };
|
|
50
|
+
const prior = document.attempts.findIndex((a) => a.attemptId === attempt.attemptId);
|
|
51
|
+
if (prior < 0) document.attempts.push(parsed.data);
|
|
52
|
+
else {
|
|
53
|
+
const sent = document.attempts[prior]!;
|
|
54
|
+
if (
|
|
55
|
+
JSON.stringify(sent.target) !== JSON.stringify(parsed.data.target) ||
|
|
56
|
+
JSON.stringify(sent.eventIds) !== JSON.stringify(attempt.eventIds) ||
|
|
57
|
+
sent.at !== attempt.at
|
|
58
|
+
)
|
|
59
|
+
throw new CatherdError(
|
|
60
|
+
"E_INPUT_INVALID",
|
|
61
|
+
"Delivery target, events and submission time are immutable",
|
|
62
|
+
);
|
|
63
|
+
document.attempts[prior] = { ...sent, ...parsed.data };
|
|
64
|
+
}
|
|
65
|
+
writeJsonAtomic(file, document);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function deliveryState(dir: string, target: HostSessionRef, eventId: string): DeliveryState {
|
|
70
|
+
try {
|
|
71
|
+
const event = JSON.parse(eventId) as unknown;
|
|
72
|
+
if (Array.isArray(event) && event[2] === "finished" && !awaitsCollect(dir)) return "collected";
|
|
73
|
+
} catch {
|
|
74
|
+
/* Invalid event text cannot imply collection. */
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const attempts = readDelivery(dir).filter(
|
|
78
|
+
(a) => sessionKey(a.target) === sessionKey(target) && a.eventIds.includes(eventId),
|
|
79
|
+
);
|
|
80
|
+
if (attempts.some((a) => a.status === "accepted")) return "enqueue-accepted";
|
|
81
|
+
if (attempts.some((a) => a.status === "submitting" || a.status === "ambiguous")) return "ambiguous";
|
|
82
|
+
return "pending";
|
|
83
|
+
} catch {
|
|
84
|
+
return "ambiguous";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -40,10 +40,13 @@ export function dispatchPaths(dir: string) {
|
|
|
40
40
|
failover: join(dir, "failover.json"),
|
|
41
41
|
/** the message that announced this dispatch's record to its session, once sent (plan 10) */
|
|
42
42
|
notified: join(dir, "notified.json"),
|
|
43
|
+
delivery: join(dir, "delivery.json"),
|
|
43
44
|
/** written once by the supervisor when the worker went quiet for half its idle timeout, not busy (spec §3.6) */
|
|
44
45
|
stall: join(dir, "stall.json"),
|
|
45
46
|
/** the message that announced the stall, once sent */
|
|
46
47
|
stallNotified: join(dir, "stall-notified.json"),
|
|
48
|
+
/** the role's own thread, written by the supervisor once the CLI names it (spec 1.5 plan 21) */
|
|
49
|
+
thread: join(dir, "thread.json"),
|
|
47
50
|
};
|
|
48
51
|
}
|
|
49
52
|
|
package/src/infra/env.ts
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* runs, a headless claude-code worker would inherit it): no process catherd starts gets them.
|
|
6
6
|
*/
|
|
7
7
|
const SECRET_ENV = new Set([
|
|
8
|
+
"CODEX_THREAD_ID",
|
|
9
|
+
"CODEX_SESSION_ID",
|
|
10
|
+
"CATHERD_ORCHESTRATION_HOST",
|
|
8
11
|
"TYPESAFE_API_KEY",
|
|
9
12
|
// spec 1.2 §9: the user's Artificial Analysis key reads scores for catherd alone
|
|
10
13
|
"ARTIFICIAL_ANALYSIS_API_KEY",
|
|
@@ -14,6 +17,9 @@ const SECRET_ENV = new Set([
|
|
|
14
17
|
"CLAUDE_CODE_HOST_SESSION_ID",
|
|
15
18
|
]);
|
|
16
19
|
|
|
20
|
+
/** Whether `name` is one of catherd's own secrets, which no process catherd starts ever gets. */
|
|
21
|
+
export const isCatherdSecret = (name: string): boolean => SECRET_ENV.has(name);
|
|
22
|
+
|
|
17
23
|
/** `base` without catherd's own secrets and without unset keys; for every process catherd starts. */
|
|
18
24
|
export function scrubSecrets(base: Record<string, string | undefined>): Record<string, string> {
|
|
19
25
|
const env: Record<string, string> = {};
|
|
@@ -39,7 +45,7 @@ export function workerEnv(
|
|
|
39
45
|
overrides: Record<string, string>,
|
|
40
46
|
cwd: string,
|
|
41
47
|
): Record<string, string> {
|
|
42
|
-
return { ...scrubSecrets(base
|
|
48
|
+
return { ...scrubSecrets({ ...base, ...overrides }), PWD: cwd };
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
/** What a preflight check may see; everything else, credentials included, stays out (spec §4.7). */
|
|
@@ -59,11 +65,18 @@ const CHECK_ENV = new Set([
|
|
|
59
65
|
"CATHERD_LOCK_SLOTS",
|
|
60
66
|
]);
|
|
61
67
|
const CHECK_ENV_PATTERN = /^(LC_[A-Z_]+|XDG_[A-Z_]+_(HOME|DIR|DIRS))$/;
|
|
68
|
+
/**
|
|
69
|
+
* Plan 23: where the toolchains a check runs find their daemon, proxy and caches (DOCKER_HOST for OrbStack or
|
|
70
|
+
* rootless Docker, a proxy, Go's and pnpm's caches); without them a check that would run fails as if broken.
|
|
71
|
+
*/
|
|
72
|
+
const TOOL_ENV_PATTERN =
|
|
73
|
+
/^(DOCKER_(HOST|CONTEXT|CONFIG|CERT_PATH|TLS_VERIFY)|TESTCONTAINERS_[A-Z_]+|(HTTPS?|NO|ALL)_PROXY|(https?|no|all)_proxy|GO(PATH|CACHE|MODCACHE|FLAGS|PROXY|PRIVATE|NOSUMDB|TOOLCHAIN)|PNPM_HOME|npm_config_store_dir|BUN_INSTALL(_CACHE_DIR)?|NVM_DIR|JAVA_HOME|CARGO_HOME|RUSTUP_HOME)$/;
|
|
62
74
|
|
|
63
75
|
/** A strict allowlist of `base` plus PWD, for lane-authored shell text such as preflight checks. */
|
|
64
76
|
export function checkEnv(base: Record<string, string | undefined>, cwd: string): Record<string, string> {
|
|
65
77
|
const env: Record<string, string> = {};
|
|
66
78
|
for (const [k, v] of Object.entries(base))
|
|
67
|
-
if (v !== undefined && (CHECK_ENV.has(k) || CHECK_ENV_PATTERN.test(k)))
|
|
79
|
+
if (v !== undefined && (CHECK_ENV.has(k) || CHECK_ENV_PATTERN.test(k) || TOOL_ENV_PATTERN.test(k)))
|
|
80
|
+
env[k] = v;
|
|
68
81
|
return { ...env, PWD: cwd };
|
|
69
82
|
}
|
package/src/infra/git.ts
CHANGED
|
@@ -54,6 +54,12 @@ export async function gitToplevel(dir: string): Promise<string | null> {
|
|
|
54
54
|
return r.kind === "ok" ? r.out.trim() || null : null;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** The repo's `origin` remote URL, or null when it has none (or git cannot say). */
|
|
58
|
+
export async function gitOrigin(repo: string): Promise<string | null> {
|
|
59
|
+
const r = await git(repo, ["config", "--get", "remote.origin.url"]);
|
|
60
|
+
return (r.kind === "ok" && r.out.trim()) || null;
|
|
61
|
+
}
|
|
62
|
+
|
|
57
63
|
/** The short HEAD, or null when git cannot say (no commit, not a repo, failure, timeout). */
|
|
58
64
|
export async function gitHead(repo: string, timeoutMs?: number): Promise<string | null> {
|
|
59
65
|
const r = await git(repo, ["rev-parse", "--short", "HEAD"], timeoutMs);
|
package/src/infra/heavy-lock.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
1
3
|
import { availableParallelism } from "node:os";
|
|
2
4
|
import { join } from "node:path";
|
|
3
|
-
import { tryLock } from "./filelock.ts";
|
|
5
|
+
import { tryLock, withFileLock } from "./filelock.ts";
|
|
4
6
|
import { locksDir } from "./paths.ts";
|
|
5
|
-
import {
|
|
7
|
+
import { isAlive, selfIdentity } from "./proc.ts";
|
|
8
|
+
import { ensurePrivateDir, writeJsonAtomic } from "./store.ts";
|
|
6
9
|
|
|
7
10
|
/** A profile's `lock.heavy` as a slot count: a number, or half the cores. */
|
|
8
11
|
export function heavySlots(setting: number | "cpus/2" | undefined): number {
|
|
@@ -17,20 +20,106 @@ export function heavySlots(setting: number | "cpus/2" | undefined): number {
|
|
|
17
20
|
export async function withHeavySlot<T>(
|
|
18
21
|
slots: number,
|
|
19
22
|
fn: (slot: number) => T | Promise<T>,
|
|
20
|
-
o: { pollMs?: number } = {},
|
|
23
|
+
o: { pollMs?: number; onWait?: () => void } = {},
|
|
21
24
|
): Promise<T> {
|
|
25
|
+
const r = await withHeavySlotWithin(slots, Number.POSITIVE_INFINITY, fn, o);
|
|
26
|
+
if (r.busy) throw new Error("unreachable: an unbounded wait never gives up");
|
|
27
|
+
return r.value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* withHeavySlot with a wait budget (plan 23): `{ busy: true }` when no slot came free within `waitMs`, so a
|
|
32
|
+
* caller with a deadline of its own (preflight) reports the lock as busy instead of timing out behind it.
|
|
33
|
+
*/
|
|
34
|
+
export async function withHeavySlotWithin<T>(
|
|
35
|
+
slots: number,
|
|
36
|
+
waitMs: number,
|
|
37
|
+
fn: (slot: number) => T | Promise<T>,
|
|
38
|
+
o: { pollMs?: number; onWait?: () => void } = {},
|
|
39
|
+
): Promise<{ busy: false; value: T } | { busy: true }> {
|
|
22
40
|
const dir = locksDir();
|
|
23
41
|
ensurePrivateDir(dir);
|
|
42
|
+
const deadline = Date.now() + waitMs;
|
|
24
43
|
for (;;) {
|
|
25
44
|
for (let i = 0; i < slots; i++) {
|
|
26
45
|
const release = tryLock(join(dir, `slot-${i}`));
|
|
27
46
|
if (!release) continue;
|
|
28
47
|
try {
|
|
29
|
-
return await fn(i);
|
|
48
|
+
return { busy: false, value: await fn(i) };
|
|
30
49
|
} finally {
|
|
31
50
|
release();
|
|
32
51
|
}
|
|
33
52
|
}
|
|
53
|
+
const left = deadline - Date.now();
|
|
54
|
+
if (left <= 0) return { busy: true };
|
|
55
|
+
o.onWait?.();
|
|
56
|
+
await Bun.sleep(Math.min(o.pollMs ?? 500, left));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A process inside a role lock: who, so a dead one is dropped, and a token, so one process can hold twice. */
|
|
61
|
+
interface RoleHolder {
|
|
62
|
+
pid: number;
|
|
63
|
+
startTime: string | null;
|
|
64
|
+
token: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface RoleLockState {
|
|
68
|
+
/** whose the lock is: a run id (holders of one run share it), or a lone process's own key */
|
|
69
|
+
owner: string;
|
|
70
|
+
holders: RoleHolder[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readRoleState(file: string): RoleLockState | null {
|
|
74
|
+
if (!existsSync(file)) return null;
|
|
75
|
+
try {
|
|
76
|
+
const v = JSON.parse(readFileSync(file, "utf8")) as RoleLockState;
|
|
77
|
+
return typeof v?.owner === "string" && Array.isArray(v.holders) ? v : null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Spec 1.5 "Cross-run verifier contention": one owner at a time machine-wide holds the lock of `role`.
|
|
85
|
+
* Commands of the same owner (one run's verifier, side by side within its slots) share it; another owner's
|
|
86
|
+
* wait until the last of them ends, so two runs' verifiers never overlap. A dead holder is dropped.
|
|
87
|
+
*/
|
|
88
|
+
export async function withRoleLock<T>(
|
|
89
|
+
role: string,
|
|
90
|
+
owner: string,
|
|
91
|
+
fn: () => T | Promise<T>,
|
|
92
|
+
o: { pollMs?: number; onWait?: () => void } = {},
|
|
93
|
+
): Promise<T> {
|
|
94
|
+
const dir = locksDir();
|
|
95
|
+
ensurePrivateDir(dir);
|
|
96
|
+
const file = join(dir, `role-${role}.json`);
|
|
97
|
+
const me: RoleHolder = { ...selfIdentity(), token: randomUUID() };
|
|
98
|
+
const enter = () =>
|
|
99
|
+
withFileLock(file, () => {
|
|
100
|
+
const state = readRoleState(file);
|
|
101
|
+
const live = (state?.holders ?? []).filter((h) => isAlive(h.pid, h.startTime));
|
|
102
|
+
if (live.length && state?.owner !== owner) return false;
|
|
103
|
+
writeJsonAtomic(file, { owner, holders: [...live, me] } satisfies RoleLockState);
|
|
104
|
+
return true;
|
|
105
|
+
});
|
|
106
|
+
while (!(await enter())) {
|
|
107
|
+
o.onWait?.();
|
|
34
108
|
await Bun.sleep(o.pollMs ?? 500);
|
|
35
109
|
}
|
|
110
|
+
try {
|
|
111
|
+
return await fn();
|
|
112
|
+
} finally {
|
|
113
|
+
await withFileLock(file, () => {
|
|
114
|
+
const state = readRoleState(file);
|
|
115
|
+
if (state)
|
|
116
|
+
writeJsonAtomic(file, { ...state, holders: state.holders.filter((h) => h.token !== me.token) });
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Who holds the role lock now, or null. */
|
|
122
|
+
export function roleLockOwner(role: string): string | null {
|
|
123
|
+
const state = readRoleState(join(locksDir(), `role-${role}.json`));
|
|
124
|
+
return state && state.holders.some((h) => isAlive(h.pid, h.startTime)) ? state.owner : null;
|
|
36
125
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { HostContext, KnownHost, OrchestrationHost } from "../domain/host.ts";
|
|
2
|
+
import { readSessionEnv, sessionFileFor } from "./claude-session.ts";
|
|
3
|
+
|
|
4
|
+
export function resolveHost(e: {
|
|
5
|
+
clientName?: string;
|
|
6
|
+
env: Record<string, string | undefined>;
|
|
7
|
+
terminalHost?: OrchestrationHost;
|
|
8
|
+
}): HostContext {
|
|
9
|
+
const conflict = (message: string): HostContext => ({ host: "unknown", session: null, conflict: message });
|
|
10
|
+
const hosts = new Set<KnownHost>();
|
|
11
|
+
const client = e.clientName;
|
|
12
|
+
if (client === "codex-mcp-client") hosts.add("codex");
|
|
13
|
+
if (client === "claude-code") hosts.add("claude-code");
|
|
14
|
+
const launcher = e.env.CATHERD_ORCHESTRATION_HOST;
|
|
15
|
+
if (launcher === "codex" || launcher === "claude-code") hosts.add(launcher);
|
|
16
|
+
const claude = readSessionEnv(e.env);
|
|
17
|
+
if (claude) hosts.add("claude-code");
|
|
18
|
+
// UUIDs are case-insensitive: one spelling, so the launcher's and a request's casing name the same thread
|
|
19
|
+
const thread = e.env.CODEX_THREAD_ID?.toLowerCase();
|
|
20
|
+
const session = e.env.CODEX_SESSION_ID?.toLowerCase();
|
|
21
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
22
|
+
if (thread !== undefined || session !== undefined) {
|
|
23
|
+
hosts.add("codex");
|
|
24
|
+
if ((thread !== undefined && !uuid.test(thread)) || (session !== undefined && !uuid.test(session)))
|
|
25
|
+
return conflict("Codex session IDs must be UUIDs; remove invalid CODEX_THREAD_ID / CODEX_SESSION_ID");
|
|
26
|
+
if (thread && session && thread !== session)
|
|
27
|
+
return conflict("CODEX_THREAD_ID and CODEX_SESSION_ID disagree; correct the launcher environment");
|
|
28
|
+
}
|
|
29
|
+
if (hosts.size > 1)
|
|
30
|
+
return conflict(
|
|
31
|
+
"Initialized client and launcher session evidence disagree; remove the other host's environment",
|
|
32
|
+
);
|
|
33
|
+
const host = [...hosts][0] ?? e.terminalHost ?? "unknown";
|
|
34
|
+
if (host === "codex")
|
|
35
|
+
return {
|
|
36
|
+
host,
|
|
37
|
+
session:
|
|
38
|
+
thread || session ? { host, sessionId: thread || session!, hostSessionId: null, name: null } : null,
|
|
39
|
+
conflict: null,
|
|
40
|
+
};
|
|
41
|
+
if (host === "claude-code" && claude) {
|
|
42
|
+
const file = sessionFileFor(claude);
|
|
43
|
+
const id = file?.sessionId ?? claude.sessionId;
|
|
44
|
+
return {
|
|
45
|
+
host,
|
|
46
|
+
session: id
|
|
47
|
+
? {
|
|
48
|
+
host,
|
|
49
|
+
sessionId: id,
|
|
50
|
+
hostSessionId: file?.hostSessionId ?? claude.hostSessionId,
|
|
51
|
+
name: file?.name ?? null,
|
|
52
|
+
}
|
|
53
|
+
: null,
|
|
54
|
+
conflict: null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return { host, session: null, conflict: null };
|
|
58
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Plan 23: a host probe that fails once may have caught the network mid-change (a VPN just dropped, a route
|
|
2
|
+
// still settling). catherd's own host probes run twice, this far apart, before they call the host blocked.
|
|
3
|
+
|
|
4
|
+
/** The pause before the second try; tests shorten it. */
|
|
5
|
+
export const hostProbe = { retryMs: 5_000 };
|
|
6
|
+
|
|
7
|
+
/** `probe`, and once more after hostProbe.retryMs when `ok` says the first answer failed. */
|
|
8
|
+
export async function probeTwice<T>(
|
|
9
|
+
probe: () => Promise<T>,
|
|
10
|
+
ok: (r: T) => boolean,
|
|
11
|
+
): Promise<{ result: T; attempts: 1 | 2 }> {
|
|
12
|
+
const first = await probe();
|
|
13
|
+
if (ok(first)) return { result: first, attempts: 1 };
|
|
14
|
+
await Bun.sleep(hostProbe.retryMs);
|
|
15
|
+
return { result: await probe(), attempts: 2 };
|
|
16
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { locksDir } from "./paths.ts";
|
|
4
|
+
import { isAlive, processStartTime } from "./proc.ts";
|
|
5
|
+
import { ensurePrivateDir, writeJsonAtomic } from "./store.ts";
|
|
6
|
+
|
|
7
|
+
// Plan 23: a role's long gate runs behind `catherd lock`. While such a lock child is alive and writing, the
|
|
8
|
+
// role's wall timeout counts from its last output, not from the role's start. The lock writes here, in the
|
|
9
|
+
// locks dir every role may write; the supervisor reads it.
|
|
10
|
+
|
|
11
|
+
/** The env var the supervisor's spec carries, so a `catherd lock` inside the role knows its dispatch. */
|
|
12
|
+
export const DISPATCH_ID_ENV = "CATHERD_DISPATCH_ID";
|
|
13
|
+
|
|
14
|
+
/** A lock child reports at most this often, so a chatty command does not write a file per line. */
|
|
15
|
+
export const ACTIVITY_EVERY_MS = 1_000;
|
|
16
|
+
|
|
17
|
+
const SAFE_ID = /^[A-Za-z0-9][\w.-]{0,127}$/;
|
|
18
|
+
|
|
19
|
+
/** `<locks>/activity/<dispatch id>`: one file per live `catherd lock` of that dispatch. */
|
|
20
|
+
export const activityDir = (dispatchId: string): string => join(locksDir(), "activity", dispatchId);
|
|
21
|
+
|
|
22
|
+
interface Activity {
|
|
23
|
+
schema: 1;
|
|
24
|
+
pid: number;
|
|
25
|
+
startTime: string | null;
|
|
26
|
+
/** the last time the lock's command wrote output (its start, before the first line) */
|
|
27
|
+
at: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A reporter for this `catherd lock` process, or null outside a dispatch (no id, or one that is not a plain
|
|
32
|
+
* id: it names a path). `tick` records output, at most once per ACTIVITY_EVERY_MS; `done` removes the file.
|
|
33
|
+
*/
|
|
34
|
+
export function activityReporter(
|
|
35
|
+
dispatchId: string | undefined,
|
|
36
|
+
now: () => number = Date.now,
|
|
37
|
+
everyMs = ACTIVITY_EVERY_MS,
|
|
38
|
+
): { tick: () => void; done: () => void } | null {
|
|
39
|
+
if (!dispatchId || !SAFE_ID.test(dispatchId) || dispatchId.includes("..")) return null;
|
|
40
|
+
const dir = activityDir(dispatchId);
|
|
41
|
+
const file = join(dir, `${process.pid}.json`);
|
|
42
|
+
const startTime = processStartTime(process.pid);
|
|
43
|
+
let last = 0;
|
|
44
|
+
const write = (t: number): void => {
|
|
45
|
+
try {
|
|
46
|
+
ensurePrivateDir(dir);
|
|
47
|
+
writeJsonAtomic(file, {
|
|
48
|
+
schema: 1,
|
|
49
|
+
pid: process.pid,
|
|
50
|
+
startTime,
|
|
51
|
+
at: new Date(t).toISOString(),
|
|
52
|
+
} satisfies Activity);
|
|
53
|
+
last = t;
|
|
54
|
+
} catch {
|
|
55
|
+
// advisory: a lock that cannot report still runs its command
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
write(now());
|
|
59
|
+
return {
|
|
60
|
+
tick: () => {
|
|
61
|
+
const t = now();
|
|
62
|
+
if (t - last >= everyMs) write(t);
|
|
63
|
+
},
|
|
64
|
+
done: () => rmSync(file, { force: true }),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* When a live `catherd lock` of the dispatch last wrote output, in ms; null when none is alive. A file whose
|
|
70
|
+
* process is gone is left alone (its lock may still be finishing its own cleanup) and never counts.
|
|
71
|
+
*/
|
|
72
|
+
export function lastLockOutput(dispatchId: string): number | null {
|
|
73
|
+
const dir = activityDir(dispatchId);
|
|
74
|
+
if (!existsSync(dir)) return null;
|
|
75
|
+
let latest: number | null = null;
|
|
76
|
+
for (const name of readdirSync(dir)) {
|
|
77
|
+
if (!name.endsWith(".json")) continue;
|
|
78
|
+
try {
|
|
79
|
+
const a = JSON.parse(readFileSync(join(dir, name), "utf8")) as Activity;
|
|
80
|
+
if (a.schema !== 1 || !isAlive(a.pid, a.startTime)) continue;
|
|
81
|
+
const at = Date.parse(a.at);
|
|
82
|
+
if (!Number.isNaN(at)) latest = Math.max(latest ?? at, at);
|
|
83
|
+
} catch {
|
|
84
|
+
// a file being written, or damaged: skipped this poll
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return latest;
|
|
88
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { scrubSecrets } from "./env.ts";
|
|
2
|
+
|
|
3
|
+
// Plan 23: what a login shell of the user's sees (DOCKER_HOST for OrbStack or rootless Docker, a proxy, a
|
|
4
|
+
// toolchain's PATH), which a server started by a desktop app or a plugin launcher may not inherit.
|
|
5
|
+
// `$SHELL -lc env` is run once per process and kept.
|
|
6
|
+
|
|
7
|
+
/** How long the login shell may take; one that hangs (a prompt in a profile) counts as an empty env. */
|
|
8
|
+
export const loginShell = { timeoutMs: 5_000 };
|
|
9
|
+
|
|
10
|
+
let captured: Record<string, string> | null = null;
|
|
11
|
+
|
|
12
|
+
/** Parses `env -0` output, or plain `env` lines from a shell whose env has no -0. */
|
|
13
|
+
export function parseEnv(out: string): Record<string, string> {
|
|
14
|
+
const env: Record<string, string> = {};
|
|
15
|
+
const rows = out.includes("\0") ? out.split("\0") : out.split("\n");
|
|
16
|
+
for (const row of rows) {
|
|
17
|
+
const eq = row.indexOf("=");
|
|
18
|
+
if (eq > 0) env[row.slice(0, eq)] = row.slice(eq + 1);
|
|
19
|
+
}
|
|
20
|
+
return env;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function capture(): Record<string, string> {
|
|
24
|
+
const shell = process.env.SHELL || "/bin/sh";
|
|
25
|
+
try {
|
|
26
|
+
const r = Bun.spawnSync([shell, "-lc", "env -0 2>/dev/null || env"], {
|
|
27
|
+
env: scrubSecrets(process.env),
|
|
28
|
+
stdin: "ignore",
|
|
29
|
+
stdout: "pipe",
|
|
30
|
+
stderr: "ignore",
|
|
31
|
+
timeout: loginShell.timeoutMs,
|
|
32
|
+
});
|
|
33
|
+
return r.exitCode === 0 ? scrubSecrets(parseEnv(r.stdout.toString())) : {};
|
|
34
|
+
} catch {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The user's login environment, without catherd's secrets; empty when the shell cannot say. Captured once. */
|
|
40
|
+
export function loginEnv(): Record<string, string> {
|
|
41
|
+
captured ??= capture();
|
|
42
|
+
return captured;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Forgets the captured env (tests, which change SHELL). */
|
|
46
|
+
export function resetLoginEnv(): void {
|
|
47
|
+
captured = null;
|
|
48
|
+
}
|