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 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. The Work Room pane, below, is the one exception: every setup installs it. An app already installed in the directory the environment names is skipped and reported installed, without
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
- The Claude integration also installs the Work Room pane, the mod `ai-gateway-work-room@mtec-ai-gateway` (Work Item
510
- 98da2635). A band above the prompt says how many Room shares and requests are waiting. Its button, or `/work-room`,
511
- opens a pane beside the conversation with two tabs, each with its count. "Requests" shows each request, decision and
512
- review as a small card, newest first: a ► or ▼ title, and under it who asked and how long ago (with "Decision" or
513
- "Review request" in front when it is not a plain request). A reminder joins the request it repeats (same asker and
514
- title once a "Reminder:" or "Nhắc lại:" prefix is dropped, with ":" and "–" counted alike) as "+1 reminder". The
515
- newest card opens by itself; pressing a title opens or closes it, and only the open card shows its answer box, Ask
516
- Claude and Hide. "Rooms" shows each Room in its own box; pressing its ► or ▼ title opens it (with its newest
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, cp, mkdir, readdir, rm, stat, writeFile } from "node:fs/promises";
4
- import { basename, delimiter, isAbsolute, join } from "node:path";
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 (Work Item 98da2635), shipped in this package under claude-mod/ and installed through the
9
- * Claude CLI as a local marketplace, the way Plan A proved on 2026-10-30: `plugin marketplace add <folder>` and
10
- * `plugin install <mod>@<marketplace> --scope user`, both without a prompt. Claude reads the mod from that folder, so
11
- * it lives under <stateRoot>/claude-mod/<runner version>/ and an older folder is removed only after a newer install.
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
- * Only a CLI that loads `modules` hooks can install it. On 2026-10-30 the terminal `claude` 2.1.212 could not, and the
14
- * desktop-bundled 2.1.299 and 2.1.308 could; the desktop app bundles one even when the terminal `claude` is older.
15
- * A mod the person disabled stays disabled: setup reinstalls only a missing or older one.
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 (run claude update or install the Claude desktop app)";
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
- /** `claude` on PATH when it is new enough, else the newest desktop-bundled CLI that is; null when none is. */
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
- /** This runner's mod as `plugin list --json` reports it, or null when it is not listed. */
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
- const row = Array.isArray(rows) ? rows.find(entry => entry.id === ID) : undefined;
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
- // The mod's own tests and the types the engine lays while developing are not part of what Claude loads.
91
- const shipped = (source) => !/\.test\.tsx?$/.test(source) && !(basename(source) === "types" && source.includes(".claude-plugin"));
92
- /** Installs the mod for the person, or leaves it as it is when this release's copy is installed or the person disabled it. Never throws. */
93
- export async function installClaudeMod(deps) {
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 current = await listed(deps, cli);
101
- if (current && !current.enabled)
102
- return { state: "disabled", cli };
103
- const copied = await stat(join(dir, "hooks", "hooks.json")).then(entry => entry.isFile(), () => false);
104
- if (current?.folder === dir && copied)
105
- return { state: "installed", cli };
106
- await rm(dir, { recursive: true, force: true });
107
- await mkdir(base, { recursive: true });
108
- await cp(deps.sourceDir, dir, { recursive: true, filter: shipped });
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
- if ((await deps.run(cli, ["plugin", "marketplace", "add", dir])).code !== 0)
113
- return { state: "failed", cli, reason: "plugin marketplace add failed" };
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 ?? "install failed" };
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, version: packageVersion(), sourceDir: packagedModDir(),
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 { claudeModDeps, claudeModRemovedByPerson, claudeModStatus, installClaudeMod, removeClaudeMod, setClaudeModRemovedByPerson } from "./claude-mod.js";
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 and the Work Room pane (/work-room).",
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
- /** One act on the Work Room mod. It never fails the hooks: a mod that cannot be handled is reported on its own. */
84
- async function claudeMod(env, stateRoot, act) {
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 act(claudeModDeps(env, stateRoot, homeDirectory(env)));
89
+ return await retireClaudeMod(claudeModDeps(env, stateRoot, homeDirectory(env)));
87
90
  }
88
91
  catch (error) {
89
- return { state: "failed", reason: errorCode(error) };
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
- const done = result.error === undefined ? { ...result, action: NEXT_STEP[app] } : result;
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
- return app === "claude" ? { ...result, mod: await claudeMod(env, stateRoot, removeClaudeMod) } : result;
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(async (app) => app === "claude"
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")) {
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { connect } from "node:net";
3
- import { claudeModStatusOf, statusOf } from "./commands.js";
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({ ...report, ...mod });
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, ...mod, ...(loaded === null ? {} : { loaded }), ...(loaded === false ? { action: CLAUDE_NOT_LOADED } : {}) });
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, runIntegrationsCommand, setupClaudeMod } from "./asks/commands.js";
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 goes with every setup (owner decision 2026-10-31); --integrations claude has just installed it.
320
- const workRoomPane = integrations.some(report => report.app === "claude" && report.mod !== undefined) ? undefined : await setupClaudeMod(env, config.stateRoot);
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
+ }