mtech-ai-local-runner 0.1.111 → 0.1.113
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 +32 -49
- package/dist/src/adapters/claude-code/adapter.js +16 -7
- package/dist/src/adapters/claude-code/stream-parser.js +65 -4
- package/dist/src/adapters/runtime-failure.js +34 -0
- package/dist/src/asks/claude-mod.js +48 -87
- package/dist/src/asks/commands.js +17 -33
- package/dist/src/asks/doctor.js +3 -4
- package/dist/src/cli.js +3 -3
- package/dist/src/daemon/codex-app-host-entry.js +94 -0
- package/dist/src/daemon/codex-app-setup.js +125 -0
- package/dist/src/daemon/codex-app-tools-host.js +82 -0
- package/dist/src/daemon/execution-coordinator.js +7 -4
- package/dist/src/daemon/runtime.js +74 -11
- package/dist/src/safety/claude-production-host.js +14 -3
- package/dist/src/safety/claude-review-provider.js +9 -1
- package/dist/src/safety/d4-execution-limits.js +23 -11
- package/dist/src/safety/d4-host-composition.js +139 -0
- package/dist/src/safety/d4-reviewed-hosts.js +2 -0
- package/dist/src/safety/d4-runtime-release.js +9 -0
- package/dist/src/verification/p1-execution-host.js +4 -0
- package/package.json +1 -3
- package/claude-mod/.claude-plugin/marketplace.json +0 -1
- package/claude-mod/.claude-plugin/plugin.json +0 -1
- package/claude-mod/hooks/gateway.ts +0 -47
- package/claude-mod/hooks/hooks.json +0 -34
- package/claude-mod/hooks/model.ts +0 -268
- package/claude-mod/hooks/register.tsx +0 -460
- package/claude-mod/tsconfig.json +0 -3
- package/claude-mod/types/index.d.ts +0 -28
package/README.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
## 0.1.113: the Work Room pane mod is removed
|
|
2
|
+
|
|
3
|
+
The Claude Code Work Room pane (`ai-gateway-work-room`) is no longer shipped (Gateway Work Item 2ae9d471). The menu bar
|
|
4
|
+
app answers requests and shows Rooms instead. Run `setup` once after updating: it uninstalls the pane and its
|
|
5
|
+
`mtec-ai-gateway` marketplace from Claude Code and deletes the runner's old copy. If setup reports `workRoomPane` as
|
|
6
|
+
`skipped` or `failed`, the pane keeps working until a later setup can remove it; `skipped` means no Claude CLI 2.1.299 or
|
|
7
|
+
newer was found. Ask Claude, Share answer to Room and Hide went with the pane.
|
|
8
|
+
|
|
9
|
+
0.1.113 also carries the runner work merged after 0.1.112 (PRs #964, #966, #968, #971). Reviewed-host registration stays
|
|
10
|
+
empty, so D4 execution stays closed, and the Codex app setup code is not wired in.
|
|
11
|
+
|
|
12
|
+
## 0.1.112: a failed Claude job says why it stopped
|
|
13
|
+
|
|
14
|
+
When a Claude job fails, the runner now tells the Gateway why, as one code: `auth` (the Claude account's sign-in was
|
|
15
|
+
refused or expired), `usage_limit`, `model_unavailable`, `rate_limited`, `provider_error`, `budget_exceeded`,
|
|
16
|
+
`cli_error` or `unknown` (Gateway Work Item 2ee28765). The Sentry "AI fix failed" card then says, for example, "Claude
|
|
17
|
+
stopped before the first answer: usage limit reached on the runner's Claude account." The runner reads only the Claude
|
|
18
|
+
CLI's error kind and HTTP status; it never sends the CLI's own text. If the card says `auth`, sign Claude in again on
|
|
19
|
+
this Mac (or renew the runner's token with `setup --claude-token`) and retry.
|
|
20
|
+
|
|
21
|
+
0.1.112 also carries the D4 work merged after 0.1.111 (PRs #943, #949, #957). Reviewed-host registration stays empty,
|
|
22
|
+
so D4 execution stays closed.
|
|
23
|
+
|
|
1
24
|
## 0.1.111: Work Rooms in the menu bar app
|
|
2
25
|
|
|
3
26
|
The menu bar window now has a **Rooms** tab (Gateway Work Item b35d65cb); the menu's **Show Rooms (n unread)…** opens
|
|
@@ -474,7 +497,7 @@ covers it. A stored delivery is forgotten an hour after it arrived, by which tim
|
|
|
474
497
|
long run.
|
|
475
498
|
|
|
476
499
|
Integrations are installed only on request, with `setup --integrations` or `integrations install`; setup never asks
|
|
477
|
-
about them.
|
|
500
|
+
about them. An app already installed in the directory the environment names is skipped and reported installed, without
|
|
478
501
|
rewriting its files:
|
|
479
502
|
|
|
480
503
|
```bash
|
|
@@ -494,54 +517,14 @@ then the Slack message covers each ask. A
|
|
|
494
517
|
reports `CODEX_HOOKS_JSON_INVALID`. `uninstall` removes both integrations, the shim, `asks.json` and any temporary
|
|
495
518
|
`asks.json.*.tmp` file a crash left behind, and it also works on a setup that never finished.
|
|
496
519
|
|
|
497
|
-
|
|
498
|
-
98da2635).
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
messages) or closes it, and its threads and messages are indented under it, each message under its author's name. From the pane you can answer a request,
|
|
506
|
-
reply in a thread, share an answer to a Room or mark messages read. Each of these starts with Send (or the button that
|
|
507
|
-
names it), then a confirm row under that field or button that names who will see it, then Claude's permission dialog.
|
|
508
|
-
Opening a Room shows its threads with unread messages first and every unread message in them. The Room's Mark read
|
|
509
|
-
marks up to 100 of them at once; a Room with more than one thread also has a Mark read on each thread.
|
|
510
|
-
|
|
511
|
-
Ask Claude puts only ids and tool names in the conversation and asks Claude to read the full request and sum it up
|
|
512
|
-
in one line; nothing is sent, and it is the only button that starts a model turn. Hide hides a request, decision or
|
|
513
|
-
review, with every reminder joined to it, on this Mac only, with no Gateway call: it stays open for the person who asked and on your other devices.
|
|
514
|
-
"Hidden (N)" with Show lists them again, each with Unhide. The pane calls the AI Gateway only through your own AI
|
|
515
|
-
Gateway connector in Claude; without the connector the pane says so. The Claude mobile app draws no input field, so
|
|
516
|
-
there the pane reads, shares, marks read and offers Ask Claude.
|
|
517
|
-
|
|
518
|
-
After a Gateway change to a tool's arguments, refresh the AI Gateway tools list in Claude (Settings, Connectors, AI
|
|
519
|
-
Gateway) and start a new session: a session drops arguments its tools list does not declare. The pane says so when
|
|
520
|
-
its Room reads lose their arguments.
|
|
521
|
-
|
|
522
|
-
The pane calls the connector with no model turn, so a Claude session in auto mode would refuse every one of those
|
|
523
|
-
calls. The mod therefore ships PreToolUse hooks that match any AI Gateway connector and apply to every caller in the
|
|
524
|
-
session, the model included:
|
|
525
|
-
|
|
526
|
-
- They allow the two reads, `work_room_read` and `work_inbox`.
|
|
527
|
-
- They allow `work_room_receipt_report` only when its `state` is `presented`, the record that the pane showed you a
|
|
528
|
-
message.
|
|
529
|
-
- They ask you in Claude's own permission dialog before every write: `work_room_write`, `work_request_answer`, and
|
|
530
|
-
`work_room_receipt_report` for marking messages read. A write from the pane therefore needs its confirm press and
|
|
531
|
-
then your answer in that dialog.
|
|
532
|
-
|
|
533
|
-
The runner copies the mod to `claude-mod/<version>/` in the state root, without its tests, and installs it with the
|
|
534
|
-
Claude CLI as a local marketplace (`plugin marketplace add` and `plugin install --scope user`). This needs a Claude CLI
|
|
535
|
-
2.1.299 or newer: the first `claude` on `PATH` when it is new enough, else the newest one the Claude desktop app
|
|
536
|
-
bundles. Without one, the hooks are installed as before and the result reports the mod as `skipped`. Setup reinstalls
|
|
537
|
-
only a missing or older copy, and it never turns on a mod you turned off. `integrations status` and `doctor` report the
|
|
538
|
-
mod's state under `mod`; `integrations remove claude` and `uninstall` remove it. The daemon never runs the Claude CLI.
|
|
539
|
-
|
|
540
|
-
Every `setup` installs or updates the mod, with or without `--integrations`, and reports it as `workRoomPane`. So
|
|
541
|
-
rerunning `npx mtech-ai-local-runner@<version> setup` is enough to get the newest pane. With `--integrations claude`
|
|
542
|
-
the Claude integration installs it and reports it under its own `mod` instead. After `integrations remove claude`,
|
|
543
|
-
setup leaves the mod out, and its result says so. `integrations install claude` adds the mod again, and from then
|
|
544
|
-
setup keeps it up to date once more. `uninstall` forgets the removal, so a later setup starts fresh.
|
|
520
|
+
Runners from 0.1.79 also installed the Work Room pane, the Claude Code mod `ai-gateway-work-room@mtec-ai-gateway`
|
|
521
|
+
(Work Item 98da2635). It is retired: the menu bar app answers requests and shows Rooms instead, and the package no
|
|
522
|
+
longer carries the mod. Every `setup`, `integrations remove claude` and `uninstall` removes the copy an older runner
|
|
523
|
+
installed: it uninstalls the mod and its local marketplace with the Claude CLI, then deletes `claude-mod/` in the state
|
|
524
|
+
root, and reports what it did as `workRoomPane` (setup) or under the Claude integration's `mod`. With no such copy
|
|
525
|
+
nothing runs and nothing is reported. The copy is deleted only once the Claude CLI no longer lists the mod, so a Mac
|
|
526
|
+
without a Claude CLI of 2.1.299 or newer, or a failed uninstall, keeps a working pane and says why; a later setup tries
|
|
527
|
+
again. The daemon never runs the Claude CLI.
|
|
545
528
|
|
|
546
529
|
`CLAUDE_CONFIG_DIR` and `CODEX_HOME` are honoured. The LaunchAgent that runs the daemon gets only `PATH`, so every
|
|
547
530
|
install adds the directory it used to a list per app in `integrations.json` in the state root, and the daemon,
|
|
@@ -7,6 +7,7 @@ import { CLAUDE_LAUNCH_OPT_OUTS, CLAUDE_RELEASE_MODE, CLAUDE_TRANSPORT, claudeCa
|
|
|
7
7
|
import { createJobSecurityReceipt } from '../../safety/job-security-receipt.js';
|
|
8
8
|
import { isTerminalEventType } from "../../protocol.js";
|
|
9
9
|
import { parseClaudeLine } from "./stream-parser.js";
|
|
10
|
+
import { runtimeFailure } from "../runtime-failure.js";
|
|
10
11
|
import { p1ModelEvent } from '../../verification/p1-execution-host.js';
|
|
11
12
|
import { compilePrompt } from "./prompt-compiler.js";
|
|
12
13
|
import { CLAUDE_COMPLETION_SCHEMA } from "../completion-report.js";
|
|
@@ -180,7 +181,8 @@ export class ClaudeCodeAdapter {
|
|
|
180
181
|
|| this.options.securityPolicy?.pin.toolsSha256 !== profile.tools_sha256 || this.options.securityPolicy?.pin.requirementsSha256 !== profile.cli_security_fingerprint
|
|
181
182
|
|| this.options.securityPolicy?.pin.securityMode !== CLAUDE_RELEASE_MODE)
|
|
182
183
|
throw Error('D4_REVIEW_HOST_REQUIRED');
|
|
183
|
-
await host.assertCurrent(structuredClone(descriptor)
|
|
184
|
+
await host.assertCurrent(structuredClone(descriptor), { jobId: this.options.jobId ?? ctx.bindingId, bindingId: ctx.bindingId, generation: ctx.generation,
|
|
185
|
+
launchFingerprint: ctx.securityBinding?.launchFingerprint ?? '', contractFingerprint: ctx.securityBinding?.contractFingerprint ?? '', taskPackageFingerprint: ctx.securityBinding?.taskPackageFingerprint ?? '' });
|
|
184
186
|
};
|
|
185
187
|
if (this.d4Limits)
|
|
186
188
|
await assertD4Host();
|
|
@@ -193,7 +195,8 @@ export class ClaudeCodeAdapter {
|
|
|
193
195
|
if (!cwd || !path.isAbsolute(cwd) || rel === "" || (!rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel)))
|
|
194
196
|
throw new Error("trusted cwd must live outside the assigned worktree");
|
|
195
197
|
const unbounded = isUnboundedLocalExecution(ctx.taskPackage);
|
|
196
|
-
|
|
198
|
+
const recordOnly = !!this.d4Limits && !('max_total_tokens' in this.d4Limits);
|
|
199
|
+
if (!unbounded && !recordOnly && (!Number.isFinite(this.options.maxBudgetUsd) || this.options.maxBudgetUsd <= 0)) {
|
|
197
200
|
throw new Error("refusing to start: no positive remaining contract budget was supplied");
|
|
198
201
|
}
|
|
199
202
|
if (!this.options.worktreeRoot) {
|
|
@@ -216,9 +219,9 @@ export class ClaudeCodeAdapter {
|
|
|
216
219
|
if (this.cancelled)
|
|
217
220
|
throw new Error("adapter startup cancelled");
|
|
218
221
|
const mcpConfigPath = this.options.writeLocalMcpConfig(endpoint);
|
|
219
|
-
const maxBudgetUsd = unbounded
|
|
222
|
+
const maxBudgetUsd = unbounded || recordOnly
|
|
220
223
|
? null
|
|
221
|
-
: Math.min(this.options.maxBudgetUsd, this.d4Limits ? this.d4Limits.max_estimated_cost_microusd / 1_000_000 : contractBudget(ctx.taskPackage));
|
|
224
|
+
: Math.min(this.options.maxBudgetUsd, this.d4Limits && 'max_estimated_cost_microusd' in this.d4Limits ? this.d4Limits.max_estimated_cost_microusd / 1_000_000 : contractBudget(ctx.taskPackage));
|
|
222
225
|
const hookPolicy = buildHookPolicy({
|
|
223
226
|
taskPackage: ctx.taskPackage,
|
|
224
227
|
worktreeRoot: this.options.worktreeRoot,
|
|
@@ -309,6 +312,9 @@ export class ClaudeCodeAdapter {
|
|
|
309
312
|
exitInfo.signal = signal;
|
|
310
313
|
});
|
|
311
314
|
let sawTerminal = false;
|
|
315
|
+
// The CLI names an API error's kind on the assistant message before the result (stream-parser.ts); a later
|
|
316
|
+
// assistant message without one replaces it.
|
|
317
|
+
let assistantError;
|
|
312
318
|
try {
|
|
313
319
|
const lines = this.d4Guard ? this.d4Lines(handle, this.d4Guard) : handle.stdout;
|
|
314
320
|
for await (const line of lines) {
|
|
@@ -348,7 +354,9 @@ export class ClaudeCodeAdapter {
|
|
|
348
354
|
return;
|
|
349
355
|
}
|
|
350
356
|
}
|
|
351
|
-
|
|
357
|
+
if (message.type === "assistant")
|
|
358
|
+
assistantError = typeof message.error === "string" ? message.error : undefined;
|
|
359
|
+
let parsed = parseClaudeLine(line, this.options.worktreeRoot, { p1: this.p1, assistantError });
|
|
352
360
|
if (!parsed)
|
|
353
361
|
continue;
|
|
354
362
|
if (parsed.eventType === "completed") {
|
|
@@ -357,7 +365,7 @@ export class ClaudeCodeAdapter {
|
|
|
357
365
|
if (this.candidateReview)
|
|
358
366
|
parsed = candidateReviewCompletion(parsed);
|
|
359
367
|
if (d4Usage)
|
|
360
|
-
parsed = { ...parsed, payload: { ...parsed.payload, d4_usage: { ...d4Usage, policy: 'observed_stop_possible_overshoot' } } };
|
|
368
|
+
parsed = { ...parsed, payload: { ...parsed.payload, d4_usage: { ...d4Usage, policy: this.d4Limits && 'max_total_tokens' in this.d4Limits ? 'observed_stop_possible_overshoot' : 'record_only' } } };
|
|
361
369
|
if (this.p1)
|
|
362
370
|
parsed = p1ModelEvent({ ...parsed, payload: { ...parsed.payload, usage: observedUsage } });
|
|
363
371
|
}
|
|
@@ -370,7 +378,8 @@ export class ClaudeCodeAdapter {
|
|
|
370
378
|
}
|
|
371
379
|
}
|
|
372
380
|
if (!sawTerminal) {
|
|
373
|
-
yield { eventType: "failed", summary: `claude process exited unexpectedly (code ${exitInfo.code ?? "unknown"})
|
|
381
|
+
yield { eventType: "failed", summary: `claude process exited unexpectedly (code ${exitInfo.code ?? "unknown"})`,
|
|
382
|
+
payload: { runtime_failure: runtimeFailure("cli_error") } };
|
|
374
383
|
}
|
|
375
384
|
}
|
|
376
385
|
catch (error) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
2
2
|
import { MAX_EVENT_SUMMARY_CHARS } from "../../protocol.js";
|
|
3
3
|
import { p1ModelEvent } from '../../verification/p1-execution-host.js';
|
|
4
|
+
import { runtimeFailure } from "../runtime-failure.js";
|
|
4
5
|
const EDIT_TOOL_NAMES = new Set(["Edit", "Write", "MultiEdit"]);
|
|
5
6
|
function bounded(summary) {
|
|
6
7
|
return summary.length > MAX_EVENT_SUMMARY_CHARS ? summary.slice(0, MAX_EVENT_SUMMARY_CHARS) : summary;
|
|
@@ -69,7 +70,66 @@ function parseSystemMessage(message) {
|
|
|
69
70
|
}
|
|
70
71
|
return null;
|
|
71
72
|
}
|
|
72
|
-
|
|
73
|
+
/*
|
|
74
|
+
* How Claude Code 2.1.308 (the listed release) says why a turn failed. An API error comes as a top-level assistant
|
|
75
|
+
* message with `error`, a coarse kind such as "authentication_failed", then a result with subtype "success",
|
|
76
|
+
* is_error true, `api_error_status` and, for some causes, `api_error`, a finer kind such as "usage_limit_reached".
|
|
77
|
+
* The CLI's own stops end with an error_* subtype. Only these identifiers and the status are read; the result text and
|
|
78
|
+
* `errors` hold provider wording and are never copied. Kinds this table does not name fall through to the status.
|
|
79
|
+
*/
|
|
80
|
+
const AUTH_KINDS = new Set(["authentication_failed", "oauth_org_not_allowed", "account_on_hold", "verification_required",
|
|
81
|
+
"cloud_credential_error"]);
|
|
82
|
+
const AUTH_API_ERRORS = new Set(["invalid_credential_header", "org_disabled_credential", "api_key_auth_disabled",
|
|
83
|
+
"provider_credentials", "gateway_signin_required", "gateway_session_expired"]);
|
|
84
|
+
const USAGE_API_ERRORS = new Set(["usage_limit_reached", "model_requires_usage_credits", "long_context_credits_required"]);
|
|
85
|
+
const KIND_CODES = new Map([["billing_error", "usage_limit"], ["rate_limit", "rate_limited"],
|
|
86
|
+
["model_not_found", "model_unavailable"], ["overloaded", "provider_error"], ["server_error", "provider_error"],
|
|
87
|
+
["max_output_tokens", "cli_error"]]);
|
|
88
|
+
const FAILURE_SUMMARIES = {
|
|
89
|
+
auth: "execution failed: Claude account sign-in refused",
|
|
90
|
+
usage_limit: "execution failed: Claude usage limit reached",
|
|
91
|
+
model_unavailable: "execution failed: model unavailable to the Claude account",
|
|
92
|
+
rate_limited: "execution failed: rate limited",
|
|
93
|
+
provider_error: "execution failed: provider API error",
|
|
94
|
+
budget_exceeded: "execution failed: budget exceeded",
|
|
95
|
+
cli_error: "execution failed: Claude CLI error",
|
|
96
|
+
unknown: "execution failed",
|
|
97
|
+
};
|
|
98
|
+
/** `assistantError` is the `error` of the last top-level assistant message before this result, if it had one. */
|
|
99
|
+
export function classifyClaudeFailure(message, assistantError) {
|
|
100
|
+
const subtype = String(message.subtype ?? "");
|
|
101
|
+
// The CLI ended the turn itself; no API error can be the cause.
|
|
102
|
+
if (subtype === "error_max_budget_usd" || subtype === "error_max_budget_exceeded")
|
|
103
|
+
return "budget_exceeded";
|
|
104
|
+
if (subtype === "error_max_turns")
|
|
105
|
+
return "cli_error";
|
|
106
|
+
const apiError = typeof message.api_error === "string" ? message.api_error : "";
|
|
107
|
+
if (AUTH_API_ERRORS.has(apiError))
|
|
108
|
+
return "auth";
|
|
109
|
+
if (USAGE_API_ERRORS.has(apiError))
|
|
110
|
+
return "usage_limit";
|
|
111
|
+
const kind = assistantError ?? "";
|
|
112
|
+
if (AUTH_KINDS.has(kind))
|
|
113
|
+
return "auth";
|
|
114
|
+
const mapped = KIND_CODES.get(kind);
|
|
115
|
+
if (mapped)
|
|
116
|
+
return mapped;
|
|
117
|
+
const status = message.api_error_status;
|
|
118
|
+
if (status === 401 || status === 403)
|
|
119
|
+
return "auth";
|
|
120
|
+
if (status === 402)
|
|
121
|
+
return "usage_limit";
|
|
122
|
+
if (status === 404)
|
|
123
|
+
return "model_unavailable";
|
|
124
|
+
if (status === 429)
|
|
125
|
+
return "rate_limited";
|
|
126
|
+
if (typeof status === "number" && Number.isInteger(status) && status >= 500 && status <= 599)
|
|
127
|
+
return "provider_error";
|
|
128
|
+
if (kind === "invalid_request" || subtype === "error_during_execution" || message.terminal_reason === "turn_setup_failed")
|
|
129
|
+
return "cli_error";
|
|
130
|
+
return "unknown";
|
|
131
|
+
}
|
|
132
|
+
function parseResultMessage(message, assistantError) {
|
|
73
133
|
const isError = message.is_error === true;
|
|
74
134
|
const costUsd = typeof message.total_cost_usd === "number" ? message.total_cost_usd : undefined;
|
|
75
135
|
if (!isError) {
|
|
@@ -90,8 +150,9 @@ function parseResultMessage(message) {
|
|
|
90
150
|
return { eventType: "failed", summary: "execution failed: structured output missing",
|
|
91
151
|
payload: { ...(costUsd !== undefined ? { cost_usd: costUsd } : {}), completion_rejection: { version: "completion-rejection-v1", code } } };
|
|
92
152
|
}
|
|
93
|
-
const
|
|
94
|
-
return { eventType: "failed", summary
|
|
153
|
+
const code = classifyClaudeFailure(message, assistantError);
|
|
154
|
+
return { eventType: "failed", summary: FAILURE_SUMMARIES[code],
|
|
155
|
+
payload: { ...(costUsd !== undefined ? { cost_usd: costUsd } : {}), runtime_failure: runtimeFailure(code, message.api_error_status) } };
|
|
95
156
|
}
|
|
96
157
|
export function parseClaudeLine(line, worktreeRoot, options = {}) {
|
|
97
158
|
let message;
|
|
@@ -110,7 +171,7 @@ export function parseClaudeLine(line, worktreeRoot, options = {}) {
|
|
|
110
171
|
case "user":
|
|
111
172
|
return parseUserMessage(message.message ?? {});
|
|
112
173
|
case "result":
|
|
113
|
-
return parseResultMessage(message);
|
|
174
|
+
return parseResultMessage(message, options.assistantError);
|
|
114
175
|
default:
|
|
115
176
|
return null;
|
|
116
177
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a provider CLI stopped, as one bounded code (no provider text). The Claude parser sets it on a failed terminal
|
|
3
|
+
* event; the daemon copies it into the job completion so the Gateway can tell an account problem from a code problem.
|
|
4
|
+
*
|
|
5
|
+
* - auth: the CLI's account was signed out, expired or refused.
|
|
6
|
+
* - usage_limit: the account's usage limit or credit ran out.
|
|
7
|
+
* - model_unavailable: the account cannot use the requested model.
|
|
8
|
+
* - rate_limited: the provider throttled the account for a short time.
|
|
9
|
+
* - provider_error: the provider's API failed or was overloaded.
|
|
10
|
+
* - budget_exceeded: the job's own cost budget ran out.
|
|
11
|
+
* - cli_error: the CLI stopped on its own error, not the account's or the provider's.
|
|
12
|
+
* - unknown: an error the CLI reported without a recognisable kind.
|
|
13
|
+
*/
|
|
14
|
+
export const RUNTIME_FAILURE_CODES = ["auth", "usage_limit", "model_unavailable", "rate_limited", "provider_error",
|
|
15
|
+
"budget_exceeded", "cli_error", "unknown"];
|
|
16
|
+
export function runtimeFailure(code, httpStatus) {
|
|
17
|
+
return { version: "runtime-failure-v1", code, ...(isHttpErrorStatus(httpStatus) ? { http_status: httpStatus } : {}) };
|
|
18
|
+
}
|
|
19
|
+
/** The exact v1 shape or undefined; copies nothing else. */
|
|
20
|
+
export function runtimeFailureDiagnostic(value) {
|
|
21
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
22
|
+
return undefined;
|
|
23
|
+
const d = value;
|
|
24
|
+
if (d.version !== "runtime-failure-v1" || !RUNTIME_FAILURE_CODES.includes(d.code))
|
|
25
|
+
return undefined;
|
|
26
|
+
if (Object.keys(d).some(key => !["version", "code", "http_status"].includes(key)))
|
|
27
|
+
return undefined;
|
|
28
|
+
if (d.http_status !== undefined && !isHttpErrorStatus(d.http_status))
|
|
29
|
+
return undefined;
|
|
30
|
+
return runtimeFailure(d.code, d.http_status);
|
|
31
|
+
}
|
|
32
|
+
function isHttpErrorStatus(value) {
|
|
33
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 400 && value <= 599;
|
|
34
|
+
}
|
|
@@ -1,30 +1,28 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
|
-
import { access,
|
|
4
|
-
import {
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { packageVersion } from "../package-version.js";
|
|
3
|
+
import { access, readdir, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
7
5
|
/*
|
|
8
|
-
* The Work Room pane mod
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* The retired Work Room pane mod. Runners from 0.1.79 until this change shipped the Claude Code mod
|
|
7
|
+
* `ai-gateway-work-room` (Work Item 98da2635) under claude-mod/ and installed it through the Claude CLI as a local
|
|
8
|
+
* marketplace: a copy under
|
|
9
|
+
* <stateRoot>/claude-mod/<runner version>/, `plugin marketplace add <copy>` and `plugin install <mod>@<marketplace>
|
|
10
|
+
* --scope user`. The menu bar app (Work Items b9218937, d2b6d0da and b35d65cb) now answers requests and shows Rooms,
|
|
11
|
+
* so the package no longer carries the mod (owner decision 2026-11-12, agreed with Henry).
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* Every setup installs the mod, with or without --integrations (owner decision 2026-10-31), unless the person removed
|
|
18
|
-
* it with `integrations remove claude`. That removal leaves <stateRoot>/claude-mod-removed, which
|
|
19
|
-
* `integrations install claude` and uninstall delete again.
|
|
13
|
+
* What is left removes a copy an older runner installed: setup, `integrations remove claude` and uninstall uninstall
|
|
14
|
+
* the mod and its marketplace and then delete <stateRoot>/claude-mod. Only that folder says an older runner installed
|
|
15
|
+
* it, so without it nothing runs, and the Claude CLI is never started.
|
|
20
16
|
*/
|
|
21
17
|
export const CLAUDE_MOD_NAME = "ai-gateway-work-room";
|
|
22
|
-
/** <stateRoot>/claude-mod-removed: the person removed the mod, so setup leaves it out until they install it again. */
|
|
23
|
-
export const CLAUDE_MOD_REMOVED_FILE = "claude-mod-removed";
|
|
24
18
|
export const CLAUDE_MOD_MARKETPLACE = "mtec-ai-gateway";
|
|
19
|
+
/** The folder in the state root that holds the copies an older runner installed. */
|
|
20
|
+
export const CLAUDE_MOD_DIR = "claude-mod";
|
|
21
|
+
/** Left by `integrations remove claude` on older runners; it means nothing any more and is deleted. */
|
|
22
|
+
export const CLAUDE_MOD_REMOVED_FILE = "claude-mod-removed";
|
|
25
23
|
const ID = `${CLAUDE_MOD_NAME}@${CLAUDE_MOD_MARKETPLACE}`;
|
|
26
24
|
const MIN_VERSION = [2, 1, 299];
|
|
27
|
-
const NO_CLI = "no Claude CLI 2.1.299 or newer
|
|
25
|
+
const NO_CLI = "no Claude CLI 2.1.299 or newer to uninstall the Work Room pane with; the copy stays until a later setup can";
|
|
28
26
|
const BUNDLED = ["Library", "Application Support", "Claude", "claude-code"];
|
|
29
27
|
const RUN_TIMEOUT_MS = 60_000;
|
|
30
28
|
function parseVersion(text) {
|
|
@@ -41,7 +39,10 @@ async function isExecutable(file) {
|
|
|
41
39
|
return false;
|
|
42
40
|
}
|
|
43
41
|
}
|
|
44
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* `claude` on PATH when it is new enough, else the newest desktop-bundled CLI that is; null when none is. Only a CLI of
|
|
44
|
+
* 2.1.299 or newer could install the mod, so only such a CLI is asked to remove it.
|
|
45
|
+
*/
|
|
45
46
|
export async function findClaudeCli(deps) {
|
|
46
47
|
const versionOf = async (file) => {
|
|
47
48
|
const out = await deps.run(file, ["--version"]);
|
|
@@ -71,92 +72,52 @@ export async function findClaudeCli(deps) {
|
|
|
71
72
|
}
|
|
72
73
|
return best?.file ?? null;
|
|
73
74
|
}
|
|
74
|
-
/**
|
|
75
|
+
/** Whether `plugin list --json` lists the mod, enabled or not; null when the list cannot be read. */
|
|
75
76
|
async function listed(deps, cli) {
|
|
76
77
|
const out = await deps.run(cli, ["plugin", "list", "--json"]);
|
|
77
78
|
if (out.code !== 0)
|
|
78
79
|
return null;
|
|
79
80
|
try {
|
|
80
81
|
const rows = JSON.parse(out.stdout);
|
|
81
|
-
|
|
82
|
-
if (!row)
|
|
83
|
-
return null;
|
|
84
|
-
return { enabled: row.enabled === true, ...(typeof row.readFromFolder === "string" ? { folder: row.readFromFolder } : {}) };
|
|
82
|
+
return Array.isArray(rows) ? rows.some(entry => entry.id === ID) : null;
|
|
85
83
|
}
|
|
86
84
|
catch {
|
|
87
85
|
return null;
|
|
88
86
|
}
|
|
89
87
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
88
|
+
/** Whether an older runner left a copy of the mod in the state root. */
|
|
89
|
+
export async function claudeModCopyExists(stateRoot) {
|
|
90
|
+
return stat(join(stateRoot, CLAUDE_MOD_DIR)).then(entry => entry.isDirectory(), () => false);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Removes the mod an older runner installed: uninstalls it and its marketplace, then deletes the runner's copies.
|
|
94
|
+
* Returns null when no older runner installed it. A copy Claude may still load is deleted only once the CLI no longer
|
|
95
|
+
* lists the mod, so a CLI that is missing or fails leaves a working pane for a later setup to remove. Never throws.
|
|
96
|
+
*/
|
|
97
|
+
export async function retireClaudeMod(deps) {
|
|
98
|
+
await rm(join(deps.stateRoot, CLAUDE_MOD_REMOVED_FILE), { force: true }).catch(() => undefined);
|
|
99
|
+
if (!await claudeModCopyExists(deps.stateRoot))
|
|
100
|
+
return null;
|
|
94
101
|
const cli = await findClaudeCli(deps);
|
|
95
102
|
if (cli === null)
|
|
96
103
|
return { state: "skipped", reason: NO_CLI };
|
|
97
|
-
const base = join(deps.stateRoot, "claude-mod");
|
|
98
|
-
const dir = join(base, deps.version);
|
|
99
104
|
try {
|
|
100
|
-
const
|
|
101
|
-
if (
|
|
102
|
-
return { state: "
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
// Both fail harmlessly when nothing is installed yet.
|
|
110
|
-
await deps.run(cli, ["plugin", "uninstall", ID, "--scope", "user"]);
|
|
105
|
+
const before = await listed(deps, cli);
|
|
106
|
+
if (before === null)
|
|
107
|
+
return { state: "failed", cli, reason: "plugin list failed" };
|
|
108
|
+
if (before) {
|
|
109
|
+
await deps.run(cli, ["plugin", "uninstall", ID, "--scope", "user"]);
|
|
110
|
+
if (await listed(deps, cli) !== false)
|
|
111
|
+
return { state: "failed", cli, reason: "plugin uninstall failed" };
|
|
112
|
+
}
|
|
113
|
+
// It fails harmlessly when the marketplace is already gone.
|
|
111
114
|
await deps.run(cli, ["plugin", "marketplace", "remove", CLAUDE_MOD_MARKETPLACE]);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if ((await deps.run(cli, ["plugin", "install", ID, "--scope", "user", "--json"])).code !== 0)
|
|
115
|
-
return { state: "failed", cli, reason: "plugin install failed" };
|
|
116
|
-
for (const old of await readdir(base))
|
|
117
|
-
if (old !== deps.version)
|
|
118
|
-
await rm(join(base, old), { recursive: true, force: true });
|
|
119
|
-
return { state: "installed", cli };
|
|
115
|
+
await rm(join(deps.stateRoot, CLAUDE_MOD_DIR), { recursive: true, force: true });
|
|
116
|
+
return { state: "removed", cli };
|
|
120
117
|
}
|
|
121
118
|
catch (error) {
|
|
122
|
-
return { state: "failed", cli, reason: error.code ?? "
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
/** Uninstalls the mod and its marketplace, then deletes the runner's copies. Never throws. */
|
|
126
|
-
export async function removeClaudeMod(deps) {
|
|
127
|
-
const cli = await findClaudeCli(deps);
|
|
128
|
-
if (cli !== null) {
|
|
129
|
-
await deps.run(cli, ["plugin", "uninstall", ID, "--scope", "user"]);
|
|
130
|
-
await deps.run(cli, ["plugin", "marketplace", "remove", CLAUDE_MOD_MARKETPLACE]);
|
|
131
|
-
}
|
|
132
|
-
await rm(join(deps.stateRoot, "claude-mod"), { recursive: true, force: true }).catch(() => undefined);
|
|
133
|
-
return { state: "removed", ...(cli ? { cli } : {}) };
|
|
134
|
-
}
|
|
135
|
-
/** Whether the person removed the mod with `integrations remove claude` since it was last installed on request. */
|
|
136
|
-
export async function claudeModRemovedByPerson(stateRoot) {
|
|
137
|
-
return stat(join(stateRoot, CLAUDE_MOD_REMOVED_FILE)).then(entry => entry.isFile(), () => false);
|
|
138
|
-
}
|
|
139
|
-
/** Records or clears the person's removal. */
|
|
140
|
-
export async function setClaudeModRemovedByPerson(stateRoot, removed) {
|
|
141
|
-
const file = join(stateRoot, CLAUDE_MOD_REMOVED_FILE);
|
|
142
|
-
if (!removed) {
|
|
143
|
-
await rm(file, { force: true });
|
|
144
|
-
return;
|
|
119
|
+
return { state: "failed", cli, reason: error.code ?? "removal failed" };
|
|
145
120
|
}
|
|
146
|
-
await mkdir(stateRoot, { recursive: true });
|
|
147
|
-
await writeFile(file, "Removed with integrations remove claude. Run integrations install claude to add the Work Room pane again.\n", { mode: 0o600 });
|
|
148
|
-
}
|
|
149
|
-
/** What `plugin list` says of the mod. Never throws. */
|
|
150
|
-
export async function claudeModStatus(deps) {
|
|
151
|
-
const cli = await findClaudeCli(deps);
|
|
152
|
-
if (cli === null)
|
|
153
|
-
return { state: "skipped", reason: NO_CLI };
|
|
154
|
-
const current = await listed(deps, cli).catch(() => null);
|
|
155
|
-
return { state: current === null ? "not_installed" : current.enabled ? "installed" : "disabled", cli };
|
|
156
|
-
}
|
|
157
|
-
/** The package's claude-mod/: two folders up from src/asks/, three from dist/src/asks/. */
|
|
158
|
-
function packagedModDir(moduleUrl = import.meta.url) {
|
|
159
|
-
return fileURLToPath(new URL(moduleUrl.toString().includes("/dist/src/") ? "../../../claude-mod/" : "../../claude-mod/", moduleUrl));
|
|
160
121
|
}
|
|
161
122
|
/**
|
|
162
123
|
* The deps setup, integrations and uninstall use. Each CLI call gets a fixed environment, as the Claude login probe
|
|
@@ -172,7 +133,7 @@ export function claudeModDeps(env, stateRoot, home) {
|
|
|
172
133
|
childEnv[key] = value;
|
|
173
134
|
}
|
|
174
135
|
return {
|
|
175
|
-
home, path: env.PATH, stateRoot,
|
|
136
|
+
home, path: env.PATH, stateRoot,
|
|
176
137
|
run: (file, args) => new Promise(resolve => {
|
|
177
138
|
execFile(file, args, { encoding: "utf8", timeout: RUN_TIMEOUT_MS, maxBuffer: 1_048_576, env: childEnv }, (error, stdout) => {
|
|
178
139
|
const code = error ? (typeof error.code === "number" ? error.code : 1) : 0;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { readdir, rm } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { ASKS_FILE } from "./store.js";
|
|
4
|
-
import {
|
|
4
|
+
import { claudeModCopyExists, claudeModDeps, retireClaudeMod } from "./claude-mod.js";
|
|
5
5
|
import { homeDirectory, installIntegration, integrationStatus, removeIntegration, removeShimIfUnused } from "./integrations.js";
|
|
6
6
|
import { APPS, isApp } from "./types.js";
|
|
7
7
|
import { deleteGuideState } from "../guide/files.js";
|
|
8
8
|
import { guideStatus, installGuide, removeGuide, removeGuideShimIfUnused } from "../guide/integrations.js";
|
|
9
9
|
const NEXT_STEP = {
|
|
10
|
-
claude: "Start a new Claude Code session, or run /reload-plugins in an open one, to load mtec-ai-gateway-asks@skills-dir
|
|
10
|
+
claude: "Start a new Claude Code session, or run /reload-plugins in an open one, to load mtec-ai-gateway-asks@skills-dir.",
|
|
11
11
|
// /hooks is a command of the Codex CLI in a terminal only: the Codex app sends it to the model as text.
|
|
12
12
|
codex: "Codex runs the four AI Gateway hooks only after you trust them once. In the Codex app, trust them under Settings → Hooks "
|
|
13
13
|
+ "or with the Review hooks button. In the Codex CLI in a terminal, trust them with /hooks. Until then the Slack message covers each ask.",
|
|
@@ -80,27 +80,19 @@ export async function statusOf(app, env, stateRoot, error) {
|
|
|
80
80
|
return { app, state: "unreadable", path: "", error: error ?? errorCode(failure) };
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
-
/**
|
|
84
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Removes the retired Work Room pane mod when an older runner installed it, and reports what happened; null when there
|
|
85
|
+
* was none. It never fails the hooks: a mod that cannot be removed is reported on its own. Never throws.
|
|
86
|
+
*/
|
|
87
|
+
export async function retireWorkRoomPane(env, stateRoot) {
|
|
85
88
|
try {
|
|
86
|
-
return await
|
|
89
|
+
return await retireClaudeMod(claudeModDeps(env, stateRoot, homeDirectory(env)));
|
|
87
90
|
}
|
|
88
91
|
catch (error) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* setup's Work Room mod: installed on every setup, with or without --integrations (owner decision 2026-10-31), unless
|
|
94
|
-
* the person removed it with `integrations remove claude`. Never throws.
|
|
95
|
-
*/
|
|
96
|
-
export async function setupClaudeMod(env, stateRoot) {
|
|
97
|
-
if (await claudeModRemovedByPerson(stateRoot)) {
|
|
98
|
-
return { state: "skipped", reason: "removed with integrations remove claude; run integrations install claude to add it again" };
|
|
92
|
+
// A home that cannot be found matters only when there is a copy to remove.
|
|
93
|
+
return await claudeModCopyExists(stateRoot) ? { state: "failed", reason: errorCode(error) } : null;
|
|
99
94
|
}
|
|
100
|
-
return claudeMod(env, stateRoot, installClaudeMod);
|
|
101
95
|
}
|
|
102
|
-
/** The Work Room mod's state, for integrations status and doctor. It runs the Claude CLI, so the daemon never calls it. */
|
|
103
|
-
export const claudeModStatusOf = (env, stateRoot) => claudeMod(env, stateRoot, claudeModStatus);
|
|
104
96
|
async function attempt(app, env, stateRoot, change) {
|
|
105
97
|
try {
|
|
106
98
|
return await change();
|
|
@@ -113,19 +105,17 @@ export async function installIntegrations(apps, env, stateRoot) {
|
|
|
113
105
|
const results = [];
|
|
114
106
|
for (const app of apps) {
|
|
115
107
|
const result = await attempt(app, env, stateRoot, () => installIntegration(app, env, stateRoot));
|
|
116
|
-
|
|
117
|
-
// The hooks first: they work without the mod, and a Claude CLI too old for the mod leaves them in place. Installing
|
|
118
|
-
// on request undoes an earlier removal, so setup keeps the mod again.
|
|
119
|
-
if (app === "claude")
|
|
120
|
-
await setClaudeModRemovedByPerson(stateRoot, false).catch(() => undefined);
|
|
121
|
-
results.push(app === "claude" ? { ...done, mod: await claudeMod(env, stateRoot, installClaudeMod) } : done);
|
|
108
|
+
results.push(result.error === undefined ? { ...result, action: NEXT_STEP[app] } : result);
|
|
122
109
|
}
|
|
123
110
|
return results;
|
|
124
111
|
}
|
|
125
|
-
/** Removes one app's asks hooks and, for Claude, the Work Room mod. */
|
|
112
|
+
/** Removes one app's asks hooks and, for Claude, the retired Work Room pane mod an older runner installed. */
|
|
126
113
|
async function removeAsks(app, env, stateRoot) {
|
|
127
114
|
const result = await attempt(app, env, stateRoot, () => removeIntegration(app, env, stateRoot));
|
|
128
|
-
|
|
115
|
+
if (app !== "claude")
|
|
116
|
+
return result;
|
|
117
|
+
const mod = await retireWorkRoomPane(env, stateRoot);
|
|
118
|
+
return mod === null ? result : { ...result, mod };
|
|
129
119
|
}
|
|
130
120
|
/** The guide state of one app; a status that cannot be read is reported with its error, never thrown. */
|
|
131
121
|
export async function guideStatusOf(app, env, stateRoot, error) {
|
|
@@ -176,8 +166,6 @@ export async function removeAsksInstall(env, stateRoot) {
|
|
|
176
166
|
const results = [];
|
|
177
167
|
for (const app of APPS)
|
|
178
168
|
results.push(await removeAsks(app, env, stateRoot));
|
|
179
|
-
// Uninstall removes everything, so a later setup starts fresh and installs the Work Room mod again.
|
|
180
|
-
await setClaudeModRemovedByPerson(stateRoot, false).catch(() => undefined);
|
|
181
169
|
// A hook left behind (an unreadable Codex hooks file) keeps its shim, which then prints nothing and exits 0.
|
|
182
170
|
await removeShimIfUnused(env, stateRoot).catch(() => false);
|
|
183
171
|
await rm(join(stateRoot, ASKS_FILE), { force: true });
|
|
@@ -202,14 +190,10 @@ export async function runIntegrationsCommand(argv, io) {
|
|
|
202
190
|
else if (parsed.action === "remove") {
|
|
203
191
|
for (const app of parsed.apps)
|
|
204
192
|
results.push(await removeAsks(app, io.env, io.stateRoot));
|
|
205
|
-
// The person removed the Work Room mod: setup leaves it out until integrations install claude.
|
|
206
|
-
if (parsed.apps.includes("claude"))
|
|
207
|
-
await setClaudeModRemovedByPerson(io.stateRoot, true).catch(() => undefined);
|
|
208
193
|
await removeShimIfUnused(io.env, io.stateRoot).catch(() => false);
|
|
209
194
|
}
|
|
210
195
|
else {
|
|
211
|
-
results = await Promise.all(parsed.apps.map(
|
|
212
|
-
? { ...await statusOf(app, io.env, io.stateRoot), mod: await claudeModStatusOf(io.env, io.stateRoot) } : statusOf(app, io.env, io.stateRoot)));
|
|
196
|
+
results = await Promise.all(parsed.apps.map(app => statusOf(app, io.env, io.stateRoot)));
|
|
213
197
|
}
|
|
214
198
|
}
|
|
215
199
|
if (!features.includes("guide")) {
|