mtech-ai-local-runner 0.1.112 → 0.1.114
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/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-entry.js +184 -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/runtime.js +17 -3
- package/dist/src/daemon/session-opener.js +7 -1
- package/dist/src/gateway-client.js +4 -2
- package/dist/src/menubar/ask-ai.js +92 -0
- package/dist/src/menubar/channel.js +11 -3
- package/dist/src/menubar/panel.js +5 -4
- package/dist/src/menubar/protocol.js +28 -1
- package/dist/src/menubar/wiring.js +12 -0
- package/dist/src/safety/claude-production-host.js +14 -3
- package/dist/src/safety/d4-host-composition.js +40 -1
- package/native/app/AI Gateway.app/Contents/Info.plist +2 -2
- package/native/app/AI Gateway.app/Contents/MacOS/AIGatewayMenubar +0 -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.114: ask your AI from the menu bar window
|
|
2
|
+
|
|
3
|
+
Select a request, decision, review or outcome record in the menu bar window, or a thread in its Rooms tab, and press
|
|
4
|
+
**Ask Claude** or **Ask Codex** (only the apps on this Mac show). A new session opens in that app with a short prompt
|
|
5
|
+
that names the item; nothing runs until you press Enter. Your AI then reads the item with your own sign-in and
|
|
6
|
+
discusses it with you. It answers, decides or records an outcome only after you confirm, the same as a session opened
|
|
7
|
+
from the Slack card. The button itself sends nothing and marks nothing read: **Mark read** stays the only way to mark
|
|
8
|
+
Room messages read from the window. The menu bar app is now version 1.3.0; `setup` installs it.
|
|
9
|
+
|
|
10
|
+
0.1.114 also carries the Codex app setup execution entry merged after 0.1.113 (PR #975). It is not wired in, so nothing
|
|
11
|
+
runs it.
|
|
12
|
+
|
|
13
|
+
## 0.1.113: the Work Room pane mod is removed
|
|
14
|
+
|
|
15
|
+
The Claude Code Work Room pane (`ai-gateway-work-room`) is no longer shipped (Gateway Work Item 2ae9d471). The menu bar
|
|
16
|
+
app answers requests and shows Rooms instead. Run `setup` once after updating: it uninstalls the pane and its
|
|
17
|
+
`mtec-ai-gateway` marketplace from Claude Code and deletes the runner's old copy. If setup reports `workRoomPane` as
|
|
18
|
+
`skipped` or `failed`, the pane keeps working until a later setup can remove it; `skipped` means no Claude CLI 2.1.299 or
|
|
19
|
+
newer was found. Ask Claude, Share answer to Room and Hide went with the pane.
|
|
20
|
+
|
|
21
|
+
0.1.113 also carries the runner work merged after 0.1.112 (PRs #964, #966, #968, #971). Reviewed-host registration stays
|
|
22
|
+
empty, so D4 execution stays closed, and the Codex app setup code is not wired in.
|
|
23
|
+
|
|
1
24
|
## 0.1.112: a failed Claude job says why it stopped
|
|
2
25
|
|
|
3
26
|
When a Claude job fails, the runner now tells the Gateway why, as one code: `auth` (the Claude account's sign-in was
|
|
@@ -486,7 +509,7 @@ covers it. A stored delivery is forgotten an hour after it arrived, by which tim
|
|
|
486
509
|
long run.
|
|
487
510
|
|
|
488
511
|
Integrations are installed only on request, with `setup --integrations` or `integrations install`; setup never asks
|
|
489
|
-
about them.
|
|
512
|
+
about them. An app already installed in the directory the environment names is skipped and reported installed, without
|
|
490
513
|
rewriting its files:
|
|
491
514
|
|
|
492
515
|
```bash
|
|
@@ -506,54 +529,14 @@ then the Slack message covers each ask. A
|
|
|
506
529
|
reports `CODEX_HOOKS_JSON_INVALID`. `uninstall` removes both integrations, the shim, `asks.json` and any temporary
|
|
507
530
|
`asks.json.*.tmp` file a crash left behind, and it also works on a setup that never finished.
|
|
508
531
|
|
|
509
|
-
|
|
510
|
-
98da2635).
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
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,
|
|
518
|
-
reply in a thread, share an answer to a Room or mark messages read. Each of these starts with Send (or the button that
|
|
519
|
-
names it), then a confirm row under that field or button that names who will see it, then Claude's permission dialog.
|
|
520
|
-
Opening a Room shows its threads with unread messages first and every unread message in them. The Room's Mark read
|
|
521
|
-
marks up to 100 of them at once; a Room with more than one thread also has a Mark read on each thread.
|
|
522
|
-
|
|
523
|
-
Ask Claude puts only ids and tool names in the conversation and asks Claude to read the full request and sum it up
|
|
524
|
-
in one line; nothing is sent, and it is the only button that starts a model turn. Hide hides a request, decision or
|
|
525
|
-
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.
|
|
526
|
-
"Hidden (N)" with Show lists them again, each with Unhide. The pane calls the AI Gateway only through your own AI
|
|
527
|
-
Gateway connector in Claude; without the connector the pane says so. The Claude mobile app draws no input field, so
|
|
528
|
-
there the pane reads, shares, marks read and offers Ask Claude.
|
|
529
|
-
|
|
530
|
-
After a Gateway change to a tool's arguments, refresh the AI Gateway tools list in Claude (Settings, Connectors, AI
|
|
531
|
-
Gateway) and start a new session: a session drops arguments its tools list does not declare. The pane says so when
|
|
532
|
-
its Room reads lose their arguments.
|
|
533
|
-
|
|
534
|
-
The pane calls the connector with no model turn, so a Claude session in auto mode would refuse every one of those
|
|
535
|
-
calls. The mod therefore ships PreToolUse hooks that match any AI Gateway connector and apply to every caller in the
|
|
536
|
-
session, the model included:
|
|
537
|
-
|
|
538
|
-
- They allow the two reads, `work_room_read` and `work_inbox`.
|
|
539
|
-
- They allow `work_room_receipt_report` only when its `state` is `presented`, the record that the pane showed you a
|
|
540
|
-
message.
|
|
541
|
-
- They ask you in Claude's own permission dialog before every write: `work_room_write`, `work_request_answer`, and
|
|
542
|
-
`work_room_receipt_report` for marking messages read. A write from the pane therefore needs its confirm press and
|
|
543
|
-
then your answer in that dialog.
|
|
544
|
-
|
|
545
|
-
The runner copies the mod to `claude-mod/<version>/` in the state root, without its tests, and installs it with the
|
|
546
|
-
Claude CLI as a local marketplace (`plugin marketplace add` and `plugin install --scope user`). This needs a Claude CLI
|
|
547
|
-
2.1.299 or newer: the first `claude` on `PATH` when it is new enough, else the newest one the Claude desktop app
|
|
548
|
-
bundles. Without one, the hooks are installed as before and the result reports the mod as `skipped`. Setup reinstalls
|
|
549
|
-
only a missing or older copy, and it never turns on a mod you turned off. `integrations status` and `doctor` report the
|
|
550
|
-
mod's state under `mod`; `integrations remove claude` and `uninstall` remove it. The daemon never runs the Claude CLI.
|
|
551
|
-
|
|
552
|
-
Every `setup` installs or updates the mod, with or without `--integrations`, and reports it as `workRoomPane`. So
|
|
553
|
-
rerunning `npx mtech-ai-local-runner@<version> setup` is enough to get the newest pane. With `--integrations claude`
|
|
554
|
-
the Claude integration installs it and reports it under its own `mod` instead. After `integrations remove claude`,
|
|
555
|
-
setup leaves the mod out, and its result says so. `integrations install claude` adds the mod again, and from then
|
|
556
|
-
setup keeps it up to date once more. `uninstall` forgets the removal, so a later setup starts fresh.
|
|
532
|
+
Runners from 0.1.79 also installed the Work Room pane, the Claude Code mod `ai-gateway-work-room@mtec-ai-gateway`
|
|
533
|
+
(Work Item 98da2635). It is retired: the menu bar app answers requests and shows Rooms instead, and the package no
|
|
534
|
+
longer carries the mod. Every `setup`, `integrations remove claude` and `uninstall` removes the copy an older runner
|
|
535
|
+
installed: it uninstalls the mod and its local marketplace with the Claude CLI, then deletes `claude-mod/` in the state
|
|
536
|
+
root, and reports what it did as `workRoomPane` (setup) or under the Claude integration's `mod`. With no such copy
|
|
537
|
+
nothing runs and nothing is reported. The copy is deleted only once the Claude CLI no longer lists the mod, so a Mac
|
|
538
|
+
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
|
|
539
|
+
again. The daemon never runs the Claude CLI.
|
|
557
540
|
|
|
558
541
|
`CLAUDE_CONFIG_DIR` and `CODEX_HOME` are honoured. The LaunchAgent that runs the daemon gets only `PATH`, so every
|
|
559
542
|
install adds the directory it used to a list per app in `integrations.json` in the state root, and the daemon,
|
|
@@ -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")) {
|
package/dist/src/asks/doctor.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { connect } from "node:net";
|
|
3
|
-
import {
|
|
3
|
+
import { statusOf } from "./commands.js";
|
|
4
4
|
import { hubSocketPath } from "./hub.js";
|
|
5
5
|
import { MAX_LINE_BYTES, encodeLine } from "./hub-protocol.js";
|
|
6
6
|
import { CLAUDE_PLUGIN_NAME } from "./integrations.js";
|
|
@@ -93,9 +93,8 @@ export async function asksDoctorReport(input) {
|
|
|
93
93
|
const [status, statuses] = await Promise.all([hubStatus(input.stateRoot), Promise.all(APPS.map(app => statusOf(app, input.env, input.stateRoot)))]);
|
|
94
94
|
const integrations = [];
|
|
95
95
|
for (const report of statuses) {
|
|
96
|
-
const mod = report.app === "claude" ? { mod: await claudeModStatusOf(input.env, input.stateRoot) } : {};
|
|
97
96
|
if (report.state !== "installed") {
|
|
98
|
-
integrations.push(
|
|
97
|
+
integrations.push(report);
|
|
99
98
|
continue;
|
|
100
99
|
}
|
|
101
100
|
if (report.app === "codex") {
|
|
@@ -103,7 +102,7 @@ export async function asksDoctorReport(input) {
|
|
|
103
102
|
continue;
|
|
104
103
|
}
|
|
105
104
|
const loaded = await claudePluginLoaded(input.env, input.run);
|
|
106
|
-
integrations.push({ ...report, ...
|
|
105
|
+
integrations.push({ ...report, ...(loaded === null ? {} : { loaded }), ...(loaded === false ? { action: CLAUDE_NOT_LOADED } : {}) });
|
|
107
106
|
}
|
|
108
107
|
return { mode: input.mode, hub: status === null ? { status: "not_running" } : { status: "running", ...status }, integrations };
|
|
109
108
|
}
|
package/dist/src/cli.js
CHANGED
|
@@ -36,7 +36,7 @@ import { access, mkdir, rm } from "node:fs/promises";
|
|
|
36
36
|
import { buildMessengerDaemon } from "./asks/messenger.js";
|
|
37
37
|
import { removeMenubarInstall, runMenubarCommand, setupMenubarApp } from "./menubar/commands.js";
|
|
38
38
|
import { runHookClient } from "./asks/hook-client.js";
|
|
39
|
-
import { installGuideIntegrations, installIntegrations, parseFeatures, parseSetupIntegrations, removeAsksInstall, removeGuideInstall,
|
|
39
|
+
import { installGuideIntegrations, installIntegrations, parseFeatures, parseSetupIntegrations, removeAsksInstall, removeGuideInstall, retireWorkRoomPane, runIntegrationsCommand } from "./asks/commands.js";
|
|
40
40
|
import { asksDoctorReport } from "./asks/doctor.js";
|
|
41
41
|
import { guideDoctorReport, guideReportShown } from "./guide/doctor.js";
|
|
42
42
|
import { runClaudeReleaseCommand } from "./commands/claude-release.js";
|
|
@@ -316,8 +316,8 @@ export async function main(argv, env, io = {}) {
|
|
|
316
316
|
const features = requestedFeatures ?? ["asks"];
|
|
317
317
|
const integrations = features.includes("asks") ? await installIntegrations(requestedIntegrations ?? [], env, config.stateRoot) : [];
|
|
318
318
|
const guide = features.includes("guide") ? await installGuideIntegrations(requestedIntegrations ?? [], env, config.stateRoot) : undefined;
|
|
319
|
-
// The Work Room pane
|
|
320
|
-
const workRoomPane =
|
|
319
|
+
// The Work Room pane mod is retired (owner decision 2026-11-12): every setup removes the copy an older runner installed.
|
|
320
|
+
const workRoomPane = await retireWorkRoomPane(env, config.stateRoot) ?? undefined;
|
|
321
321
|
// The menu bar app (Work Item b9218937) goes with every setup unless the person removed it.
|
|
322
322
|
const menubar = await setupMenubarApp(env, config.stateRoot);
|
|
323
323
|
// The LaunchAgent runs a copy under releases/<version>, never the npx cache that may have run setup.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { connectCodexAppSetup } from "./codex-app-tools-host.js";
|
|
3
|
+
const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
4
|
+
const blocked = () => ({ status: "blocked", creationBlocked: true, gatewayWritesBlocked: true });
|
|
5
|
+
/**
|
|
6
|
+
* One ephemeral, read-only callback invocation across the supported Node REPL/app tools.
|
|
7
|
+
* No network listener, durable inbox, execution aggregate, credentials or live-mode option.
|
|
8
|
+
* The authenticated supervisor dispatches only the returned exact official read call, then
|
|
9
|
+
* supplies its raw MCP response. Correlation IDs identify RPCs, never app/Work Sessions.
|
|
10
|
+
*/
|
|
11
|
+
export class CodexAppHostReadOnlyEntry {
|
|
12
|
+
connection;
|
|
13
|
+
scope;
|
|
14
|
+
started = false;
|
|
15
|
+
closed = false;
|
|
16
|
+
result;
|
|
17
|
+
waiting;
|
|
18
|
+
constructor(scope) {
|
|
19
|
+
this.scope = { ...scope };
|
|
20
|
+
const deny = async () => { throw new Error("Creation and Gateway writes are blocked in read-only host entry"); };
|
|
21
|
+
this.connection = connectCodexAppSetup({ approvedHostId: scope.hostId, approvedProjectId: scope.projectId,
|
|
22
|
+
tools: {
|
|
23
|
+
listProjects: args => this.request({ id: randomUUID(), tool: "list_projects", arguments: args }),
|
|
24
|
+
readThread: args => this.request({ id: randomUUID(), tool: "read_thread", arguments: args }),
|
|
25
|
+
createThread: deny,
|
|
26
|
+
}, claimDispatch: deny, report: deny });
|
|
27
|
+
}
|
|
28
|
+
start(input) {
|
|
29
|
+
if (this.started || this.closed)
|
|
30
|
+
throw new Error("Probe is single-use");
|
|
31
|
+
if (!object(input) || Object.keys(input).some(k => k !== "threadId") || (input.threadId !== undefined
|
|
32
|
+
&& (typeof input.threadId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(input.threadId))))
|
|
33
|
+
throw new Error("Exact existing thread ID or empty discovery input required");
|
|
34
|
+
const threadId = input.threadId;
|
|
35
|
+
this.started = true;
|
|
36
|
+
void this.probe(threadId).then(result => { if (!this.closed)
|
|
37
|
+
this.result = result; }, () => { if (!this.closed)
|
|
38
|
+
this.result = blocked(); });
|
|
39
|
+
}
|
|
40
|
+
async next() {
|
|
41
|
+
// Flush the local promise chain only; never waits for an external tool or polls a server.
|
|
42
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
43
|
+
if (this.result)
|
|
44
|
+
return { kind: "done", result: { ...this.result } };
|
|
45
|
+
if (!this.waiting)
|
|
46
|
+
return { kind: this.started ? "awaiting_reply" : "idle" };
|
|
47
|
+
if (this.waiting.delivered)
|
|
48
|
+
return { kind: "awaiting_reply" };
|
|
49
|
+
this.waiting.delivered = true;
|
|
50
|
+
return { kind: "tool_call", call: structuredClone(this.waiting.call) };
|
|
51
|
+
}
|
|
52
|
+
reply(id, response) {
|
|
53
|
+
const pending = this.waiting;
|
|
54
|
+
if (this.closed || !pending?.delivered || pending.call.id !== id)
|
|
55
|
+
throw new Error("No matching outstanding official call");
|
|
56
|
+
this.waiting = undefined;
|
|
57
|
+
pending.resolve(response);
|
|
58
|
+
}
|
|
59
|
+
close() {
|
|
60
|
+
this.closed = true;
|
|
61
|
+
this.result = blocked();
|
|
62
|
+
const pending = this.waiting;
|
|
63
|
+
this.waiting = undefined;
|
|
64
|
+
pending?.reject(new Error("Host probe closed"));
|
|
65
|
+
}
|
|
66
|
+
/** No caller-controlled flag, receipt or copied approval can enable side effects here. */
|
|
67
|
+
execute(_input) { return { status: "creation_blocked" }; }
|
|
68
|
+
/** Local fixtures only: exercise the original adapter up to its denied write boundary. */
|
|
69
|
+
startAdapterCheck(input) {
|
|
70
|
+
if (this.started || this.closed)
|
|
71
|
+
throw new Error("Probe is single-use");
|
|
72
|
+
this.started = true;
|
|
73
|
+
void this.connection.adapter.execute(input).then(result => {
|
|
74
|
+
if (!this.closed)
|
|
75
|
+
this.result = { ...blocked(), adapterStatus: result.status };
|
|
76
|
+
}, () => { if (!this.closed)
|
|
77
|
+
this.result = blocked(); });
|
|
78
|
+
}
|
|
79
|
+
request(call) {
|
|
80
|
+
if (this.closed || this.waiting)
|
|
81
|
+
return Promise.reject(new Error("Probe cannot issue another call"));
|
|
82
|
+
return new Promise((resolve, reject) => { this.waiting = { call, delivered: false, resolve, reject }; });
|
|
83
|
+
}
|
|
84
|
+
async probe(threadId) {
|
|
85
|
+
const projects = await this.connection.host.listProjects();
|
|
86
|
+
if (!projects.some(p => p.hostId === this.scope.hostId && p.projectId === this.scope.projectId))
|
|
87
|
+
return blocked();
|
|
88
|
+
if (!threadId)
|
|
89
|
+
return { status: "connected_read_only", creationBlocked: true, gatewayWritesBlocked: true };
|
|
90
|
+
const observation = await this.connection.host.track({ threadId });
|
|
91
|
+
const verified = observation.status === "identity_observed";
|
|
92
|
+
return { ...blocked(), status: verified ? "connected_read_only" : "blocked", identity: verified ? "identity_observed" : "unverified" };
|
|
93
|
+
}
|
|
94
|
+
}
|