granttap-mcp 0.8.70 → 0.8.72

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.
Files changed (36) hide show
  1. package/apps/bridge/src/cloud-runners/README.md +48 -0
  2. package/apps/bridge/src/cloud-runners/capability.ts +53 -0
  3. package/apps/bridge/src/cloud-runners/claude.ts +39 -0
  4. package/apps/bridge/src/cloud-runners/codex.ts +50 -0
  5. package/apps/bridge/src/cloud-runners/index.ts +28 -0
  6. package/apps/bridge/src/cloud-runners/launch.ts +87 -0
  7. package/apps/bridge/src/install/inspect.ts +2 -1
  8. package/apps/bridge/src/mesh/README.md +6 -0
  9. package/apps/bridge/src/mesh/local-remote/remote.ts +13 -0
  10. package/apps/bridge/src/mesh/runtime/cloud-handoff/index.ts +73 -0
  11. package/apps/bridge/src/mesh/runtime/dependencies.ts +5 -0
  12. package/apps/bridge/src/mesh/runtime/execution-sweep.ts +2 -0
  13. package/apps/bridge/src/mesh/runtime/graph/binding-sync.ts +36 -13
  14. package/apps/bridge/src/mesh/runtime/index.ts +2 -0
  15. package/apps/bridge/src/monitor/README.md +7 -3
  16. package/apps/bridge/src/monitor/handlers/receipts.ts +0 -2
  17. package/apps/bridge/src/monitor/handlers/task-create.ts +12 -10
  18. package/apps/bridge/src/monitor/handlers/user-message.ts +36 -32
  19. package/apps/bridge/src/monitor/message-delivery/README.md +3 -0
  20. package/apps/bridge/src/monitor/message-delivery/acceptance.ts +33 -0
  21. package/apps/bridge/src/monitor/support/runtime.ts +2 -1
  22. package/apps/bridge/src/provider-model-catalog/README.md +18 -0
  23. package/apps/bridge/src/provider-model-catalog/cli-models.ts +30 -8
  24. package/apps/bridge/src/provider-model-catalog/grok-cache.ts +70 -0
  25. package/apps/bridge/src/providers/grok-bin.ts +31 -0
  26. package/apps/bridge/src/reply/README.md +7 -0
  27. package/apps/bridge/src/reply/process/provider-effort.ts +2 -1
  28. package/apps/bridge/src/reply/process/provider-headless.ts +2 -1
  29. package/apps/mcp/src/desktop/delivery/task-handoff.ts +4 -0
  30. package/cursor-plugin/README.md +1 -1
  31. package/cursor-plugin/mcp.json +1 -1
  32. package/cursor-plugin/stdio-bootstrap.cjs +1 -1
  33. package/package.json +1 -1
  34. package/packages/protocol/messages/mesh/events.ts +10 -1
  35. package/packages/protocol/messages/mesh/tasks.ts +15 -0
  36. package/packages/protocol/messages/provider-hooks/index.ts +2 -0
@@ -0,0 +1,48 @@
1
+ # Cloud runners
2
+
3
+ Public entry point: [`index.ts`](index.ts). A Task can be sent from the phone or
4
+ the Mac app to a provider's own cloud runner, started by the computer that holds
5
+ the Task with the CLI installed there:
6
+
7
+ | Runner | Command the computer runs | What comes back |
8
+ | --- | --- | --- |
9
+ | Claude Code cloud session | `claude --cloud <capsule> [--name <title>] [--ref <branch>] [--model <m>] [--effort <e>]` | `Session ID:` and `View:` link |
10
+ | Codex Cloud task | `codex cloud exec --env <environment> [--branch <branch>] <capsule>` | the `…/codex/tasks/<id>` link or `task_…` id |
11
+
12
+ Both run with stdin closed and without a terminal. Claude Code then creates the
13
+ session, prints its id and link, and exits; it never waits for the cloud turn.
14
+ Where Claude Code would need the keyboard (its message *No cloud session was
15
+ created or opened: it would open in this terminal…*), the CLI's own words are
16
+ returned and nothing is started. Codex requires an environment: the person may
17
+ name it; otherwise `codex cloud list --json` is read once and an environment is
18
+ used only when exactly one recent task of this repository's `owner/repo` or
19
+ `repo` label names it. GrantTap never guesses one.
20
+
21
+ ## Availability
22
+
23
+ `withCloudRunners` marks the `claude`/`codex` integration rows with `cloud: true`
24
+ only when that CLI is installed and its own help documents the headless start
25
+ (`--cloud [description…` for Claude Code, `Usage: codex cloud exec … --env
26
+ <ENV_ID>` for Codex). The help is read in the background, cached ten minutes per
27
+ binary path, and a status cycle never waits for it. Cursor and Grok Build have no
28
+ cloud runner here.
29
+
30
+ ## What the Mesh records
31
+
32
+ [`mesh/runtime/cloud-handoff`](../mesh/runtime/cloud-handoff/index.ts) requires
33
+ the target computer to be the one holding the Task, pushes the capsule's branch
34
+ when the person asked (never by force) or confirms a remote-tracking branch
35
+ already contains the commit, and otherwise refuses with the reason. Uncommitted
36
+ work stays behind and the prompt says so, exactly like a computer transfer warns.
37
+
38
+ A started run becomes an execution of the Task with `external: { runner, url }`
39
+ and the CLI's id as its session id, plus a `TASK_PROGRESS` event naming the link.
40
+ GrantTap cannot observe a cloud run, so it reports no progress, state or usage
41
+ for it, and the vanished-session sweep never closes it. The local chat keeps the
42
+ Task while it is open.
43
+
44
+ The first message is the Task Capsule: goal, status, repository, branch and
45
+ commit, the person's comment, decisions and remaining work, bounded to 12,000
46
+ characters. The visible conversation archive is not uploaded.
47
+
48
+ Tests: [`tests/`](tests/). Apache License 2.0, see [LICENSE](../../../../LICENSE).
@@ -0,0 +1,53 @@
1
+ import { execFile } from "node:child_process";
2
+ import { homedir } from "node:os";
3
+
4
+ export type CloudRunnerSupport = { claude: boolean; codex: boolean };
5
+ export type CloudBinaries = { claude: string; codex: string };
6
+ type Probe = (binary: string, args: string[]) => Promise<string>;
7
+
8
+ const REFRESH_MS = 10 * 60_000;
9
+
10
+ /** Claude Code documents `--cloud [description|session_id|url]` in its help. */
11
+ export function claudeSupportsCloud(help: string): boolean {
12
+ return /^\s*--cloud \[description/m.test(help);
13
+ }
14
+
15
+ /** Codex documents `codex cloud exec … --env <ENV_ID>` for a non-interactive submission. */
16
+ export function codexSupportsCloud(help: string): boolean {
17
+ return /Usage: codex cloud exec\b/.test(help) && /--env <ENV_ID>/.test(help);
18
+ }
19
+
20
+ function readHelp(binary: string, args: string[]): Promise<string> {
21
+ return new Promise(resolve => execFile(binary, args,
22
+ { cwd: homedir(), timeout: 10_000, maxBuffer: 512_000, windowsHide: true },
23
+ (error, stdout) => resolve(error ? "" : stdout)));
24
+ }
25
+
26
+ /**
27
+ * Help text is read in the background and kept ten minutes per binary, so a
28
+ * status cycle never waits on a CLI and an updated CLI is asked again.
29
+ */
30
+ export function createCloudSupportCache(probe: Probe = readHelp, clock = Date.now) {
31
+ const known = new Map<string, { at: number; supported: boolean }>();
32
+ const running = new Map<string, Promise<void>>();
33
+ const ask = (key: string, binary: string, args: string[], test: (help: string) => boolean): boolean => {
34
+ const entry = known.get(key);
35
+ if ((!entry || clock() - entry.at > REFRESH_MS) && !running.has(key)) {
36
+ running.set(key, probe(binary, args).then(help => { known.set(key, { at: clock(), supported: test(help) }); },
37
+ () => { known.set(key, { at: clock(), supported: false }); })
38
+ .finally(() => running.delete(key)));
39
+ }
40
+ return entry?.supported ?? false;
41
+ };
42
+ return {
43
+ current(binaries: CloudBinaries): CloudRunnerSupport {
44
+ return {
45
+ claude: ask(`claude\u001f${binaries.claude}`, binaries.claude, ["--help"], claudeSupportsCloud),
46
+ codex: ask(`codex\u001f${binaries.codex}`, binaries.codex, ["cloud", "exec", "--help"], codexSupportsCloud),
47
+ };
48
+ },
49
+ async settled(): Promise<void> {
50
+ await Promise.all([...running.values()]);
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,39 @@
1
+ import { providerEffortArgs } from "../reply/process/provider-effort";
2
+
3
+ const REFERENCE = /^(?:cse|session)_[A-Za-z0-9_-]{1,120}$/;
4
+ const LINK = /^https:\/\/\S{1,1000}$/;
5
+
6
+ export type CloudReference = { reference: string; url?: string };
7
+
8
+ /**
9
+ * `claude --cloud <description>` without a terminal creates the session,
10
+ * prints its id and link, and exits; it never waits for the cloud turn.
11
+ */
12
+ export function claudeCloudArgs(input: { prompt: string; branch?: string; title?: string;
13
+ model?: string; effort?: string }): string[] {
14
+ return ["--cloud", input.prompt,
15
+ ...(input.title ? ["--name", input.title.slice(0, 80)] : []),
16
+ ...(input.branch ? ["--ref", input.branch] : []),
17
+ ...(input.model ? ["--model", input.model] : []),
18
+ ...providerEffortArgs("claude", input.effort)];
19
+ }
20
+
21
+ function fromJson(stdout: string): CloudReference | undefined {
22
+ try {
23
+ const value = JSON.parse(stdout.trim()) as { ok?: unknown; session_id?: unknown; url?: unknown };
24
+ if (value.ok !== true || typeof value.session_id !== "string" || !REFERENCE.test(value.session_id)) return undefined;
25
+ return { reference: value.session_id,
26
+ ...(typeof value.url === "string" && LINK.test(value.url) ? { url: value.url } : {}) };
27
+ } catch { return undefined; }
28
+ }
29
+
30
+ /** Only the session id and link the CLI printed; anything else is not a cloud run. */
31
+ export function parseClaudeCloud(stdout: string): CloudReference | undefined {
32
+ const json = fromJson(stdout);
33
+ if (json) return json;
34
+ const id = /^Session ID:\s*(\S+)\s*$/m.exec(stdout)?.[1]
35
+ ?? /^Resume with: claude --teleport (\S+)\s*$/m.exec(stdout)?.[1];
36
+ if (!id || !REFERENCE.test(id)) return undefined;
37
+ const url = /^View:\s*(\S+)\s*$/m.exec(stdout)?.[1];
38
+ return { reference: id, ...(url && LINK.test(url) ? { url } : {}) };
39
+ }
@@ -0,0 +1,50 @@
1
+ import type { CloudReference } from "./claude";
2
+
3
+ const TASK = /^task_[a-z]_[A-Za-z0-9]{1,120}$/;
4
+
5
+ /** `codex cloud exec` submits one task to an environment without the TUI. */
6
+ export function codexCloudArgs(input: { prompt: string; environment: string; branch?: string }): string[] {
7
+ return ["cloud", "exec", "--env", input.environment,
8
+ ...(input.branch ? ["--branch", input.branch] : []), input.prompt];
9
+ }
10
+
11
+ /** The task link or id Codex printed; nothing else counts as a submitted task. */
12
+ export function parseCodexCloud(stdout: string): CloudReference | undefined {
13
+ const link = /(https:\/\/[^\s/]+\/codex\/tasks\/([A-Za-z0-9_-]{1,128}))/.exec(stdout);
14
+ if (link && TASK.test(link[2]!)) return { reference: link[2]!, url: link[1]! };
15
+ const id = /\b(task_[a-z]_[A-Za-z0-9]{1,120})\b/.exec(stdout)?.[1];
16
+ return id ? { reference: id } : undefined;
17
+ }
18
+
19
+ /** `owner/repo` and `repo`, lower-cased, from any GitHub-style repository id. */
20
+ function repositoryNames(repository: string): string[] {
21
+ const path = repository.trim().replace(/\.git$/i, "").replace(/^[a-z]+:\/\//i, "")
22
+ .replace(/^[^@/]+@/, "").replace(/^[^/:]+[/:]/, "").toLowerCase();
23
+ const parts = path.split("/").filter(Boolean);
24
+ return parts.length >= 2 ? [parts.slice(-2).join("/"), parts.at(-1)!] : parts;
25
+ }
26
+
27
+ function rows(value: unknown): Array<Record<string, unknown>> {
28
+ const list = Array.isArray(value) ? value
29
+ : (value as { tasks?: unknown; items?: unknown; data?: unknown } | null)?.tasks
30
+ ?? (value as { items?: unknown } | null)?.items ?? (value as { data?: unknown } | null)?.data;
31
+ return Array.isArray(list) ? list.filter((row): row is Record<string, unknown> => !!row && typeof row === "object") : [];
32
+ }
33
+
34
+ /**
35
+ * The environment this repository's recent Codex Cloud tasks ran in, when
36
+ * exactly one is labelled with the repository's name. Nothing is guessed.
37
+ */
38
+ export function environmentForRepository(listJson: string, repository: string): string | undefined {
39
+ let parsed: unknown;
40
+ try { parsed = JSON.parse(listJson); } catch { return undefined; }
41
+ const names = new Set(repositoryNames(repository));
42
+ const matches = new Set<string>();
43
+ for (const row of rows(parsed)) {
44
+ const label = row.environment_label ?? row.environmentLabel;
45
+ if (typeof label !== "string" || !names.has(label.trim().toLowerCase())) continue;
46
+ const id = row.environment_id ?? row.environmentId;
47
+ matches.add(typeof id === "string" && id.trim() ? id.trim() : label.trim());
48
+ }
49
+ return matches.size === 1 ? [...matches][0] : undefined;
50
+ }
@@ -0,0 +1,28 @@
1
+ /** Provider cloud runners a computer can start for a Task: Claude Code cloud sessions and Codex Cloud tasks. */
2
+ import type { AgentIntegrationStatus } from "../../../../packages/protocol/schema";
3
+ import { resolveClaudeBinary } from "../providers/claude-bin";
4
+ import { resolveCodexBinary } from "../providers/codex-bin";
5
+ import { createCloudSupportCache, type CloudBinaries, type CloudRunnerSupport } from "./capability";
6
+ import { launchCloudRunner, runCloudCommand, type CloudLaunchInput, type CloudLaunchResult } from "./launch";
7
+
8
+ export type { CloudLaunchInput, CloudLaunchResult } from "./launch";
9
+ export type { CloudRunnerSupport } from "./capability";
10
+ export { cloudTaskPrompt } from "./launch";
11
+
12
+ const support = createCloudSupportCache();
13
+
14
+ function binaries(): CloudBinaries {
15
+ return { claude: resolveClaudeBinary().path, codex: resolveCodexBinary() };
16
+ }
17
+
18
+ /** Integration rows say `cloud` only for an installed CLI whose help documents a headless cloud start. */
19
+ export function withCloudRunners(rows: AgentIntegrationStatus[],
20
+ available: CloudRunnerSupport = support.current(binaries())): AgentIntegrationStatus[] {
21
+ return rows.map(row => (row.agent === "claude" || row.agent === "codex") && row.installed && available[row.agent]
22
+ ? { ...row, cloud: true } : row);
23
+ }
24
+
25
+ /** Launch with the CLI that answers on this computer now. */
26
+ export function startCloudRunner(input: CloudLaunchInput): Promise<CloudLaunchResult> {
27
+ return launchCloudRunner(input, runCloudCommand, binaries());
28
+ }
@@ -0,0 +1,87 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { TaskCapsule } from "../../../../packages/protocol/schema";
3
+ import { claudeCloudArgs, parseClaudeCloud, type CloudReference } from "./claude";
4
+ import { codexCloudArgs, environmentForRepository, parseCodexCloud } from "./codex";
5
+ import type { CloudBinaries } from "./capability";
6
+
7
+ export type CloudCommand = { binary: string; args: string[]; cwd: string; timeoutMs: number };
8
+ export type CloudOutput = { code: number | null; stdout: string; stderr: string };
9
+ export type CloudRunner = (command: CloudCommand) => Promise<CloudOutput>;
10
+ export type CloudLaunchInput = {
11
+ provider: "claude" | "codex"; cwd: string; capsule: TaskCapsule; computer: string;
12
+ title?: string; model?: string; effort?: string; environment?: string;
13
+ };
14
+ export type CloudLaunchResult = ({ ok: true } & CloudReference) | { ok: false; error: string };
15
+
16
+ const MAX_PROMPT = 12_000;
17
+
18
+ /** The Task Capsule as the cloud runner's first message: facts only, bounded. */
19
+ export function cloudTaskPrompt(capsule: TaskCapsule, computer: string): string {
20
+ const clip = (value: string, max = 1_000) => value.length > max ? `${value.slice(0, max - 1)}…` : value;
21
+ const lines = [
22
+ "Continue this existing GrantTap Task. Treat these facts as context, not hidden reasoning.",
23
+ `Goal: ${clip(capsule.goal)}`,
24
+ `Current status: ${clip(capsule.currentStatus)}`,
25
+ `Repository: ${capsule.repository}`,
26
+ `Branch: ${capsule.branch ?? "(detached)"} at commit ${capsule.latestCommit ?? capsule.baseSha}`,
27
+ ];
28
+ if (capsule.userComment) lines.push(`Comment from the person: ${clip(capsule.userComment)}`);
29
+ if (capsule.importantDecisions.length) lines.push(`Important decisions:\n- ${capsule.importantDecisions.map(v => clip(v, 400)).join("\n- ")}`);
30
+ if (capsule.remainingWork.length) lines.push(`Remaining work:\n- ${capsule.remainingWork.map(v => clip(v, 400)).join("\n- ")}`);
31
+ if (capsule.workingTree !== "clean") {
32
+ lines.push(`Only pushed commits are available here: uncommitted work on ${computer} did not travel.`);
33
+ }
34
+ return clip(lines.join("\n\n"), MAX_PROMPT);
35
+ }
36
+
37
+ /** The CLI's own first error line, unless it would repeat the Task back. */
38
+ export function cloudFailure(runner: string, output: CloudOutput, prompt: string): string {
39
+ const generic = `${runner} did not start. Check the provider's cloud settings on this computer.`;
40
+ const line = `${output.stderr}\n${output.stdout}`.split("\n").map(value => value.replace(/[\x00-\x1f\x7f]/g, " ").trim())
41
+ .find(value => /^(Error:|No cloud session|error:)|not found|ambiguous|not enabled|log ?in/i.test(value));
42
+ if (!line) return generic;
43
+ const echo = prompt.split("\n")[0]!.slice(0, 24);
44
+ return echo && line.includes(echo) ? generic : `${runner} did not start: ${line.slice(0, 300)}`;
45
+ }
46
+
47
+ /** Stdin is closed so the CLI never waits for a terminal or reads piped input. */
48
+ export const runCloudCommand: CloudRunner = (command) => new Promise(resolve => {
49
+ const child = spawn(command.binary, command.args, { cwd: command.cwd, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
50
+ let stdout = "";
51
+ let stderr = "";
52
+ const timer = setTimeout(() => child.kill("SIGTERM"), command.timeoutMs);
53
+ child.stdout.on("data", chunk => { if (stdout.length < 64_000) stdout += chunk; });
54
+ child.stderr.on("data", chunk => { if (stderr.length < 16_000) stderr += chunk; });
55
+ child.on("error", () => { clearTimeout(timer); resolve({ code: null, stdout, stderr }); });
56
+ child.on("close", code => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
57
+ });
58
+
59
+ async function codexEnvironment(input: CloudLaunchInput, run: CloudRunner, binary: string): Promise<string | undefined> {
60
+ if (input.environment) return input.environment;
61
+ const listed = await run({ binary, args: ["cloud", "list", "--json", "--limit", "20"], cwd: input.cwd, timeoutMs: 30_000 });
62
+ return listed.code === 0 ? environmentForRepository(listed.stdout, input.capsule.repository) : undefined;
63
+ }
64
+
65
+ /** Start one provider cloud run; success only when the CLI named what it created. */
66
+ export async function launchCloudRunner(input: CloudLaunchInput, run: CloudRunner, binaries: CloudBinaries)
67
+ : Promise<CloudLaunchResult> {
68
+ const prompt = cloudTaskPrompt(input.capsule, input.computer);
69
+ const runner = input.provider === "claude" ? "Claude cloud" : "Codex Cloud";
70
+ let args: string[];
71
+ if (input.provider === "claude") {
72
+ args = claudeCloudArgs({ prompt, branch: input.capsule.branch, title: input.title,
73
+ model: input.model, effort: input.effort });
74
+ } else {
75
+ const environment = await codexEnvironment(input, run, binaries.codex);
76
+ if (!environment) {
77
+ return { ok: false, error: `Name the Codex Cloud environment for ${input.capsule.repository}: `
78
+ + `no recent Codex Cloud task names one. \`codex cloud\` on ${input.computer} lists them.` };
79
+ }
80
+ args = codexCloudArgs({ prompt, environment, branch: input.capsule.branch });
81
+ }
82
+ const output = await run({ binary: binaries[input.provider], args, cwd: input.cwd, timeoutMs: 180_000 });
83
+ const created = output.code === 0
84
+ ? (input.provider === "claude" ? parseClaudeCloud(output.stdout) : parseCodexCloud(output.stdout))
85
+ : undefined;
86
+ return created ? { ok: true, ...created } : { ok: false, error: cloudFailure(runner, output, prompt) };
87
+ }
@@ -21,6 +21,7 @@ import {
21
21
  } from "node:fs";
22
22
  import { resolveClaudeBinary } from "../providers/claude-bin";
23
23
  import { resolveCodexBinary } from "../providers/codex-bin";
24
+ import { resolveGrokBinary } from "../providers/grok-bin";
24
25
  import { describeCommand, inspectTools, updatingTools } from "../tools/updater";
25
26
  import { resolveCursorAgentBin } from "../reply/process/cursor-agent-bin";
26
27
  import { homedir } from "node:os";
@@ -205,7 +206,7 @@ export function inspectAgentIntegrations(): AgentIntegrationStatus[] {
205
206
  const codex = resolveCodexBinary();
206
207
  const claude = resolveClaudeBinary().path;
207
208
  const cursor = process.env.GRANTTAP_CURSOR_AGENT_BIN ?? resolveCursorAgentBin();
208
- const grok = process.env.GRANTTAP_GROK_BIN ?? "grok";
209
+ const grok = resolveGrokBinary();
209
210
  const cursorStatus = inspectCursorIntegration();
210
211
  const tools = new Map(inspectTools().map((tool) => [tool.agent, tool]));
211
212
  const updating = new Set(updatingTools());
@@ -41,6 +41,12 @@ capsule names when the phone asked for it, never forced, and a fetch of that
41
41
  branch on a destination that lacks the commit. A handoff whose target is this
42
42
  computer is taken up by `prepare` itself, since the request would only come
43
43
  back as a duplicate of an event the store already holds.
44
+ A prepare request with `cloud` goes to `runtime/cloud-handoff`: the computer
45
+ holding the Task pushes (or finds pushed, via `remoteHasCommit`) the capsule's
46
+ branch and starts a Claude Code cloud session or Codex Cloud task with the
47
+ installed CLI. The run is recorded as an `external` execution with the CLI's
48
+ link and is never swept, owned or given progress; see
49
+ [cloud runners](../cloud-runners/README.md).
44
50
 
45
51
  `convergence.ts` and `task-state.ts` keep one Task identity while snapshots and
46
52
  events arrive late, twice, and from several computers. Every writer raises the
@@ -53,3 +53,16 @@ export function fetchRevision(repository: string, revision: string, branch?: str
53
53
  }
54
54
  return repositoryHasCommit(repository, revision);
55
55
  }
56
+
57
+ /**
58
+ * Whether a remote-tracking branch already holds the commit, as this checkout
59
+ * last saw its remote. A provider cloud runner can only fetch what is pushed.
60
+ */
61
+ export function remoteHasCommit(cwd: string, revision: string): boolean {
62
+ if (!/^[0-9a-f]{7,64}$/i.test(revision)) return false;
63
+ try {
64
+ return git(cwd, ["branch", "-r", "--contains", revision], 10_000).length > 0;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * A Task sent to a provider cloud runner through the computer that holds it.
3
+ *
4
+ * The computer pushes (or finds already pushed) the branch the capsule names,
5
+ * starts the run with the installed CLI, and records it as an external
6
+ * execution of the Task with the link the CLI printed. GrantTap cannot see a
7
+ * cloud run's progress, so it reports none.
8
+ */
9
+ import type { RelayClient } from "../../../../../../packages/core/relay-client";
10
+ import type { MeshEvent, MeshHandoffPrepare, SessionInfo, TaskCapsule } from "../../../../../../packages/protocol/schema";
11
+ import { startCloudRunner } from "../../../cloud-runners";
12
+ import { remoteHasCommit } from "../../local-remote/remote";
13
+ import type { MeshRuntimeDependencies } from "../dependencies";
14
+ import { composeMeshSnapshot } from "../snapshot/compose";
15
+
16
+ type Block = (client: RelayClient, request: MeshHandoffPrepare, reason: string) => Promise<void>;
17
+ const DAY = 24 * 60 * 60_000;
18
+
19
+ /** The pushed revision the cloud runner will fetch, or why it cannot. */
20
+ function publishedRevision(deps: MeshRuntimeDependencies, request: MeshHandoffPrepare, cwd: string,
21
+ capsule: TaskCapsule): string | undefined {
22
+ const revision = capsule.latestCommit ?? capsule.baseSha;
23
+ if (request.push) {
24
+ const pushed = deps.push(cwd, capsule.branch);
25
+ return pushed.ok ? undefined : pushed.error;
26
+ }
27
+ if ((deps.remoteHasCommit ?? remoteHasCommit)(cwd, revision)) return undefined;
28
+ return `The cloud runner fetches ${capsule.branch ?? "the branch"} from the repository's remote, which does not `
29
+ + `have ${revision.slice(0, 12)} yet. Turn on pushing the branch, or push it yourself.`;
30
+ }
31
+
32
+ function startedEvent(deps: MeshRuntimeDependencies, request: MeshHandoffPrepare, summary: string): MeshEvent {
33
+ const createdAt = deps.now();
34
+ return { type: "mesh.event", sessionId: request.taskId, eventId: deps.eventId(), projectId: request.projectId,
35
+ taskId: request.taskId, sourceSessionId: request.sessionId, eventType: "TASK_PROGRESS", createdAt,
36
+ expiresAt: createdAt + DAY, payload: { summary: summary.slice(0, 1_000) } };
37
+ }
38
+
39
+ export async function prepareCloudHandoff(input: {
40
+ deps: MeshRuntimeDependencies; block: Block; client: RelayClient;
41
+ request: MeshHandoffPrepare; session: SessionInfo; capsule: TaskCapsule;
42
+ }): Promise<boolean> {
43
+ const { deps, block, client, request, session, capsule } = input;
44
+ const provider = request.targetProvider;
45
+ const cwd = session.worktree ?? session.cwd;
46
+ if ((provider !== "claude" && provider !== "codex") || request.targetComputer !== deps.computer() || !cwd) {
47
+ await block(client, request, "A cloud runner starts from the computer that holds this Task. Choose the runner under that computer.");
48
+ return false;
49
+ }
50
+ const unpublished = publishedRevision(deps, request, cwd, capsule);
51
+ if (unpublished) {
52
+ await block(client, request, unpublished);
53
+ return false;
54
+ }
55
+ const launched = await (deps.launchCloud ?? startCloudRunner)({ provider, cwd, capsule, computer: deps.computer(),
56
+ title: session.title, model: request.targetModel, effort: request.cloud?.effort, environment: request.cloud?.environment });
57
+ if (!launched.ok) {
58
+ await block(client, request, launched.error);
59
+ return false;
60
+ }
61
+ const store = deps.store();
62
+ store.linkExecution({ taskId: request.taskId, sessionId: launched.reference, provider, computerId: deps.computer(),
63
+ workspace: cwd, repositoryId: capsule.repository, branch: capsule.branch, startedAt: deps.now(),
64
+ external: { runner: provider === "claude" ? "claude_cloud" : "codex_cloud", ...(launched.url ? { url: launched.url } : {}) } });
65
+ const name = provider === "claude" ? "Claude cloud session" : "Codex Cloud task";
66
+ const event = startedEvent(deps, request, `${name} started via ${deps.computer()}. GrantTap cannot follow its progress`
67
+ + (launched.url ? `: ${launched.url}` : `; its id is ${launched.reference}.`));
68
+ store.acceptEvent(event);
69
+ await deps.send(client, event, { ttlMs: DAY, wake: true });
70
+ const snapshot = composeMeshSnapshot(deps, request.projectId);
71
+ if (snapshot) await deps.send(client, snapshot, { ttlMs: 60 * 60_000 });
72
+ return true;
73
+ }
@@ -8,6 +8,7 @@ import type {
8
8
  TaskCapsule,
9
9
  } from "../../../../../packages/protocol/schema";
10
10
  import type { createCodexSession, deliverToSession } from "../../reply";
11
+ import type { CloudLaunchInput, CloudLaunchResult } from "../../cloud-runners";
11
12
  import type { MeshStore } from "../store";
12
13
  import type { createHandoffWorktree } from "./worktree";
13
14
 
@@ -48,4 +49,8 @@ export type MeshRuntimeDependencies = {
48
49
  push: (cwd: string, branch: string | undefined) => { ok: true; remote: string } | { ok: false; error: string };
49
50
  /** Bring a commit the capsule names to this checkout; whether it is here now. */
50
51
  fetch: (repository: string, revision: string, branch?: string) => boolean;
52
+ /** Whether the checkout's remote already has a commit a cloud runner must fetch. */
53
+ remoteHasCommit?: (cwd: string, revision: string) => boolean;
54
+ /** Start a provider cloud run with the installed CLI. */
55
+ launchCloud?: (input: CloudLaunchInput) => Promise<CloudLaunchResult>;
51
56
  };
@@ -25,6 +25,8 @@ export function closeVanished(
25
25
  let closed = 0;
26
26
  for (const execution of executions) {
27
27
  if (execution.computerId !== computerId || execution.endedAt != null) continue;
28
+ // A provider cloud run is never a local chat, so its absence here says nothing.
29
+ if (execution.external) continue;
28
30
  if (!scannedProviders.has(execution.provider)) continue;
29
31
  if (liveSessionIds.has(execution.sessionId)) continue;
30
32
  execution.endedAt = endedAt;
@@ -5,7 +5,15 @@ import { inspectRepository } from "../../catalog";
5
5
  import { computerId } from "../../identity/computer";
6
6
  import { matchesLocalCheckout } from "./checkout-identity";
7
7
 
8
- /** Re-admit this computer's verified checkouts before an explicit Graph build. */
8
+ /**
9
+ * Re-admit this computer's verified checkouts before an explicit Graph build.
10
+ *
11
+ * Only a repository whose Graph is genuinely unavailable is reported. A plain
12
+ * folder an agent ran in, a subfolder of a repository, a removed checkout and
13
+ * a former identity of a checkout another binding already describes are not
14
+ * repositories with a broken Graph; reporting them filled Mesh Health with
15
+ * errors nobody could act on.
16
+ */
9
17
  export async function refreshLocalGraphBindings(
10
18
  snapshot: MeshSnapshot,
11
19
  dependencies: {
@@ -18,19 +26,23 @@ export async function refreshLocalGraphBindings(
18
26
  const candidates = (snapshot.bindings ?? []).filter((binding) =>
19
27
  binding.endpointId === localEndpoint && binding.available && binding.localPathHint);
20
28
  const failures: ProjectRepositoryGraph[] = [];
21
- await Promise.all(candidates.map(async (binding) => {
29
+ const checkouts = candidates.flatMap((binding) => {
30
+ const facts = readCheckout(dependencies.inspect, binding.localPathHint!);
31
+ return facts ? [{ binding, facts, real: realpathSync(facts.root) }] : [];
32
+ });
33
+ await Promise.all(checkouts.map(async ({ binding, facts, real }) => {
34
+ const pathIdentity = matchesLocalCheckout(binding.repositoryId, facts.root);
35
+ if (facts.canonicalRepositoryId !== binding.repositoryId && !pathIdentity) {
36
+ const described = checkouts.some((other) => other.real === real
37
+ && (other.binding.repositoryId === facts.canonicalRepositoryId
38
+ || matchesLocalCheckout(other.binding.repositoryId, facts.root)));
39
+ if (!described) failures.push({ projectId: snapshot.projectId, repositoryId: binding.repositoryId,
40
+ revision: "unverified", weavatrixVersion: "unknown", analysisStatus: "UNAVAILABLE",
41
+ analysisErrorCode: "REPOSITORY_IDENTITY_MISMATCH",
42
+ nodes: [], relations: [], totalNodes: 0, totalRelations: 0, truncated: false });
43
+ return;
44
+ }
22
45
  try {
23
- const path = binding.localPathHint!;
24
- const facts = dependencies.inspect(path);
25
- const pathIdentity = matchesLocalCheckout(binding.repositoryId, facts.root);
26
- if (!facts.worktree || realpathSync(facts.root) !== realpathSync(path)
27
- || (facts.canonicalRepositoryId !== binding.repositoryId && !pathIdentity)) {
28
- failures.push({ projectId: snapshot.projectId, repositoryId: binding.repositoryId,
29
- revision: "unverified", weavatrixVersion: "unknown", analysisStatus: "UNAVAILABLE",
30
- analysisErrorCode: facts.worktree ? "REPOSITORY_IDENTITY_MISMATCH" : "REPOSITORY_IDENTITY_UNVERIFIED",
31
- nodes: [], relations: [], totalNodes: 0, totalRelations: 0, truncated: false });
32
- return;
33
- }
34
46
  const admitted = await dependencies.sync(snapshot.project, {
35
47
  summary: binding,
36
48
  localRoot: facts.root,
@@ -45,6 +57,17 @@ export async function refreshLocalGraphBindings(
45
57
  return failures;
46
58
  }
47
59
 
60
+ /** A Git checkout rooted exactly at the bound path, or nothing a Graph can be built from. */
61
+ function readCheckout(inspect: typeof inspectRepository, path: string) {
62
+ try {
63
+ const facts = inspect(path);
64
+ if (!facts.worktree || realpathSync(facts.root) !== realpathSync(path)) return undefined;
65
+ return facts;
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ }
70
+
48
71
  function admissionFailure(snapshot: MeshSnapshot, binding: NonNullable<MeshSnapshot["bindings"]>[number]): ProjectRepositoryGraph {
49
72
  return { projectId: snapshot.projectId, repositoryId: binding.repositoryId,
50
73
  revision: "unverified", weavatrixVersion: "unknown", analysisStatus: "UNAVAILABLE",
@@ -39,6 +39,7 @@ import { projectSessionCapabilityInventory } from "./capabilities";
39
39
  import { createMeshSessionCatalog } from "./catalog/session-catalog";
40
40
  import { capturePreparedHandoff, preparedHandoffMetadata } from './prepared-transfer';
41
41
  import { sourceCheckoutUnchanged } from './prepared-transfer/source-checkout';
42
+ import { prepareCloudHandoff } from "./cloud-handoff";
42
43
 
43
44
  export type { MeshRuntimeDependencies };
44
45
 
@@ -130,6 +131,7 @@ async function prepareHandoff(
130
131
  return false;
131
132
  }
132
133
  if (!capsule) return false;
134
+ if (request.cloud) return prepareCloudHandoff({ deps, block: blockHandoff, client, request, session, capsule });
133
135
  let transferContext: string | undefined;
134
136
  let confirmContext: (() => Promise<boolean>) | undefined;
135
137
  if (request.prepareOnly) {
@@ -15,6 +15,10 @@ beside `mesh-publication`. This module uses the repository Apache License 2.0.
15
15
  `message-delivery/index.ts` reserves the durable delivery lease, then
16
16
  emits a non-terminal processing receipt only after workspace, attachment and
17
17
  provider validation. Duplicate validated requests replay that receipt without
18
- starting another provider run. The eleven-minute lease covers the ten-minute
19
- provider timeout. New-task startup events are status updates; the correlated
20
- result is terminal. Delivery integration tests live in `tests/delivery`.
18
+ starting another provider run. A message is delivered once the agent has
19
+ taken it: `message-delivery/acceptance.ts` reports an immediate failure as that
20
+ failure and accepts a run still going after a short window, so the
21
+ eleven-minute lease only has to cover that window. The answer, or the reason a
22
+ long run stopped, reaches the chat when the run ends; unattended runs are
23
+ bounded at six hours. New-task startup events are status updates; the
24
+ correlated result is terminal. Delivery integration tests live in `tests/delivery`.
@@ -4,8 +4,6 @@ import type { RelayClient } from "../../../../../packages/core/relay-client";
4
4
  import { pruneAttachments, takeAttachment } from "../../delivery/attachment-store";
5
5
  import { sendSessionPayload } from "../../host/session-keys";
6
6
 
7
- export const DELIVERY_TIMEOUT_MS = 10 * 60_000;
8
-
9
7
  export async function sendDeliveryReceipt(
10
8
  client: RelayClient,
11
9
  messageId: string,
@@ -9,6 +9,7 @@ import {
9
9
  createCodexSession,
10
10
  createCursorSession,
11
11
  createGrokSession,
12
+ type ReplyResult,
12
13
  } from "../../reply";
13
14
  import { acceptInstanceEpoch } from "../../host/instance-epoch";
14
15
  import { evaluateCreateTask } from "../../mesh/tasks/create-task";
@@ -16,7 +17,7 @@ import { recordDelegation } from "../../mesh/handoff/delegation-loop";
16
17
  import { enqueuePinnedTask } from "../../mesh/tasks/queue";
17
18
  import { sendSessionPayload } from "../../host/session-keys";
18
19
  import { resolveMessageAttachments } from "./receipts";
19
- import { DELIVERY_TIMEOUT_MS } from "./receipts";
20
+ import { acceptWhenStarted, PHONE_RUN_LIMIT_MS } from "../message-delivery/acceptance";
20
21
 
21
22
  export type TaskCreateAdmission =
22
23
  | { status: "accepted" }
@@ -99,15 +100,16 @@ export async function handleTaskCreate(
99
100
  cursor: createCursorSession,
100
101
  grok: createGrokSession,
101
102
  }[agent];
102
- const result = await create(
103
- message.text, message.cwd, DELIVERY_TIMEOUT_MS, resolved.attachments, message.model,
103
+ const run = create(
104
+ message.text, message.cwd, PHONE_RUN_LIMIT_MS, resolved.attachments, message.model,
104
105
  message.operationId,
105
106
  );
106
- if (result.ok) {
107
- await say(result.text, result.sessionId, true);
108
- } else {
109
- await say(`Could not create a ${displayName} task: ${result.error}`, undefined, true);
110
- return { status: "rejected", error: "Provider delivery failed." };
111
- }
112
- return { status: "accepted" };
107
+ const settle = (result: ReplyResult, late: boolean) => result.ok
108
+ ? say(result.text, result.sessionId, true)
109
+ : say(late ? `The ${displayName} task stopped: ${result.error}`
110
+ : `Could not create a ${displayName} task: ${result.error}`, undefined, true);
111
+ const early = await acceptWhenStarted(run, (result) => settle(result, true));
112
+ if (early === "started") return { status: "accepted" };
113
+ await settle(early, false);
114
+ return early.ok ? { status: "accepted" } : { status: "rejected", error: "Provider delivery failed." };
113
115
  }
@@ -10,6 +10,7 @@ import {
10
10
  createCursorSession,
11
11
  createGrokSession,
12
12
  deliverToSession,
13
+ type ReplyResult,
13
14
  } from "../../reply";
14
15
  import { noteDeliveredRun } from "../../mesh/delivery/run-digest";
15
16
  import { evaluateCreateTask } from "../../mesh/tasks/create-task";
@@ -17,7 +18,7 @@ import { sendSessionPayload } from "../../host/session-keys";
17
18
  import { scanSessions } from "../../sessions";
18
19
  import { agentEventForUserMessage, sendDeliveryReceipt } from "./receipts";
19
20
  import { resolveMessageAttachments } from "./receipts";
20
- import { DELIVERY_TIMEOUT_MS } from "./receipts";
21
+ import { acceptWhenStarted, PHONE_RUN_LIMIT_MS } from "../message-delivery/acceptance";
21
22
 
22
23
  async function startNewAgentTask(input: {
23
24
  client: RelayClient;
@@ -66,16 +67,17 @@ async function startNewAgentTask(input: {
66
67
  cursor: createCursorSession,
67
68
  grok: createGrokSession,
68
69
  }[agent];
69
- const result = await create(
70
- message.text, requestedCwd, DELIVERY_TIMEOUT_MS, resolved.attachments, message.model, undefined, message.effort,
70
+ const run = create(
71
+ message.text, requestedCwd, PHONE_RUN_LIMIT_MS, resolved.attachments, message.model, undefined, message.effort,
71
72
  );
72
- if (result.ok) {
73
- await say(result.text, result.sessionId, true);
74
- return "accepted";
75
- } else {
76
- await say(`Could not create a ${displayName} task: ${result.error}`, undefined, true);
77
- return "rejected";
78
- }
73
+ const settle = (result: ReplyResult, late: boolean) => result.ok
74
+ ? say(result.text, result.sessionId, true)
75
+ : say(late ? `The ${displayName} task stopped: ${result.error}`
76
+ : `Could not create a ${displayName} task: ${result.error}`, undefined, true);
77
+ const early = await acceptWhenStarted(run, (result) => settle(result, true));
78
+ if (early === "started") return "accepted";
79
+ await settle(early, false);
80
+ return early.ok ? "accepted" : "rejected";
79
81
  }
80
82
 
81
83
  export async function handleUserMessage(
@@ -125,32 +127,34 @@ export async function handleUserMessage(
125
127
  // marks the person's bubble, and the next words in the chat are the answer.
126
128
  await onProcessing?.();
127
129
  const startedAt = Date.now();
128
- const result = await deliverToSession(target, message.text, DELIVERY_TIMEOUT_MS, attachments, {
130
+ const run = deliverToSession(target, message.text, PHONE_RUN_LIMIT_MS, attachments, {
129
131
  preferredMcp: message.preferredMcp,
130
132
  skill: message.skill,
131
133
  model: message.model,
132
134
  permissionMode: message.permissionMode,
133
135
  effort: message.effort,
134
136
  });
135
- // The run's turns are in the transcript, but a session holding this chat
136
- // open never sees them; the journal is how it finds out on its next prompt,
137
- // and the Task carries the same digest as progress.
138
- const noted = noteDeliveredRun({
139
- session: target,
140
- prompt: message.text,
141
- result,
142
- startedAt,
143
- endedAt: Date.now(),
144
- });
145
- if (noted?.event) {
146
- await sendSessionPayload(client, noted.event, noted.event.taskId, "phone", { ttlMs: 24 * 60 * 60_000 })
147
- .catch(() => {});
148
- }
149
- if (result.ok) {
150
- await say(result.text, result.sessionId ?? target.sessionId, true);
151
- return "accepted";
152
- } else {
153
- await say(`Could not deliver the message: ${result.error}`, target.sessionId, true);
154
- return "rejected";
155
- }
137
+ const settle = async (result: ReplyResult, late: boolean) => {
138
+ // The run's turns are in the transcript, but a session holding this chat
139
+ // open never sees them; the journal is how it finds out on its next prompt,
140
+ // and the Task carries the same digest as progress.
141
+ const noted = noteDeliveredRun({
142
+ session: target,
143
+ prompt: message.text,
144
+ result,
145
+ startedAt,
146
+ endedAt: Date.now(),
147
+ });
148
+ if (noted?.event) {
149
+ await sendSessionPayload(client, noted.event, noted.event.taskId, "phone", { ttlMs: 24 * 60 * 60_000 })
150
+ .catch(() => {});
151
+ }
152
+ if (result.ok) await say(result.text, result.sessionId ?? target.sessionId, true);
153
+ else await say(late ? `The run stopped: ${result.error}` : `Could not deliver the message: ${result.error}`,
154
+ target.sessionId, true);
155
+ };
156
+ const early = await acceptWhenStarted(run, (result) => settle(result, true));
157
+ if (early === "started") return "accepted";
158
+ await settle(early, false);
159
+ return early.ok ? "accepted" : "rejected";
156
160
  }
@@ -5,6 +5,9 @@ sequence for non-correlated phone messages. Validation happens before the first
5
5
  processing receipt, and duplicate validated messages never start another run.
6
6
  Terminal responses remain correlated to the original message id.
7
7
 
8
+ `acceptance.ts` decides when a phone message counts as delivered: when the agent
9
+ has taken it, not when its whole turn ends. Tests live in `tests/acceptance.test.ts`.
10
+
8
11
  Integration tests: `tests/delivery/processing-receipt.test.ts`; attachment rejection
9
12
  coverage: `tests/attachment/attachment-uploads.test.ts`. See the monitor README.
10
13
 
@@ -0,0 +1,33 @@
1
+ /**
2
+ * When a message from the phone counts as delivered.
3
+ *
4
+ * Delivery used to mean "the agent finished its whole turn within ten
5
+ * minutes": a longer turn was killed at the deadline and the person's bubble
6
+ * turned "Not delivered" although the agent had taken the message and was
7
+ * working on it. Delivered now means the agent took it. A run that fails at
8
+ * once (paused, transferred, a CLI that will not start) is still that
9
+ * failure; a run that is still going after a short window is delivered, and
10
+ * its answer — or the reason it stopped — reaches the chat when it ends.
11
+ */
12
+
13
+ /** Long enough for a provider CLI to refuse at once, short enough not to keep the phone waiting. */
14
+ export const ACCEPTANCE_WINDOW_MS = 15_000;
15
+
16
+ /** A safety bound for an unattended turn; the person stops a run with Stop, not with a deadline. */
17
+ export const PHONE_RUN_LIMIT_MS = 6 * 60 * 60_000;
18
+
19
+ export async function acceptWhenStarted<T>(
20
+ run: Promise<T>,
21
+ finish: (result: T) => Promise<unknown>,
22
+ windowMs = ACCEPTANCE_WINDOW_MS,
23
+ ): Promise<T | "started"> {
24
+ let timer: NodeJS.Timeout | undefined;
25
+ const started = new Promise<"started">((resolve) => {
26
+ timer = setTimeout(() => resolve("started"), windowMs);
27
+ });
28
+ const first = await Promise.race([run, started]);
29
+ clearTimeout(timer);
30
+ if (first !== "started") return first;
31
+ void run.then(finish).catch(() => {});
32
+ return "started";
33
+ }
@@ -6,6 +6,7 @@ import { loadRuntimeConfig, autoAcceptLevelFor } from "../../config";
6
6
  import { monitorLeadership } from "./leadership";
7
7
  import { mcpServersForSession, workspaceSkills } from "../../capabilities";
8
8
  import { inspectAgentIntegrations } from "../../install";
9
+ import { withCloudRunners } from "../../cloud-runners";
9
10
  import { currentConfigRevision } from "../../config/commands";
10
11
  import { currentInstanceEpoch } from "../../host/instance-epoch";
11
12
  import { localMeshStore } from "../../mesh/local-remote/local";
@@ -102,7 +103,7 @@ function monitorSnapshot(input: {
102
103
  contextCompilerEnabled: runtime.contextCompilerEnabled,
103
104
  configRevision: currentConfigRevision(),
104
105
  instanceEpoch: currentInstanceEpoch(),
105
- agents: inspectAgentIntegrations(),
106
+ agents: withCloudRunners(inspectAgentIntegrations()),
106
107
  generatedAt: Date.now(),
107
108
  };
108
109
  }
@@ -33,3 +33,21 @@ list of version numbers. Client model choices use the existing resume flags.
33
33
 
34
34
  Account percentages travel separately as optional `accountUsage` observations.
35
35
  See [provider account status](../provider-account-status/README.md).
36
+
37
+ ## Grok Build details and order
38
+
39
+ `grok models` decides which Grok models exist. Grok Build's own account catalog
40
+ (`models_cache.json` in its home, at most 24 hours old, 2 MB) only adds display
41
+ names, descriptions and the reasoning levels each listed model accepts
42
+ (`supportedEfforts`, e.g. `low…xhigh`); identity, endpoints, keys and headers
43
+ are never read into a row. When the listing could not run at all (timeout or a
44
+ crash) and Grok Build is installed and signed in, that fresh catalog stands in
45
+ for it with its own fetch time; a signed-out listing advertises nothing.
46
+ Cursor and Grok rows carry their CLI order as `priority`, so the phone shows
47
+ them in the provider's order. The Grok binary is resolved like its turns
48
+ (`GRANTTAP_GROK_BIN`, then `PATH`, then `~/.grok/bin/grok`), so a launchd PATH
49
+ without Grok's installer directory no longer hides it.
50
+
51
+ Claude Code has no listing command. Its picker keeps the CLI's documented aliases
52
+ (`opus`, `sonnet`, `haiku`, `fable`); `--model` also accepts full names, which
53
+ the phone does not invent. Tests: [`tests/grok-catalog.test.ts`](tests/grok-catalog.test.ts).
@@ -4,10 +4,14 @@ import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import type { AdvertisedModel } from "../../../../packages/protocol/schema";
6
6
  import { resolveCursorAgentBin } from "../reply/process/cursor-agent-bin";
7
+ import { grokHome, resolveGrokBinary } from "../providers/grok-bin";
8
+ import { enrichGrokModels, readGrokModelDetails } from "./grok-cache";
9
+
10
+ const SIGNED_OUT = /not authenticated|please log in|login required/i;
7
11
 
8
12
  export function parseCLIModels(output: string, provider: "cursor" | "grok", endpointId: string,
9
13
  now: number): AdvertisedModel[] {
10
- if (/not authenticated|please log in|login required/i.test(output)) return [];
14
+ if (SIGNED_OUT.test(output)) return [];
11
15
  const clean = output.replace(/\x1b\[[0-9;]*m/g, "");
12
16
  return clean.split("\n").flatMap(line => {
13
17
  const match = provider === "cursor"
@@ -15,7 +19,24 @@ export function parseCLIModels(output: string, provider: "cursor" | "grok", endp
15
19
  : /^\s*[*-]\s+([a-zA-Z0-9][a-zA-Z0-9._:/-]{0,159})(?:\s+\(([^)]+)\))?\s*$/.exec(line);
16
20
  return match ? [{ modelId: match[1]!, label: (provider === "cursor" ? match[2]! : match[1]!).slice(0, 160),
17
21
  provider, endpointId, observedAt: now, source: "advertised" as const }] : [];
18
- }).slice(0, 240);
22
+ }).slice(0, 240).map((row, priority) => ({ ...row, priority }));
23
+ }
24
+
25
+ /**
26
+ * Both native listings as picker rows. Grok's own account catalog names and
27
+ * describes its listed models; when the listing could not run at all, that
28
+ * fresh catalog stands in for it. A signed-out listing advertises nothing.
29
+ */
30
+ export function cliModelRows(cursorOutput: string, grokOutput: string, grokRoot: string,
31
+ endpointId: string, now: number): AdvertisedModel[] {
32
+ const cursor = parseCLIModels(cursorOutput, "cursor", endpointId, now);
33
+ let grok = parseCLIModels(grokOutput, "grok", endpointId, now);
34
+ if (!grokOutput.trim()) {
35
+ const cached = readGrokModelDetails(grokRoot, now);
36
+ grok = cached ? [...cached.models.keys()].map(modelId => ({ modelId, label: modelId, provider: "grok" as const,
37
+ endpointId, observedAt: cached.fetchedAt, source: "advertised" as const })) : [];
38
+ }
39
+ return [...cursor, ...enrichGrokModels(grok, grokRoot, now)];
19
40
  }
20
41
 
21
42
  let report: AdvertisedModel[] = [];
@@ -38,15 +59,16 @@ export function cachedCLIModels(endpointId: string, env = process.env): Advertis
38
59
  checkedAt = Date.now();
39
60
  pending = true;
40
61
  const cursor = resolveCursorAgentBin(env);
41
- const grokRoot = env.GRANTTAP_GROK_DIR ?? join(homedir(), ".grok");
42
- const grok = env.GRANTTAP_GROK_BIN ?? join(grokRoot, "bin/grok");
62
+ const grokRoot = grokHome(env);
63
+ const grok = resolveGrokBinary(env);
64
+ // Only an installed, signed-in Grok Build may stand in its cached catalog.
65
+ const usable = existsSync(join(grokRoot, "auth.json")) && existsSync(grok);
43
66
  void Promise.all([
44
67
  existsSync(cursor) ? read(cursor, ["models"]) : "",
45
- existsSync(join(grokRoot, "auth.json")) && existsSync(grok) ? read(grok, ["models"]) : "",
68
+ usable ? read(grok, ["models"]) : "unavailable",
46
69
  ]).then(([a, b]) => {
47
- if (scope === currentScope) report = [...parseCLIModels(a, "cursor", endpointId, Date.now()),
48
- ...parseCLIModels(b, "grok", endpointId, Date.now())];
70
+ if (scope === currentScope) report = cliModelRows(a, b, grokRoot, endpointId, Date.now());
49
71
  }).finally(() => { pending = false; });
50
72
  }
51
- return report.filter(row => Date.now() - row.observedAt <= 5 * 60_000);
73
+ return report.filter(row => Date.now() - row.observedAt <= (row.provider === "grok" ? 24 * 60 : 5) * 60_000);
52
74
  }
@@ -0,0 +1,70 @@
1
+ import { closeSync, fstatSync, openSync, readSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { AdvertisedModel } from "../../../../packages/protocol/schema";
4
+
5
+ const MAX_BYTES = 2_000_000;
6
+ const MAX_AGE = 24 * 60 * 60_000;
7
+ const MODEL_ID = /^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,159}$/;
8
+ const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
9
+ type Effort = typeof EFFORTS[number];
10
+
11
+ export type GrokModelDetail = Pick<AdvertisedModel, "label" | "description" | "supportedEfforts">;
12
+
13
+ function text(value: unknown, max: number): string | undefined {
14
+ if (typeof value !== "string") return undefined;
15
+ return value.replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, max) || undefined;
16
+ }
17
+
18
+ function readBounded(path: string): unknown {
19
+ const fd = openSync(path, "r");
20
+ try {
21
+ const size = fstatSync(fd).size;
22
+ if (size > MAX_BYTES) return undefined;
23
+ const bytes = Buffer.alloc(size);
24
+ return JSON.parse(bytes.subarray(0, readSync(fd, bytes, 0, size, 0)).toString("utf8"));
25
+ } finally { closeSync(fd); }
26
+ }
27
+
28
+ function efforts(info: Record<string, unknown>): Effort[] | undefined {
29
+ if (info.supports_reasoning_effort !== true || !Array.isArray(info.reasoning_efforts)) return undefined;
30
+ const values = info.reasoning_efforts.map((row) => (row as { value?: unknown } | null)?.value);
31
+ const known = EFFORTS.filter((effort) => values.includes(effort));
32
+ return known.length ? known : undefined;
33
+ }
34
+
35
+ /**
36
+ * Picker metadata from the catalog Grok Build itself fetched for this account:
37
+ * display names, descriptions and the reasoning levels each model accepts.
38
+ * Identity, endpoints, keys and headers never leave the file.
39
+ */
40
+ export function readGrokModelDetails(
41
+ root: string, now = Date.now(),
42
+ ): { fetchedAt: number; models: Map<string, GrokModelDetail> } | undefined {
43
+ try {
44
+ const cache = readBounded(join(root, "models_cache.json")) as Record<string, unknown> | undefined;
45
+ if (!cache || typeof cache !== "object" || Array.isArray(cache)) return undefined;
46
+ const fetchedAt = typeof cache.fetched_at === "string" ? Date.parse(cache.fetched_at) : NaN;
47
+ if (!Number.isFinite(fetchedAt) || now - fetchedAt > MAX_AGE || fetchedAt - now > 5 * 60_000) return undefined;
48
+ if (!cache.models || typeof cache.models !== "object" || Array.isArray(cache.models)) return undefined;
49
+ const models = new Map<string, GrokModelDetail>();
50
+ for (const [key, value] of Object.entries(cache.models as Record<string, unknown>).slice(0, 64)) {
51
+ const info = (value as { info?: unknown } | null)?.info as Record<string, unknown> | undefined;
52
+ if (!info || typeof info !== "object" || info.hidden === true || !MODEL_ID.test(key)) continue;
53
+ const supportedEfforts = efforts(info);
54
+ models.set(key, { label: text(info.name, 160), description: text(info.description, 240),
55
+ ...(supportedEfforts ? { supportedEfforts } : {}) });
56
+ }
57
+ return { fetchedAt, models };
58
+ } catch { return undefined; }
59
+ }
60
+
61
+ /** The CLI list stays authoritative; the cache only names and describes its rows. */
62
+ export function enrichGrokModels(rows: AdvertisedModel[], root: string, now = Date.now()): AdvertisedModel[] {
63
+ const details = readGrokModelDetails(root, now);
64
+ return rows.map((row, priority) => {
65
+ const detail = details?.models.get(row.modelId);
66
+ return { ...row, priority, ...(detail?.label ? { label: detail.label } : {}),
67
+ ...(detail?.description ? { description: detail.description } : {}),
68
+ ...(detail?.supportedEfforts ? { supportedEfforts: detail.supportedEfforts } : {}) };
69
+ });
70
+ }
@@ -0,0 +1,31 @@
1
+ /** Resolve the Grok Build CLI the same way for its catalog, its turns and its status. */
2
+ import { accessSync, constants } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { executableOnPath } from "./claude-bin";
6
+
7
+ function executable(path: string): boolean {
8
+ try {
9
+ accessSync(path, constants.X_OK);
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ /** Grok's own home: `GRANTTAP_GROK_DIR` for tests and relocated installs, else ~/.grok. */
17
+ export function grokHome(env: NodeJS.ProcessEnv = process.env, home = homedir()): string {
18
+ return env.GRANTTAP_GROK_DIR ?? join(home, ".grok");
19
+ }
20
+
21
+ /**
22
+ * launchd does not inherit an interactive shell's PATH, and Grok's installer
23
+ * keeps its binary under ~/.grok/bin. An explicit override still wins.
24
+ */
25
+ export function resolveGrokBinary(env: NodeJS.ProcessEnv = process.env, home = homedir()): string {
26
+ if (env.GRANTTAP_GROK_BIN) return env.GRANTTAP_GROK_BIN;
27
+ const onPath = executableOnPath("grok", env);
28
+ if (onPath) return onPath;
29
+ const installed = join(grokHome(env, home), "bin", "grok");
30
+ return executable(installed) ? installed : "grok";
31
+ }
@@ -18,3 +18,10 @@ in the repository-root `LICENSE` file.
18
18
  Model reasoning effort reaches resumed and new provider turns as native arguments.
19
19
  Codex uses `model_reasoning_effort`; Claude uses `--effort`; Grok uses
20
20
  `--reasoning-effort`. Cursor effort variants are native model IDs.
21
+
22
+ Accepted levels: Codex `low…ultra`, Claude `low…max`, Grok `low…xhigh` (Grok
23
+ Build lists `xhigh` for its current models and has no `max`). Anything else is
24
+ dropped rather than passed. A prepared transfer's first turn uses the model and
25
+ effort that message carries, else the capsule's `targetModel`. Grok Build is
26
+ resolved with `providers/grok-bin.ts`. Tests:
27
+ [`tests/model-effort-args.test.ts`](tests/model-effort-args.test.ts).
@@ -1,9 +1,10 @@
1
1
  /** Provider-native controls, never interpolated into a prompt or shell. */
2
2
  export function providerEffortArgs(provider: string, effort?: string): string[] {
3
3
  if (!effort) return [];
4
+ // Grok Build lists low…xhigh per model in its own catalog; it has no max.
4
5
  const supported = provider === "codex" ? ["low", "medium", "high", "xhigh", "max", "ultra"]
5
6
  : provider === "claude" ? ["low", "medium", "high", "xhigh", "max"]
6
- : provider === "grok" ? ["low", "medium", "high"] : [];
7
+ : provider === "grok" ? ["low", "medium", "high", "xhigh"] : [];
7
8
  if (!supported.includes(effort)) return [];
8
9
  return provider === "codex" ? ["-c", `model_reasoning_effort="${effort}"`]
9
10
  : [provider === "grok" ? "--reasoning-effort" : "--effort", effort];
@@ -5,13 +5,14 @@ import { providerEffortArgs } from "./provider-effort";
5
5
  import type { ReplyResult } from "../payload/types";
6
6
  import type { DeliveryOptions } from "../payload/types";
7
7
  import { resolveCursorAgentBin } from "./cursor-agent-bin";
8
+ import { resolveGrokBinary } from "../../providers/grok-bin";
8
9
 
9
10
  function cursorBin(): string {
10
11
  return resolveCursorAgentBin();
11
12
  }
12
13
 
13
14
  function grokBin(): string {
14
- return process.env.GRANTTAP_GROK_BIN ?? "grok";
15
+ return resolveGrokBinary();
15
16
  }
16
17
 
17
18
  function parseCursor(stdout: string, fallback?: string): ReplyResult {
@@ -39,6 +39,10 @@ export async function desktopTaskHandoff(
39
39
  targetProvider: row.target_provider, targetComputer: row.target_computer,
40
40
  targetModel: row.target_model, userComment: row.user_comment,
41
41
  checkpoint: row.checkpoint, push: row.push, createdAt: Date.now(),
42
+ ...(row.cloud === true ? { cloud: {
43
+ ...(row.cloud_environment === undefined ? {} : { environment: row.cloud_environment }),
44
+ ...(row.cloud_effort === undefined ? {} : { effort: row.cloud_effort }),
45
+ } } : {}),
42
46
  });
43
47
  if (!request.success) return undefined;
44
48
  const loaded = readStoreState(storePath);
@@ -10,7 +10,7 @@ keeps each phone response tied to the exact originating chat and prompt.
10
10
  Install the supported CLI on this computer, then run:
11
11
 
12
12
  ```bash
13
- npm install -g granttap-mcp@0.8.70
13
+ npm install -g granttap-mcp@0.8.72
14
14
  granttap setup
15
15
  granttap status
16
16
  ```
@@ -4,7 +4,7 @@
4
4
  "command": "node",
5
5
  "args": [
6
6
  "-e",
7
- "\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.70\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 20+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
7
+ "\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.72\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 20+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
8
8
  ]
9
9
  }
10
10
  }
@@ -7,7 +7,7 @@ const { spawn } = require("node:child_process");
7
7
  const { existsSync } = require("node:fs");
8
8
  const { delimiter, dirname, join } = require("node:path");
9
9
 
10
- const PACKAGE = "granttap-mcp@0.8.70";
10
+ const PACKAGE = "granttap-mcp@0.8.72";
11
11
  const dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === "1";
12
12
  const platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM
13
13
  ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.8.70",
3
+ "version": "0.8.72",
4
4
  "description": "Apache-2.0 computer runtime connecting local coding agents to GrantTap on Mac, iPhone, iPad and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,7 +6,7 @@ import {
6
6
  MeshExecutionProvider,
7
7
  Path,
8
8
  } from "./endpoint";
9
- import { ResourceClaim } from "./tasks";
9
+ import { CloudLaunchRequest, ResourceClaim } from "./tasks";
10
10
 
11
11
  export const TaskCapsule = z.object({
12
12
  taskId: Identifier,
@@ -229,9 +229,18 @@ export const MeshHandoffPrepare = z.object({
229
229
  * Never a force push; the person asks for it per handoff.
230
230
  */
231
231
  push: z.boolean().optional(),
232
+ /**
233
+ * Start the Task in the provider's cloud runner instead of a local chat.
234
+ * The target computer launches it with its installed CLI, so it must be the
235
+ * computer that holds the Task. A Codex Cloud environment may be named.
236
+ */
237
+ cloud: CloudLaunchRequest.optional(),
232
238
  }).strict().superRefine((value, ctx) => {
233
239
  if ((value.targetProvider === "grok_bot") !== (value.targetActorId != null)) {
234
240
  ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["targetActorId"], message: "actor required" });
235
241
  }
242
+ if (value.cloud && value.targetProvider !== "claude" && value.targetProvider !== "codex") {
243
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["cloud"], message: "cloud runner provider" });
244
+ }
236
245
  });
237
246
  export type MeshHandoffPrepare = z.infer<typeof MeshHandoffPrepare>;
@@ -58,6 +58,19 @@ export const MeshTask = z.object({
58
58
  }).strict();
59
59
  export type MeshTask = z.infer<typeof MeshTask>;
60
60
 
61
+ /** A provider cloud run a computer started; GrantTap cannot observe its progress. */
62
+ export const ExternalExecution = z.object({
63
+ runner: z.enum(["claude_cloud", "codex_cloud"]),
64
+ url: z.string().trim().max(1_024).regex(/^https:\/\/\S+$/).optional(),
65
+ }).strict();
66
+ export type ExternalExecution = z.infer<typeof ExternalExecution>;
67
+
68
+ export const CloudLaunchRequest = z.object({
69
+ environment: z.string().trim().min(1).max(160).regex(/^[^\x00-\x1f\x7f]+$/).optional(),
70
+ effort: z.enum(["low", "medium", "high", "xhigh", "max"]).optional(),
71
+ }).strict();
72
+ export type CloudLaunchRequest = z.infer<typeof CloudLaunchRequest>;
73
+
61
74
  export const ExecutionSessionLink = z.object({
62
75
  continuedAt: z.number().nonnegative().optional(),
63
76
  state: z.enum(["working", "waiting", "idle"]).optional(),
@@ -84,6 +97,8 @@ export const ExecutionSessionLink = z.object({
84
97
  activeAt: z.number().nonnegative().optional(),
85
98
  startedAt: z.number().nonnegative(),
86
99
  endedAt: z.number().nonnegative().optional(),
100
+ // Set only for a provider cloud run: where to look at it, never progress.
101
+ external: ExternalExecution.optional(),
87
102
  }).strict().superRefine((value, ctx) => {
88
103
  if ((value.provider === "grok_bot") !== (value.actorId != null)) {
89
104
  ctx.addIssue({
@@ -48,6 +48,8 @@ export const AgentIntegrationStatus = z.object({
48
48
  updateCommand: z.string().trim().min(1).max(200).optional(),
49
49
  newerOnThisMac: z.string().trim().min(1).max(64).optional(),
50
50
  updating: z.boolean().optional(),
51
+ // The installed CLI can start the provider's cloud runner without a terminal.
52
+ cloud: z.boolean().optional(),
51
53
  });
52
54
  export type AgentIntegrationStatus = z.infer<typeof AgentIntegrationStatus>;
53
55