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.
- package/apps/bridge/src/cloud-runners/README.md +48 -0
- package/apps/bridge/src/cloud-runners/capability.ts +53 -0
- package/apps/bridge/src/cloud-runners/claude.ts +39 -0
- package/apps/bridge/src/cloud-runners/codex.ts +50 -0
- package/apps/bridge/src/cloud-runners/index.ts +28 -0
- package/apps/bridge/src/cloud-runners/launch.ts +87 -0
- package/apps/bridge/src/install/inspect.ts +2 -1
- package/apps/bridge/src/mesh/README.md +6 -0
- package/apps/bridge/src/mesh/local-remote/remote.ts +13 -0
- package/apps/bridge/src/mesh/runtime/cloud-handoff/index.ts +73 -0
- package/apps/bridge/src/mesh/runtime/dependencies.ts +5 -0
- package/apps/bridge/src/mesh/runtime/execution-sweep.ts +2 -0
- package/apps/bridge/src/mesh/runtime/graph/binding-sync.ts +36 -13
- package/apps/bridge/src/mesh/runtime/index.ts +2 -0
- package/apps/bridge/src/monitor/README.md +7 -3
- package/apps/bridge/src/monitor/handlers/receipts.ts +0 -2
- package/apps/bridge/src/monitor/handlers/task-create.ts +12 -10
- package/apps/bridge/src/monitor/handlers/user-message.ts +36 -32
- package/apps/bridge/src/monitor/message-delivery/README.md +3 -0
- package/apps/bridge/src/monitor/message-delivery/acceptance.ts +33 -0
- package/apps/bridge/src/monitor/support/runtime.ts +2 -1
- package/apps/bridge/src/provider-model-catalog/README.md +18 -0
- package/apps/bridge/src/provider-model-catalog/cli-models.ts +30 -8
- package/apps/bridge/src/provider-model-catalog/grok-cache.ts +70 -0
- package/apps/bridge/src/providers/grok-bin.ts +31 -0
- package/apps/bridge/src/reply/README.md +7 -0
- package/apps/bridge/src/reply/process/provider-effort.ts +2 -1
- package/apps/bridge/src/reply/process/provider-headless.ts +2 -1
- package/apps/mcp/src/desktop/delivery/task-handoff.ts +4 -0
- package/cursor-plugin/README.md +1 -1
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/stdio-bootstrap.cjs +1 -1
- package/package.json +1 -1
- package/packages/protocol/messages/mesh/events.ts +10 -1
- package/packages/protocol/messages/mesh/tasks.ts +15 -0
- 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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
19
|
-
|
|
20
|
-
|
|
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 {
|
|
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
|
|
103
|
-
message.text, message.cwd,
|
|
103
|
+
const run = create(
|
|
104
|
+
message.text, message.cwd, PHONE_RUN_LIMIT_MS, resolved.attachments, message.model,
|
|
104
105
|
message.operationId,
|
|
105
106
|
);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
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 {
|
|
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
|
|
70
|
-
message.text, requestedCwd,
|
|
70
|
+
const run = create(
|
|
71
|
+
message.text, requestedCwd, PHONE_RUN_LIMIT_MS, resolved.attachments, message.model, undefined, message.effort,
|
|
71
72
|
);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
await say(result.text, result.sessionId ?? target.sessionId, true);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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 (
|
|
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
|
|
42
|
-
const grok = env
|
|
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
|
-
|
|
68
|
+
usable ? read(grok, ["models"]) : "unavailable",
|
|
46
69
|
]).then(([a, b]) => {
|
|
47
|
-
if (scope === currentScope) report =
|
|
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
|
|
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);
|
package/cursor-plugin/README.md
CHANGED
|
@@ -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.
|
|
13
|
+
npm install -g granttap-mcp@0.8.72
|
|
14
14
|
granttap setup
|
|
15
15
|
granttap status
|
|
16
16
|
```
|
package/cursor-plugin/mcp.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
@@ -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
|
|