catherd-cli 1.5.0 → 2.0.0-beta.20261022113309
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 +164 -208
- package/THIRD_PARTY_NOTICES.md +217 -35
- package/catalog/jev.json +27 -1
- package/catalog/models.json +56 -247
- package/package.json +6 -8
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +97 -421
- package/plugin/skills/catherd-setup/SKILL.md +20 -19
- package/src/adapters/all.ts +0 -4
- package/src/adapters/backend.ts +1 -2
- package/src/adapters/cursor/home.ts +1 -1
- package/src/adapters/cursor/index.ts +4 -4
- package/src/cli.ts +22 -27
- package/src/domain/bars.ts +33 -107
- package/src/domain/blocked.ts +72 -0
- package/src/domain/branch.ts +51 -0
- package/src/domain/brief.ts +21 -6
- package/src/domain/catalog.ts +69 -324
- package/src/domain/cost.ts +15 -30
- package/src/domain/errors.ts +2 -2
- package/src/domain/fail-lines.ts +26 -0
- package/src/domain/failover.ts +37 -87
- package/src/domain/findings.ts +47 -0
- package/src/domain/gate-brief.ts +1 -0
- package/src/domain/ids.ts +13 -2
- package/src/domain/jev.ts +47 -2
- package/src/domain/judge.ts +124 -0
- package/src/domain/lane.ts +22 -3
- package/src/domain/milestone-memory.ts +53 -0
- package/src/domain/notice.ts +25 -8
- package/src/domain/notify.ts +32 -0
- package/src/domain/plan-doc.ts +198 -0
- package/src/domain/plan-lint.ts +141 -0
- package/src/domain/profile-rules.ts +101 -160
- package/src/domain/profile.ts +116 -63
- package/src/domain/record.ts +53 -2
- package/src/domain/role-prompts.ts +131 -45
- package/src/domain/role-scope.ts +4 -34
- package/src/domain/role-tools.ts +2 -2
- package/src/domain/roles.ts +2 -0
- package/src/domain/route.ts +17 -0
- package/src/domain/rules.ts +33 -0
- package/src/domain/run-events.ts +298 -0
- package/src/domain/run-machine.ts +668 -0
- package/src/domain/run-state.ts +1310 -0
- package/src/domain/scorecard.ts +197 -0
- package/src/domain/select.ts +151 -146
- package/src/domain/slopalytics.ts +72 -0
- package/src/entry/answer-command.ts +39 -0
- package/src/entry/catalog-command.ts +25 -174
- package/src/entry/check-bin.ts +17 -0
- package/src/entry/cli-kit.ts +1 -1
- package/src/entry/daemon-bin.ts +22 -0
- package/src/entry/glyphs.ts +15 -2
- package/src/entry/init-command.ts +18 -68
- package/src/entry/knowledge-command.ts +45 -4
- package/src/entry/lock-command.ts +6 -7
- package/src/entry/mcp/role-server.ts +18 -0
- package/src/entry/mcp/server.ts +13 -40
- package/src/entry/mcp/session-tools.ts +149 -0
- package/src/entry/panel/openapi.ts +189 -0
- package/src/entry/panel/server.ts +410 -0
- package/src/entry/panel-command.ts +68 -0
- package/src/entry/profile-command.ts +10 -14
- package/src/entry/role-cli-command.ts +47 -3
- package/src/entry/runs-command.ts +3 -19
- package/src/entry/scorecard-command.ts +187 -0
- package/src/entry/supervise-bin.ts +1 -1
- package/src/infra/env.ts +0 -2
- package/src/infra/git.ts +64 -0
- package/src/infra/heavy-lock.ts +45 -7
- package/src/infra/launch.ts +63 -7
- package/src/infra/memory.ts +45 -0
- package/src/infra/ntfy.ts +40 -0
- package/src/infra/open-url.ts +42 -0
- package/src/infra/paths.ts +2 -2
- package/src/infra/slopalytics.ts +30 -0
- package/src/infra/store.ts +6 -1
- package/src/services/admission.ts +24 -9
- package/src/services/backends.ts +4 -6
- package/src/services/capture.ts +1 -22
- package/src/services/catalog-service.ts +155 -262
- package/src/services/credentials.ts +4 -20
- package/src/services/daemon.ts +181 -0
- package/src/services/dispatch-service.ts +5 -1
- package/src/services/doctor-backends.ts +1 -1
- package/src/services/doctor-checks.ts +47 -0
- package/src/services/doctor.ts +4 -9
- package/src/services/failover-rungs.ts +109 -0
- package/src/services/finalize.ts +12 -0
- package/src/services/gate-env.ts +1 -2
- package/src/services/gate-service.ts +21 -6
- package/src/services/jev-service.ts +2 -4
- package/src/services/lane-service.ts +34 -3
- package/src/services/milestone-check.ts +433 -0
- package/src/services/milestone-tree.ts +170 -0
- package/src/services/milestones.ts +35 -8
- package/src/services/notifier.ts +69 -2
- package/src/services/notify-service.ts +26 -0
- package/src/services/panel-setup.ts +116 -0
- package/src/services/plan-input.ts +151 -0
- package/src/services/ports.ts +30 -4
- package/src/services/preflight.ts +82 -8
- package/src/services/profile-service.ts +30 -3
- package/src/services/profile-store.ts +8 -3
- package/src/services/protocol.ts +4 -0
- package/src/services/provenance.ts +31 -90
- package/src/services/questions.ts +9 -0
- package/src/services/reconcile.ts +11 -0
- package/src/services/routing-service.ts +26 -15
- package/src/services/run-briefs.ts +477 -0
- package/src/services/run-client.ts +217 -0
- package/src/services/run-effects.ts +837 -0
- package/src/services/run-events-store.ts +174 -0
- package/src/services/run-evidence.ts +1 -1
- package/src/services/run-read.ts +221 -0
- package/src/services/run-report.ts +176 -0
- package/src/services/run-service.ts +55 -6
- package/src/services/run-store.ts +54 -7
- package/src/services/scorecard-metrics.ts +86 -0
- package/src/services/scorecard.ts +382 -0
- package/src/services/summary.ts +27 -3
- package/src/services/tree-path.ts +27 -0
- package/src/services/workspace-admission.ts +5 -13
- package/src/services/workspace-service.ts +5 -0
- package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js +1 -0
- package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
- package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js +1 -0
- package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-B6GiwDkh.js +11 -0
- package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
- package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
- package/web/dist/assets/index-C5IbylvM.css +2 -0
- package/web/dist/assets/index-C5IbylvM.css.br +0 -0
- package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
- package/web/dist/assets/label-DFSYBc62.js +1 -0
- package/web/dist/assets/label-DFSYBc62.js.br +0 -0
- package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
- package/web/dist/assets/link-D8EcKZUt.js +1 -0
- package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
- package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
- package/web/dist/assets/login-DaN5HPPW.js +1 -0
- package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
- package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
- package/web/dist/assets/query-Cu--ccT3.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
- package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
- package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
- package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
- package/web/dist/assets/table-BOxXhqS4.js +1 -0
- package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
- package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
- package/web/dist/assets/time--9kSQbbp.js +1 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
- package/web/dist/index.html +15 -0
- package/catalog/ATTRIBUTION.md +0 -44
- package/catalog/scores.json +0 -3202
- package/catalog/sources.json +0 -97
- package/src/adapters/antigravity/events.ts +0 -131
- package/src/adapters/antigravity/home.ts +0 -46
- package/src/adapters/antigravity/index.ts +0 -335
- package/src/adapters/antigravity/models.ts +0 -37
- package/src/adapters/grok/events.ts +0 -112
- package/src/adapters/grok/home.ts +0 -187
- package/src/adapters/grok/index.ts +0 -358
- package/src/adapters/grok/models.ts +0 -48
- package/src/domain/calibration.ts +0 -132
- package/src/domain/sources.ts +0 -144
- package/src/entry/mcp/dispatch-tools.ts +0 -52
- package/src/entry/mcp/lane-tools.ts +0 -131
- package/src/entry/mcp/protocol-tools.ts +0 -63
- package/src/entry/mcp/push-tools.ts +0 -16
- package/src/entry/mcp/run-tools.ts +0 -164
- package/src/entry/mcp/setup-tools.ts +0 -109
- package/src/entry/mcp/workspace-tools.ts +0 -142
- package/src/entry/tui/commands.ts +0 -563
- package/src/entry/tui/effects.ts +0 -385
- package/src/entry/tui/fixtures.ts +0 -637
- package/src/entry/tui/profile-edits.ts +0 -146
- package/src/entry/tui/profile-tree.ts +0 -429
- package/src/entry/tui/providers/app.tsx +0 -162
- package/src/entry/tui/providers/boundary.tsx +0 -57
- package/src/entry/tui/providers/data.tsx +0 -160
- package/src/entry/tui/providers/keymap.tsx +0 -166
- package/src/entry/tui/providers/root.tsx +0 -45
- package/src/entry/tui/providers/theme.tsx +0 -17
- package/src/entry/tui/providers/toast.tsx +0 -77
- package/src/entry/tui/run.tsx +0 -114
- package/src/entry/tui/state.ts +0 -400
- package/src/entry/tui/stories.ts +0 -114
- package/src/entry/tui/text.ts +0 -130
- package/src/entry/tui/theme.ts +0 -140
- package/src/entry/tui/views/app.tsx +0 -208
- package/src/entry/tui/views/dialogs.tsx +0 -41
- package/src/entry/tui/views/profile-actions.ts +0 -331
- package/src/entry/tui/views/profiles.tsx +0 -520
- package/src/entry/tui/views/runs.tsx +0 -619
- package/src/entry/tui/views/save-dialog.tsx +0 -344
- package/src/entry/tui/views/status.tsx +0 -213
- package/src/entry/tui/widgets/chrome.tsx +0 -114
- package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
- package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
- package/src/entry/tui/widgets/dialog-select.tsx +0 -212
- package/src/entry/tui/widgets/dialog.tsx +0 -68
- package/src/entry/tui/widgets/line.tsx +0 -92
- package/src/entry/tui/widgets/list.tsx +0 -197
- package/src/entry/tui/widgets/spinner.tsx +0 -49
- package/src/entry/tui/widgets/toast.tsx +0 -62
- package/src/infra/sources/arena.ts +0 -95
- package/src/infra/sources/artificial-analysis.ts +0 -119
- package/src/infra/sources/cache.ts +0 -126
- package/src/infra/sources/csv.ts +0 -38
- package/src/infra/sources/epoch.ts +0 -82
- package/src/infra/sources/http.ts +0 -75
- package/src/infra/sources/litellm.ts +0 -58
- package/src/infra/sources/models-dev.ts +0 -59
- package/src/infra/sources/openrouter-endpoints.ts +0 -52
- package/src/infra/sources/openrouter-models.ts +0 -60
- package/src/infra/sources/rows.ts +0 -38
- package/src/infra/sources/vectara.ts +0 -69
- package/src/infra/sources/zip.ts +0 -48
- package/src/services/catalog-refresh.ts +0 -201
- package/src/services/doctor-sources.ts +0 -76
- package/src/services/runs-page.ts +0 -290
- package/src/services/source-derive.ts +0 -397
- package/src/services/source-sync.ts +0 -312
- package/src/services/standins.ts +0 -238
- package/src/services/treat-likes.ts +0 -113
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { type Tokens, ZERO_TOKENS } from "../../domain/record.ts";
|
|
2
|
-
|
|
3
|
-
export interface GrokEnd {
|
|
4
|
-
stopReason: string;
|
|
5
|
-
thread: string | null;
|
|
6
|
-
tokens: Tokens;
|
|
7
|
-
/** only when the server reported the whole cost (API-key traffic); absent is unknown, never free */
|
|
8
|
-
costUsd: number | null;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface GrokFold {
|
|
12
|
-
/** the text after the last tool call: the worker's final message (spec 1.3 §5.5) */
|
|
13
|
-
reply: string;
|
|
14
|
-
end: GrokEnd | null;
|
|
15
|
-
/** a terminal `error` event's message: a run that never started a turn prints one instead of `end` */
|
|
16
|
-
error: string | null;
|
|
17
|
-
/** the sum of the per-response `usage` events, for a stream with no `end` */
|
|
18
|
-
used: Tokens;
|
|
19
|
-
lastEvent: string | null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Research §3.10: the limit texts of the 1.0.44 binary ([bin] "rate limit for your plan", "free Grok Build
|
|
24
|
-
* usage limit", "temporarily overloaded") and of the 2026-09-25 research's source read. Their apostrophes are
|
|
25
|
-
* U+2019, so no pattern spells one.
|
|
26
|
-
*/
|
|
27
|
-
export const GROK_LIMIT = [
|
|
28
|
-
/rate limit/i,
|
|
29
|
-
/usage limit/i,
|
|
30
|
-
/too many requests/i,
|
|
31
|
-
/resource.exhausted/i,
|
|
32
|
-
/at capacity/i,
|
|
33
|
-
/temporarily overloaded/i,
|
|
34
|
-
];
|
|
35
|
-
/** A plan without Grok Build: no wait fixes it. */
|
|
36
|
-
export const GROK_NOT_LIMIT = [/requires a Grok subscription/i];
|
|
37
|
-
/** clap's error for a flag this grok does not know, exit 2 (research §3.3 [run]). */
|
|
38
|
-
export const GROK_TOO_OLD = [/unexpected argument '/i];
|
|
39
|
-
/** research §3.3 [run]: a logged-out run's one error event */
|
|
40
|
-
const GROK_AUTH = [/Not signed in/i];
|
|
41
|
-
|
|
42
|
-
type Event = Record<string, any>;
|
|
43
|
-
|
|
44
|
-
export function parseGrokLine(line: string): Event | null {
|
|
45
|
-
if (!line.trim().startsWith("{")) return null;
|
|
46
|
-
try {
|
|
47
|
-
const e = JSON.parse(line) as unknown;
|
|
48
|
-
return e && typeof e === "object" && !Array.isArray(e) ? (e as Event) : null;
|
|
49
|
-
} catch {
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Spec 1.3 §5.5: `input_tokens` is uncached only (research §3.3), so input adds the cache reads and writes. */
|
|
55
|
-
export function grokTokens(u: Event | undefined): Tokens {
|
|
56
|
-
if (!u) return { ...ZERO_TOKENS };
|
|
57
|
-
const n = (k: string) => (typeof u[k] === "number" ? (u[k] as number) : 0);
|
|
58
|
-
const cached = n("cache_read_input_tokens");
|
|
59
|
-
return {
|
|
60
|
-
input: n("input_tokens") + cached + n("cache_creation_input_tokens"),
|
|
61
|
-
cached,
|
|
62
|
-
output: n("output_tokens"),
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export const isLimit = (text: string): boolean =>
|
|
67
|
-
GROK_LIMIT.some((r) => r.test(text)) && !GROK_NOT_LIMIT.some((r) => r.test(text));
|
|
68
|
-
export const isTooOld = (text: string): boolean => GROK_TOO_OLD.some((r) => r.test(text));
|
|
69
|
-
export const isAuthFailure = (text: string): boolean => GROK_AUTH.some((r) => r.test(text));
|
|
70
|
-
|
|
71
|
-
export function eventName(e: Event): string {
|
|
72
|
-
if (e.type === "tool_call") return `tool_call/${e.toolName ?? e.kind ?? "?"}`;
|
|
73
|
-
if (e.type === "tool_call_update") return `tool_call_update/${e.status ?? "?"}`;
|
|
74
|
-
return String(e.type ?? "?");
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Spec §3.7: a command the worker runs, a file it reads or edits, or its message. */
|
|
78
|
-
export function grokActivity(e: Event): string | undefined {
|
|
79
|
-
if (e.type === "text") return typeof e.data === "string" && e.data.trim() ? e.data : undefined;
|
|
80
|
-
if (e.type !== "tool_call") return undefined;
|
|
81
|
-
const input = (e.rawInput ?? {}) as Event;
|
|
82
|
-
if (typeof input.command === "string") return `$ ${input.command}`;
|
|
83
|
-
if (typeof input.path === "string") return `${e.kind === "read" ? "read" : "edit"} ${input.path}`;
|
|
84
|
-
return String(e.title ?? e.toolName ?? "tool");
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function foldGrokEvents(lines: string[]): GrokFold {
|
|
88
|
-
const f: GrokFold = { reply: "", end: null, error: null, used: { ...ZERO_TOKENS }, lastEvent: null };
|
|
89
|
-
for (const line of lines) {
|
|
90
|
-
const e = parseGrokLine(line);
|
|
91
|
-
if (!e) continue;
|
|
92
|
-
f.lastEvent = eventName(e);
|
|
93
|
-
if (e.type === "tool_call" || e.type === "tool_call_update") f.reply = "";
|
|
94
|
-
else if (e.type === "text" && typeof e.data === "string") f.reply += e.data;
|
|
95
|
-
else if (e.type === "usage") {
|
|
96
|
-
const t = grokTokens(e.usage);
|
|
97
|
-
f.used = {
|
|
98
|
-
input: f.used.input + t.input,
|
|
99
|
-
cached: f.used.cached + t.cached,
|
|
100
|
-
output: f.used.output + t.output,
|
|
101
|
-
};
|
|
102
|
-
} else if (e.type === "end")
|
|
103
|
-
f.end = {
|
|
104
|
-
stopReason: String(e.stopReason ?? "?"),
|
|
105
|
-
thread: typeof e.sessionId === "string" && e.sessionId ? e.sessionId : null,
|
|
106
|
-
tokens: e.usage ? grokTokens(e.usage) : f.used,
|
|
107
|
-
costUsd: typeof e.total_cost_usd === "number" ? e.total_cost_usd : null,
|
|
108
|
-
};
|
|
109
|
-
else if (e.type === "error") f.error = String(e.message ?? "error");
|
|
110
|
-
}
|
|
111
|
-
return f;
|
|
112
|
-
}
|
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { CatherdError } from "../../domain/errors.ts";
|
|
5
|
-
import type { Access } from "../../domain/record.ts";
|
|
6
|
-
import { dataDir } from "../../infra/paths.ts";
|
|
7
|
-
import { writeTextAtomic } from "../../infra/store.ts";
|
|
8
|
-
import { movedHomeEnv, writableRoots } from "../access.ts";
|
|
9
|
-
|
|
10
|
-
/** The user's GROK_HOME: grok's config, login, sessions and sandbox profiles. */
|
|
11
|
-
export const userGrokHome = (): string => process.env.GROK_HOME || join(homedir(), ".grok");
|
|
12
|
-
|
|
13
|
-
/** Where isolated grok runs keep their HOME; computing it creates nothing. */
|
|
14
|
-
export const isolatedGrokRoot = (): string => join(dataDir(), "grok-home");
|
|
15
|
-
|
|
16
|
-
/** The Claude Code and Cursor compatibility toggles of grok's config reference (research §3.8). */
|
|
17
|
-
const COMPAT = ["CLAUDE", "CURSOR"].flatMap((tool) =>
|
|
18
|
-
["AGENTS", "RULES", "SKILLS", "MCPS", "HOOKS"].map((what) => `GROK_${tool}_${what}_ENABLED`),
|
|
19
|
-
);
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Spec 1.3 §5.4: an isolated run's env. GROK_HOME and the toggles alone still load the user's Claude Code
|
|
23
|
-
* plugins, agents and permission rules (research §3.8 [run]), so HOME moves too, and GROK_HOME lives in it.
|
|
24
|
-
* `home`: another root than the isolated runs' (doctor's sandbox check runs in a scratch one).
|
|
25
|
-
*/
|
|
26
|
-
export function grokHomeEnv(home = isolatedGrokRoot()): Record<string, string> {
|
|
27
|
-
return {
|
|
28
|
-
...movedHomeEnv(home),
|
|
29
|
-
GROK_HOME: join(home, ".grok"),
|
|
30
|
-
GROK_MEMORY: "0",
|
|
31
|
-
...Object.fromEntries(COMPAT.map((k) => [k, "0"])),
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** What stops grok's kernel read-only profile here (research §3.6); tests point it elsewhere. */
|
|
36
|
-
export const grokHost = { platform: process.platform as string, dockerSocket: "/var/run/docker.sock" };
|
|
37
|
-
|
|
38
|
-
/** The tools a read-only role keeps where grok's read-only profile refuses to start (spec 1.3 §5.3). */
|
|
39
|
-
export const READ_TOOLS = "read_file,grep,list_dir,web_search,web_fetch";
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Spec 1.3 §5.3: grok refuses `read-only` (and every profile extending it) on a Mac whose docker socket is a
|
|
43
|
-
* symlink (Docker Desktop, OrbStack, Colima; research §3.6 [run]), so a read-only role there runs in the
|
|
44
|
-
* `workspace` profile with only the read tools: advisory, where the kernel profile is enforced.
|
|
45
|
-
*/
|
|
46
|
-
export function readOnlyRefused(): boolean {
|
|
47
|
-
return (
|
|
48
|
-
grokHost.platform === "darwin" &&
|
|
49
|
-
lstatSync(grokHost.dockerSocket, { throwIfNoEntry: false })?.isSymbolicLink() === true
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Spec 1.3 §5.3: the sandbox profile (and, where read-only cannot apply, the tool filter) of each access. */
|
|
54
|
-
export function grokAccess(access: Access, network = true): string[] {
|
|
55
|
-
if (access === "full") return ["--sandbox", "off"];
|
|
56
|
-
if (access === "workspace-write") return ["--sandbox", network ? "catherd-ws" : "catherd-ws-offline"];
|
|
57
|
-
return readOnlyRefused() ? ["--sandbox", "workspace", "--tools", READ_TOOLS] : ["--sandbox", "read-only"];
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* catherd's sandbox profiles: `workspace` plus catherd's writable roots (literal directories; research §3.6),
|
|
62
|
-
* and the same without child network (Linux only; macOS has no child-network control).
|
|
63
|
-
*/
|
|
64
|
-
export function catherdProfiles(): Record<string, Record<string, unknown>> {
|
|
65
|
-
const read_write = writableRoots();
|
|
66
|
-
return {
|
|
67
|
-
"catherd-ws": { extends: "workspace", read_write },
|
|
68
|
-
"catherd-ws-offline": { extends: "workspace", read_write, restrict_network: true },
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const BEGIN = "# >>> catherd";
|
|
73
|
-
const END = "# <<< catherd";
|
|
74
|
-
const HEADER = `${BEGIN}'s sandbox profiles for its grok workers: catherd rewrites this block, so edit outside it`;
|
|
75
|
-
|
|
76
|
-
const tomlError = (text: string): string | null => {
|
|
77
|
-
try {
|
|
78
|
-
Bun.TOML.parse(text);
|
|
79
|
-
return null;
|
|
80
|
-
} catch (e) {
|
|
81
|
-
return e instanceof Error ? e.message : String(e);
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
/** The `read_write` roots of each profile in catherd's block as it stands, by profile name. */
|
|
86
|
-
function blockRoots(block: string): Record<string, string[]> {
|
|
87
|
-
try {
|
|
88
|
-
const p =
|
|
89
|
-
(Bun.TOML.parse(block) as { profiles?: Record<string, { read_write?: unknown }> }).profiles ?? {};
|
|
90
|
-
return Object.fromEntries(
|
|
91
|
-
Object.entries(p).map(([k, v]) => [
|
|
92
|
-
k,
|
|
93
|
-
Array.isArray(v.read_write) ? v.read_write.filter((r): r is string => typeof r === "string") : [],
|
|
94
|
-
]),
|
|
95
|
-
);
|
|
96
|
-
} catch {
|
|
97
|
-
return {};
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* `profiles` with each one's `read_write` merged into the block's current roots: those that still exist, in
|
|
103
|
-
* their order, then the new ones. Two catherd homes on one machine (another CATHERD_HOME, other lock dirs) then
|
|
104
|
-
* converge on one block instead of rewriting each other's.
|
|
105
|
-
*/
|
|
106
|
-
function mergedProfiles(
|
|
107
|
-
profiles: Record<string, Record<string, unknown>>,
|
|
108
|
-
was: Record<string, string[]>,
|
|
109
|
-
): Record<string, Record<string, unknown>> {
|
|
110
|
-
return Object.fromEntries(
|
|
111
|
-
Object.entries(profiles).map(([name, p]) => {
|
|
112
|
-
const mine = Array.isArray(p.read_write) ? (p.read_write as string[]) : [];
|
|
113
|
-
const kept = (was[name] ?? []).filter((r) => existsSync(r));
|
|
114
|
-
const roots = [...new Set([...kept, ...mine])];
|
|
115
|
-
return [name, { ...p, read_write: roots }];
|
|
116
|
-
}),
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* `text` (a sandbox.toml) with catherd's marked block set to `profiles`: rewritten where it stands, or appended
|
|
122
|
-
* when there is none. A rewrite in place keeps every line outside the block, and what each line belongs to: a
|
|
123
|
-
* bare key the user put after the block stays in the table it was in. The block's existing roots that still
|
|
124
|
-
* exist stay (mergedProfiles). `why` when catherd must not write: the file is not TOML, defines a `catherd-*`
|
|
125
|
-
* profile of its own, or has lost the block's end marker.
|
|
126
|
-
*/
|
|
127
|
-
export function withCatherdProfiles(
|
|
128
|
-
text: string,
|
|
129
|
-
profiles = catherdProfiles(),
|
|
130
|
-
): { text: string } | { why: string } {
|
|
131
|
-
const start = text.indexOf(BEGIN);
|
|
132
|
-
const end = start === -1 ? -1 : text.indexOf(END, start);
|
|
133
|
-
if (start !== -1 && end === -1)
|
|
134
|
-
return { why: `catherd's block has lost its end marker (${END}): delete the block` };
|
|
135
|
-
const after = start === -1 ? "" : text.slice(end + END.length).replace(/^\r?\n/, "");
|
|
136
|
-
// the file without the block, for the checks: the block goes with the blank line catherd put before it
|
|
137
|
-
const rest = start === -1 ? text : text.slice(0, start).replace(/\n\n$/, "\n") + after;
|
|
138
|
-
const bad = tomlError(rest);
|
|
139
|
-
if (bad) return { why: `it is not valid TOML (${bad})` };
|
|
140
|
-
const own = Object.keys((Bun.TOML.parse(rest) as { profiles?: object }).profiles ?? {}).find((k) =>
|
|
141
|
-
k.startsWith("catherd-"),
|
|
142
|
-
);
|
|
143
|
-
if (own) return { why: `it has a [profiles.${own}] of its own: rename it, catherd's tables are catherd-*` };
|
|
144
|
-
const was = start === -1 ? {} : blockRoots(text.slice(start, end));
|
|
145
|
-
const block = `${HEADER}\n${Bun.TOML.stringify({ profiles: mergedProfiles(profiles, was) })}${END}\n`;
|
|
146
|
-
const head = rest.trimEnd();
|
|
147
|
-
const out =
|
|
148
|
-
start === -1 ? `${head ? `${head}\n\n` : ""}${block}` : `${text.slice(0, start)}${block}${after}`;
|
|
149
|
-
const broken = tomlError(out);
|
|
150
|
-
return broken ? { why: `it is not valid TOML with catherd's tables added (${broken})` } : { text: out };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Spec 1.3 §5.3, §9 Q3: puts catherd's `[profiles.catherd-*]` tables into `home`'s sandbox.toml (grok reads
|
|
155
|
-
* custom profiles only there or in the repo's `.grok/`), keeping the file's mode; the rest of the file is the
|
|
156
|
-
* user's. Throws E_CONFIG_INVALID, touching nothing, when catherd must not write it.
|
|
157
|
-
*/
|
|
158
|
-
export function writeGrokProfiles(home: string): void {
|
|
159
|
-
const path = join(home, "sandbox.toml");
|
|
160
|
-
const refuse = (file: string, why: string) =>
|
|
161
|
-
new CatherdError("E_CONFIG_INVALID", `catherd will not edit ${file}: ${why}`, {
|
|
162
|
-
fix: `fix ${file}, or isolate grok (catherd profile set harness.grok.isolated true)`,
|
|
163
|
-
});
|
|
164
|
-
// a link (a dotfiles repo) stays a link: the atomic rename goes to the file it points at
|
|
165
|
-
let file = path;
|
|
166
|
-
if (lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink())
|
|
167
|
-
try {
|
|
168
|
-
file = realpathSync(path);
|
|
169
|
-
} catch (e) {
|
|
170
|
-
throw refuse(path, `it is a link catherd cannot follow (${(e as NodeJS.ErrnoException).code ?? e})`);
|
|
171
|
-
}
|
|
172
|
-
let was = "";
|
|
173
|
-
try {
|
|
174
|
-
was = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
175
|
-
} catch (e) {
|
|
176
|
-
throw refuse(file, `catherd cannot read it (${(e as NodeJS.ErrnoException).code ?? e})`);
|
|
177
|
-
}
|
|
178
|
-
const next = withCatherdProfiles(was);
|
|
179
|
-
if ("why" in next) throw refuse(file, next.why);
|
|
180
|
-
if (next.text === was) return;
|
|
181
|
-
try {
|
|
182
|
-
writeTextAtomic(file, next.text, { mode: existsSync(file) ? statSync(file).mode & 0o777 : 0o600 });
|
|
183
|
-
} catch (e) {
|
|
184
|
-
// a read-only store (a Nix home, say) or a folder catherd may not write
|
|
185
|
-
throw refuse(file, `catherd cannot write it (${(e as NodeJS.ErrnoException).code ?? e})`);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
@@ -1,358 +0,0 @@
|
|
|
1
|
-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
-
import { basename, join } from "node:path";
|
|
3
|
-
import { CatherdError } from "../../domain/errors.ts";
|
|
4
|
-
import type { Rung } from "../../domain/ids.ts";
|
|
5
|
-
import type { Access, RunStatus } from "../../domain/record.ts";
|
|
6
|
-
import { assetPath } from "../../infra/assets.ts";
|
|
7
|
-
import { ensurePrivateDir } from "../../infra/store.ts";
|
|
8
|
-
import { realTmpdir } from "../access.ts";
|
|
9
|
-
import {
|
|
10
|
-
type BackendAdapter,
|
|
11
|
-
compareVersions,
|
|
12
|
-
type DiscoveredModel,
|
|
13
|
-
type EventDelta,
|
|
14
|
-
extractVersion,
|
|
15
|
-
type FinishedRun,
|
|
16
|
-
type Outcome,
|
|
17
|
-
type Probe,
|
|
18
|
-
type RunRequest,
|
|
19
|
-
type SpawnPlan,
|
|
20
|
-
} from "../backend.ts";
|
|
21
|
-
import { runCli } from "../cli.ts";
|
|
22
|
-
import { discovered } from "../discovery.ts";
|
|
23
|
-
import {
|
|
24
|
-
eventName,
|
|
25
|
-
foldGrokEvents,
|
|
26
|
-
GROK_LIMIT,
|
|
27
|
-
GROK_TOO_OLD,
|
|
28
|
-
grokActivity,
|
|
29
|
-
grokTokens,
|
|
30
|
-
isAuthFailure,
|
|
31
|
-
isLimit,
|
|
32
|
-
isTooOld,
|
|
33
|
-
parseGrokLine,
|
|
34
|
-
} from "./events.ts";
|
|
35
|
-
import {
|
|
36
|
-
grokAccess,
|
|
37
|
-
grokHomeEnv,
|
|
38
|
-
isolatedGrokRoot,
|
|
39
|
-
readOnlyRefused,
|
|
40
|
-
userGrokHome,
|
|
41
|
-
writeGrokProfiles,
|
|
42
|
-
} from "./home.ts";
|
|
43
|
-
import { parseGrokModels, type ShippedGrok } from "./models.ts";
|
|
44
|
-
|
|
45
|
-
export { isolatedGrokRoot } from "./home.ts";
|
|
46
|
-
|
|
47
|
-
/** Spec 1.3 §5.1: the version whose help, docs and argument checks were read (research §3). */
|
|
48
|
-
export const GROK_MIN_VERSION = "1.0.44";
|
|
49
|
-
export const GROK_INSTALL = "curl -fsSL https://x.ai/cli/install.sh | bash";
|
|
50
|
-
const THREAD = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
51
|
-
const DAY_MS = 24 * 60 * 60_000;
|
|
52
|
-
const LOGIN_FIX = "grok login, or grok login --device-code without a browser, or export XAI_API_KEY=<key>";
|
|
53
|
-
/** research §3.1 [doc 14]: grok never updates itself under a worker or a probe */
|
|
54
|
-
const NO_UPDATE = { GROK_DISABLE_AUTOUPDATER: "1" };
|
|
55
|
-
|
|
56
|
-
/** How long a `grok` query (version, models, the sandbox check) may take before it counts as failed. */
|
|
57
|
-
export const grokShell = { timeoutMs: 15_000 };
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* A fresh run's session id (`-s`), derived from its dispatch id (the dispatch dir's last part) so finalize knows
|
|
61
|
-
* it without the stream: a run killed before grok's `end` (a timeout, a cancel) still records the session grok
|
|
62
|
-
* saved (plan 16 final review). The id alone, not the full path: a data dir reached by another path (a link,
|
|
63
|
-
* `/private/var` for `/var`) gives the same session.
|
|
64
|
-
*/
|
|
65
|
-
export function sessionFor(r: Pick<RunRequest, "dispatchDir">): string {
|
|
66
|
-
const h = new Bun.CryptoHasher("sha256").update(basename(r.dispatchDir)).digest("hex");
|
|
67
|
-
const variant = ((Number.parseInt(h[16] as string, 16) & 0x3) | 0x8).toString(16);
|
|
68
|
-
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${variant}${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Whether grok got as far as a session: any event but a lone terminal error (not signed in, a bad flag). */
|
|
72
|
-
const sessionStarted = (lines: string[]): boolean =>
|
|
73
|
-
lines.some((l) => {
|
|
74
|
-
try {
|
|
75
|
-
return (JSON.parse(l) as { type?: unknown }).type !== "error";
|
|
76
|
-
} catch {
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
function plan(r: RunRequest): SpawnPlan {
|
|
82
|
-
if (r.thread !== null && !THREAD.test(r.thread))
|
|
83
|
-
throw new CatherdError("E_ADMIT_THREAD", `"${r.thread}" is not a grok session id`, {
|
|
84
|
-
fix: "pass the thread from the earlier RunRecord",
|
|
85
|
-
});
|
|
86
|
-
const access = grokAccess(r.access, r.network !== false);
|
|
87
|
-
return {
|
|
88
|
-
cmd: "grok",
|
|
89
|
-
args: [
|
|
90
|
-
// headless grok never reads stdin (research §3.2 [doc 14]): the brief goes by file
|
|
91
|
-
"--prompt-file",
|
|
92
|
-
r.briefPath,
|
|
93
|
-
"--output-format",
|
|
94
|
-
"streaming-json",
|
|
95
|
-
"--cwd",
|
|
96
|
-
r.repo,
|
|
97
|
-
"-m",
|
|
98
|
-
r.rung.model,
|
|
99
|
-
...(r.rung.effort === "default" ? [] : ["--effort", r.rung.effort]),
|
|
100
|
-
// hidden (research §3.2 [run]): trust for this session, no self-update, no memory (spec 1.3 §9 Q8)
|
|
101
|
-
"--always-approve",
|
|
102
|
-
"--trust",
|
|
103
|
-
"--no-auto-update",
|
|
104
|
-
"--no-memory",
|
|
105
|
-
// a fresh session takes catherd's id; a resumed one keeps the profile it started with and refuses
|
|
106
|
-
// another (research §3.5), while a tool filter is no profile: a read-only role keeps its read tools
|
|
107
|
-
...(r.thread === null
|
|
108
|
-
? [...access, "-s", sessionFor(r)]
|
|
109
|
-
: [...(access.includes("--tools") ? access.slice(-2) : []), "-r", r.thread]),
|
|
110
|
-
],
|
|
111
|
-
env: { ...NO_UPDATE, ...(r.isolated ? grokHomeEnv() : {}) },
|
|
112
|
-
cwd: r.repo,
|
|
113
|
-
stdinPath: null,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** The catalog's grok models (`on.grok`): grok lists no efforts (research §3.4), the catalog does. */
|
|
118
|
-
let shipped: ShippedGrok | null = null;
|
|
119
|
-
function shippedOnGrok(): ShippedGrok {
|
|
120
|
-
if (shipped) return shipped;
|
|
121
|
-
const doc = JSON.parse(readFileSync(assetPath("catalog/models.json"), "utf8")) as {
|
|
122
|
-
families: { on: Record<string, { id: string; efforts: string[]; context?: number } | undefined> }[];
|
|
123
|
-
};
|
|
124
|
-
shipped = {};
|
|
125
|
-
for (const f of doc.families)
|
|
126
|
-
if (f.on.grok) shipped[f.on.grok.id] = { efforts: f.on.grok.efforts, context: f.on.grok.context ?? null };
|
|
127
|
-
return shipped;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** `grok models` under `env`: the user's GROK_HOME (its login) by default, catherd's isolated one with the key. */
|
|
131
|
-
async function listAs(env: Record<string, string>): Promise<DiscoveredModel[]> {
|
|
132
|
-
const r = await runCli("grok", ["models"], { ...grokShell, env });
|
|
133
|
-
return r?.ok ? parseGrokModels(r.out, shippedOnGrok()).models : [];
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const listModels = (): Promise<DiscoveredModel[]> => listAs(NO_UPDATE);
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* The discovery cache an isolated run is judged by: the listing under catherd's own GROK_HOME, where only
|
|
140
|
-
* XAI_API_KEY signs grok in, apart from the native login's (1.3 follow-ups: the two accounts may differ).
|
|
141
|
-
*/
|
|
142
|
-
export const ISOLATED_LISTING = "grok-isolated";
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Spec 1.3 §5.3–§5.4: an isolated run needs XAI_API_KEY (its home holds no login, and the user's auth.json is
|
|
146
|
-
* never copied: its refresh token rotates, research §3.9); a workspace-write run needs catherd-ws in the
|
|
147
|
-
* sandbox.toml of the GROK_HOME it runs with; the model must be one grok lists, at an effort the catalog offers.
|
|
148
|
-
*/
|
|
149
|
-
async function prepare(req: {
|
|
150
|
-
rung: Rung;
|
|
151
|
-
access: Access;
|
|
152
|
-
isolated: boolean;
|
|
153
|
-
repo: string;
|
|
154
|
-
network?: boolean;
|
|
155
|
-
}): Promise<void> {
|
|
156
|
-
if (req.isolated && !process.env.XAI_API_KEY)
|
|
157
|
-
throw new CatherdError("E_BACKEND_NOT_LOGGED_IN", "an isolated grok run needs XAI_API_KEY", {
|
|
158
|
-
fix: "export XAI_API_KEY=<key>, or catherd profile set harness.grok.isolated false",
|
|
159
|
-
});
|
|
160
|
-
const home = req.isolated ? join(isolatedGrokRoot(), ".grok") : userGrokHome();
|
|
161
|
-
if (req.isolated) ensurePrivateDir(home);
|
|
162
|
-
if (req.access === "workspace-write") writeGrokProfiles(home);
|
|
163
|
-
const { model, effort } = req.rung;
|
|
164
|
-
// an isolated run signs in with the key, so its model is checked against the key's own listing
|
|
165
|
-
const models = req.isolated
|
|
166
|
-
? await discovered(ISOLATED_LISTING, () => listAs({ ...NO_UPDATE, ...grokHomeEnv() }), {
|
|
167
|
-
maxAgeMs: DAY_MS,
|
|
168
|
-
need: model,
|
|
169
|
-
})
|
|
170
|
-
: await discovered("grok", listModels, { maxAgeMs: DAY_MS, need: model });
|
|
171
|
-
if (models.length === 0) return; // grok listed nothing: let the run itself say what is wrong
|
|
172
|
-
const m = models.find((x) => x.id === model);
|
|
173
|
-
if (!m)
|
|
174
|
-
throw new CatherdError("E_BACKEND_MODEL_UNKNOWN", `grok does not list ${model}`, {
|
|
175
|
-
fix: "run grok models, and use a model it lists",
|
|
176
|
-
});
|
|
177
|
-
if (effort !== "default" && m.efforts.length > 0 && !m.efforts.includes(effort))
|
|
178
|
-
throw new CatherdError("E_BACKEND_MODEL_UNKNOWN", `${model} has no effort "${effort}" on grok`, {
|
|
179
|
-
fix: `use one of: ${["default", ...m.efforts].map((e) => `grok:${model}#${e}`).join(", ")}`,
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function finalize(run: FinishedRun): Outcome {
|
|
184
|
-
const f = foldGrokEvents(run.eventLines);
|
|
185
|
-
const stopped = run.exit.reason;
|
|
186
|
-
const said = f.error ?? run.stderr;
|
|
187
|
-
const status: RunStatus =
|
|
188
|
-
stopped === "cancelled"
|
|
189
|
-
? "cancelled"
|
|
190
|
-
: stopped === "idle-timeout" || stopped === "wall-timeout"
|
|
191
|
-
? "timeout"
|
|
192
|
-
: f.end?.stopReason === "end_turn"
|
|
193
|
-
? "ok"
|
|
194
|
-
: f.end
|
|
195
|
-
? "failed"
|
|
196
|
-
: run.exit.code === 2 || isTooOld(run.stderr)
|
|
197
|
-
? "cli-too-old"
|
|
198
|
-
: isLimit(said)
|
|
199
|
-
? "limit"
|
|
200
|
-
: "failed";
|
|
201
|
-
const errLines = run.stderr.trim().split("\n");
|
|
202
|
-
// clap's usage lines follow its error: the error line says what went wrong
|
|
203
|
-
const lastErr = errLines.find((l) => isTooOld(l)) ?? errLines.at(-1) ?? "";
|
|
204
|
-
const message = f.end
|
|
205
|
-
? `stopped: ${f.end.stopReason}`
|
|
206
|
-
: isAuthFailure(said)
|
|
207
|
-
? `grok is not signed in (fix: ${LOGIN_FIX})`
|
|
208
|
-
: f.error || lastErr || `no end event (${stopped}, exit ${run.exit.code ?? run.exit.signal})`;
|
|
209
|
-
return {
|
|
210
|
-
status,
|
|
211
|
-
thread:
|
|
212
|
-
f.end?.thread ??
|
|
213
|
-
run.request.thread ??
|
|
214
|
-
(sessionStarted(run.eventLines) ? sessionFor(run.request) : null),
|
|
215
|
-
tokens: f.end?.tokens ?? f.used,
|
|
216
|
-
costUsd: f.end?.costUsd ?? null,
|
|
217
|
-
images: [],
|
|
218
|
-
error: status === "ok" ? null : { code: status, message },
|
|
219
|
-
...(status === "ok" ? { reply: f.reply } : {}),
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function parse(line: string): EventDelta {
|
|
224
|
-
const e = parseGrokLine(line);
|
|
225
|
-
if (!e) return {};
|
|
226
|
-
const d: EventDelta = { lastEvent: eventName(e) };
|
|
227
|
-
const activity = grokActivity(e);
|
|
228
|
-
if (activity) d.activity = activity;
|
|
229
|
-
// a tool call runs from its tool_call until an update leaves in_progress, often printing nothing: busy
|
|
230
|
-
if ((e.type === "tool_call" || e.type === "tool_call_update") && typeof e.toolCallId === "string")
|
|
231
|
-
d.item = { id: e.toolCallId, open: e.type === "tool_call" || e.status === "in_progress" };
|
|
232
|
-
// one usage event per model response (research §3.3): the input that response read
|
|
233
|
-
if (e.type === "usage") d.requestInput = grokTokens(e.usage).input;
|
|
234
|
-
if (e.type === "end") {
|
|
235
|
-
d.final = true;
|
|
236
|
-
d.tokens = grokTokens(e.usage);
|
|
237
|
-
if (typeof e.sessionId === "string" && e.sessionId) d.thread = e.sessionId;
|
|
238
|
-
if (typeof e.total_cost_usd === "number") d.costUsd = e.total_cost_usd;
|
|
239
|
-
if (e.stopReason !== "end_turn") d.failure = `stopped: ${e.stopReason}`;
|
|
240
|
-
}
|
|
241
|
-
// research §3.3 [run]: a run that cannot go on prints an error and no end
|
|
242
|
-
if (e.type === "error") {
|
|
243
|
-
d.final = true;
|
|
244
|
-
d.failure = String(e.message ?? "error");
|
|
245
|
-
if (isLimit(d.failure)) d.limit = true;
|
|
246
|
-
}
|
|
247
|
-
return d;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Spec 1.3 §5.6: doctor's `sandbox:grok` row, from a run that spends no turn: grok applies its sandbox before it
|
|
252
|
-
* checks the login (research §3.6 [run]), so from an empty GROK_HOME with no API key the run stops at the
|
|
253
|
-
* refusal when read-only cannot apply here, else at "Not signed in".
|
|
254
|
-
*/
|
|
255
|
-
async function sandboxInfo(): Promise<{ id: string; label: string; detail: string } | null> {
|
|
256
|
-
const home = mkdtempSync(join(realTmpdir(), "catherd-grok-check-"));
|
|
257
|
-
try {
|
|
258
|
-
const r = await runCli(
|
|
259
|
-
"grok",
|
|
260
|
-
["-p", "hi", "--output-format", "streaming-json", "--sandbox", "read-only", "--no-auto-update"],
|
|
261
|
-
// a scratch HOME as well as GROK_HOME, with memory and the compat features off: nothing of the user's
|
|
262
|
-
// own Claude Code or Cursor setup loads into the check (1.3 follow-ups)
|
|
263
|
-
{ ...grokShell, env: { ...NO_UPDATE, ...grokHomeEnv(home), XAI_API_KEY: "" } },
|
|
264
|
-
);
|
|
265
|
-
if (!r) return null;
|
|
266
|
-
const refused = /could not apply the '?read-only'? sandbox profile/.test(r.err);
|
|
267
|
-
const why = r.err
|
|
268
|
-
.split("\n")
|
|
269
|
-
.find((l) => l.startsWith("warning: "))
|
|
270
|
-
?.slice("warning: ".length);
|
|
271
|
-
const detail = !refused
|
|
272
|
-
? "grok's read-only profile applies here; catherd runs a read-only role in it (enforced)"
|
|
273
|
-
: `grok refuses its read-only profile here${why ? ` (${why})` : ""}; ${
|
|
274
|
-
readOnlyRefused()
|
|
275
|
-
? "catherd runs a read-only role in the workspace profile with the read tools only (advisory)"
|
|
276
|
-
: "a read-only grok role fails here: give it another backend or full access"
|
|
277
|
-
}`;
|
|
278
|
-
return { id: "sandbox:grok", label: "grok sandbox", detail };
|
|
279
|
-
} finally {
|
|
280
|
-
rmSync(home, { recursive: true, force: true });
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Spec 1.3 §5.1, §5.6. Logged in: the first line of `grok models`, which answers fast, with no browser, when
|
|
286
|
-
* logged out (research §3.4 [run]); a Grok login bills the subscription, an API key per token. A grok the OS
|
|
287
|
-
* cannot execute (a Linux build on macOS, research §3.1) throws here, and probeBackend reports it.
|
|
288
|
-
*/
|
|
289
|
-
async function probe(): Promise<Probe> {
|
|
290
|
-
const v = await runCli("grok", ["--version"], { ...grokShell, env: NO_UPDATE });
|
|
291
|
-
if (!v)
|
|
292
|
-
return {
|
|
293
|
-
installed: false,
|
|
294
|
-
version: null,
|
|
295
|
-
versionOk: false,
|
|
296
|
-
loggedIn: null,
|
|
297
|
-
problems: [{ code: "E_BACKEND_MISSING", message: "grok is not on PATH", fix: GROK_INSTALL }],
|
|
298
|
-
};
|
|
299
|
-
const version = extractVersion(v.out);
|
|
300
|
-
const versionOk = version !== null && compareVersions(version, GROK_MIN_VERSION) >= 0;
|
|
301
|
-
const m = await runCli("grok", ["models"], { ...grokShell, env: NO_UPDATE });
|
|
302
|
-
const { loggedIn, login } = parseGrokModels(`${m?.out ?? ""}\n${m?.err ?? ""}`);
|
|
303
|
-
const problems: Probe["problems"] = [];
|
|
304
|
-
if (!versionOk)
|
|
305
|
-
problems.push({
|
|
306
|
-
code: "E_BACKEND_TOO_OLD",
|
|
307
|
-
message: `grok ${version ?? "?"} is older than ${GROK_MIN_VERSION}`,
|
|
308
|
-
fix: "grok update --stable",
|
|
309
|
-
});
|
|
310
|
-
if (loggedIn === false)
|
|
311
|
-
problems.push({
|
|
312
|
-
code: "E_BACKEND_NOT_LOGGED_IN",
|
|
313
|
-
message: "grok is not logged in (grok models says: You are not authenticated.)",
|
|
314
|
-
fix: LOGIN_FIX,
|
|
315
|
-
});
|
|
316
|
-
const info = await sandboxInfo();
|
|
317
|
-
return {
|
|
318
|
-
installed: true,
|
|
319
|
-
version,
|
|
320
|
-
versionOk,
|
|
321
|
-
loggedIn,
|
|
322
|
-
...(login
|
|
323
|
-
? { login, billing: login === "API key" ? ("metered" as const) : ("subscription" as const) }
|
|
324
|
-
: {}),
|
|
325
|
-
problems,
|
|
326
|
-
...(info ? { info: [info] } : {}),
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
export const grokAdapter: BackendAdapter = {
|
|
331
|
-
id: "grok",
|
|
332
|
-
minVersion: GROK_MIN_VERSION,
|
|
333
|
-
install: GROK_INSTALL,
|
|
334
|
-
probe,
|
|
335
|
-
listModels,
|
|
336
|
-
prepare,
|
|
337
|
-
plan,
|
|
338
|
-
parse,
|
|
339
|
-
finalize,
|
|
340
|
-
// spec 1.3 §5.3: the kernel holds each profile, except read-only where only a tool allowlist can (a Mac with a
|
|
341
|
-
// symlinked docker socket); on macOS no profile controls the network
|
|
342
|
-
enforcement: {
|
|
343
|
-
get "read-only"() {
|
|
344
|
-
return readOnlyRefused() ? "advisory" : "enforced";
|
|
345
|
-
},
|
|
346
|
-
"workspace-write": "enforced",
|
|
347
|
-
full: "enforced",
|
|
348
|
-
},
|
|
349
|
-
errors: { limit: GROK_LIMIT, tooOld: GROK_TOO_OLD },
|
|
350
|
-
// a session keeps the sandbox profile it started with and refuses another (research §3.5)
|
|
351
|
-
resume: { supported: true, sameAccessOnly: true, threadPattern: THREAD },
|
|
352
|
-
isolationNote:
|
|
353
|
-
"native grok loads your Claude Code plugins, agents, skills, hooks and permission rules, and your Cursor rules and MCP",
|
|
354
|
-
isolationKey: "XAI_API_KEY",
|
|
355
|
-
// a one-shot run may drain uploads for ~150 s after end (research §3.3); the record is complete at end
|
|
356
|
-
graceAfterFinalMs: 30_000,
|
|
357
|
-
reportsCost: true,
|
|
358
|
-
};
|