eve-hub 0.14.3 → 0.14.5
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/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/bash-run.mjs +1 -1
- package/fork-env.mjs +10 -7
- package/hooks/sandbox.d.ts +1 -1
- package/hooks/sandbox.js +4 -15
- package/host-browser.js +2 -2
- package/host-sandbox.d.ts +22 -39
- package/host-sandbox.js +77 -81
- package/hub-upload.d.ts +20 -0
- package/hub-upload.js +42 -0
- package/kit-modules.json +0 -1
- package/package.json +1 -1
- package/stubs.mjs +1 -2
- package/templates/writing.md +5 -4
- package/tools/bash.js +1 -1
- package/tools/post_message.d.ts +9 -0
- package/tools/post_message.js +59 -11
- package/tools/show.js +8 -25
- package/tools/view_image.js +4 -3
- package/tools/attach_image.d.ts +0 -16
- package/tools/attach_image.js +0 -51
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# eve-hub
|
|
2
2
|
|
|
3
|
+
## 0.14.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Mac bots' bash and read_file work again on eve 0.83.3: HostSandbox speaks eve's connection-based sandbox API (eve derives the session; `/workspace` and `~` still map to the bot's root and the user's home). The sandbox hook no longer deletes the host sandbox each turn; eve itself starts fresh when a session's recorded sandbox came from another provider.
|
|
8
|
+
|
|
9
|
+
## 0.14.4
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- `post_message` carries a post's images (`images`: sandbox paths, or `screenshot` on a local hub) and uploads the documents its markdown links point at (a sandbox path becomes `hub://upload/<id>` on a hosted hub, `file://` on a local one). Its text is capped at 700 characters, link targets excluded, and `![]()` is refused. `show` refuses images. `attach_image` is removed. Docs are written in markdown, which the app renders in Vercel's report design; HTML only for a bespoke layout.
|
|
14
|
+
|
|
15
|
+
Upgrade boundary: the hub no longer collects images uploaded by `attach_image`. A bot still on eve-hub 0.14.3 or older keeps a working `attach_image` (the upload succeeds), but its image never reaches a post. Redeploy every bot on 0.14.4 together with the hub that carries this release.
|
|
16
|
+
|
|
3
17
|
## 0.14.3
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ The bot-side code every hub agent shares (milestone M3 in [`docs/architecture`](
|
|
|
4
4
|
|
|
5
5
|
| Module | What it is | Bots use it as |
|
|
6
6
|
|---|---|---|
|
|
7
|
-
| `tools/update_thread.ts`, `list_threads.ts`, `read_thread.ts`, `react.ts`, `show.ts`, `
|
|
7
|
+
| `tools/update_thread.ts`, `list_threads.ts`, `read_thread.ts`, `react.ts`, `show.ts`, `browser.ts` | Registry and workspace tools (`defineTool`) | `agent/tools/<same name>.ts` |
|
|
8
8
|
| `tools/create_thread.ts`, `post_message.ts`, `archive_thread.ts`, `unarchive_thread.ts`, `list_agents.ts`, `create_agent.ts`, `remove_agent.ts`, `restart_agent.ts`, `tag_threads.ts`, `apply_tags.ts` | The orchestrator's tools | `main`'s `agent/tools/<same name>.ts` |
|
|
9
9
|
| `hooks/sandbox.ts` | Migrates legacy Docker sessions to the host sandbox (`defineHook`) | `agent/hooks/sandbox.ts` |
|
|
10
10
|
| `instructions/main.ts`, `main-instructions.mjs` | main's instructions (`defineInstructions`), rendered at build time from the kit's `templates/main-instructions.md` (identity, role) between the shared templates, each in its own tag, so no workspace carries a copy | `main`'s `agent/instructions.ts`; workspace-only additions in `agent/role/*.md` (inside `<role>`) |
|
package/bash-run.mjs
CHANGED
|
@@ -241,7 +241,7 @@ export function selfStopRefusal(command, env = process.env) {
|
|
|
241
241
|
export async function runBash(sandbox, { command, timeoutSeconds }, { hostRoot, code = workspaceCode(), graceMs = KILL_GRACE_MS, env = process.env } = {}) {
|
|
242
242
|
const selfStop = selfStopRefusal(command, env);
|
|
243
243
|
if (selfStop) return { exitCode: 1, stdout: "", stderr: selfStop, truncated: false };
|
|
244
|
-
const linked = linkedInstallTargets(command, sandbox.root ?? hostRoot);
|
|
244
|
+
const linked = linkedInstallTargets(command, sandbox.root?.() ?? hostRoot);
|
|
245
245
|
if (linked.length) {
|
|
246
246
|
const lines = linked.map(({ dir, target }) => `${join(dir, "node_modules")} -> ${target}`);
|
|
247
247
|
return {
|
package/fork-env.mjs
CHANGED
|
@@ -9,19 +9,22 @@ import { registryFetch, sandboxRegistry } from "./registry-fetch.mjs";
|
|
|
9
9
|
|
|
10
10
|
const TTL_MS = 3000;
|
|
11
11
|
|
|
12
|
+
// A fork's config, or null once the registry says the session isn't a fork
|
|
13
|
+
// (404). Any other answer throws and isn't cached: eve fixes a session's root
|
|
14
|
+
// when it opens the connection, so a guess of "no fork" would pin a fork to the
|
|
15
|
+
// shared checkout.
|
|
12
16
|
export function createForkEnv({ registry = sandboxRegistry(), fetchImpl = globalThis.fetch, now = Date.now } = {}) {
|
|
13
17
|
const cache = new Map(); // sessionId -> { config, at }
|
|
14
18
|
return async function lookup(sessionId) {
|
|
15
19
|
if (registry === null) return null; // no local hub: no forks
|
|
16
20
|
const hit = cache.get(sessionId);
|
|
17
21
|
if (hit && now() - hit.at < TTL_MS) return hit.config;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return config?.root ? config : null;
|
|
22
|
+
const res = await registryFetch(`/api/sandbox/session-config/${encodeURIComponent(sessionId)}`, {}, { base: registry, fetchImpl });
|
|
23
|
+
if (res.status !== 404 && !res.ok) throw new Error(`fork config for ${sessionId}: HTTP ${res.status}`);
|
|
24
|
+
const body = res.ok ? await res.json() : null;
|
|
25
|
+
const config = body?.root ? body : null;
|
|
26
|
+
cache.set(sessionId, { config, at: now() });
|
|
27
|
+
return config;
|
|
25
28
|
};
|
|
26
29
|
}
|
|
27
30
|
|
package/hooks/sandbox.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: import("eve/hooks").HookDefinition<
|
|
1
|
+
declare const _default: import("eve/hooks").HookDefinition<never>;
|
|
2
2
|
export default _default;
|
package/hooks/sandbox.js
CHANGED
|
@@ -1,17 +1,6 @@
|
|
|
1
1
|
import { defineHook } from "eve/hooks";
|
|
2
|
-
//
|
|
2
|
+
// Once retired Docker sandboxes recorded by sessions created before the host
|
|
3
|
+
// sandbox. eve now does that itself: a session whose recorded sandbox came
|
|
4
|
+
// from another provider starts a fresh one. Kept so existing bot stubs load.
|
|
3
5
|
// Turn reporting is owned by the registry's eve stream consumer, not hooks.
|
|
4
|
-
export default defineHook({
|
|
5
|
-
events: {
|
|
6
|
-
async "turn.started"(_event, ctx) {
|
|
7
|
-
try {
|
|
8
|
-
const sandbox = await ctx.getSandbox();
|
|
9
|
-
if (sandbox.resolvePath("/workspace") === "/workspace")
|
|
10
|
-
await sandbox.delete();
|
|
11
|
-
}
|
|
12
|
-
catch (error) {
|
|
13
|
-
console.warn("[roster] sandbox migration skipped", error);
|
|
14
|
-
}
|
|
15
|
-
},
|
|
16
|
-
},
|
|
17
|
-
});
|
|
6
|
+
export default defineHook({ events: {} });
|
package/host-browser.js
CHANGED
|
@@ -132,8 +132,8 @@ class SandboxBrowser {
|
|
|
132
132
|
onClose;
|
|
133
133
|
// One action at a time per browser. Tool calls in one model step run in
|
|
134
134
|
// parallel, and Chromium drops a Page.captureScreenshot that's in flight
|
|
135
|
-
// when a navigation commits: it never answers. So `open` +
|
|
136
|
-
//
|
|
135
|
+
// when a navigation commits: it never answers. So `open` + a
|
|
136
|
+
// screenshot side by side hung the turn. Serialized, the screenshot
|
|
137
137
|
// waits for the page to load and then captures it.
|
|
138
138
|
chain = Promise.resolve();
|
|
139
139
|
exclusive(fn) {
|
package/host-sandbox.d.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import { type ChildProcess } from "node:child_process";
|
|
2
2
|
import type { SandboxSession } from "eve/sandbox";
|
|
3
|
+
import { type SandboxConnection, type SandboxProvider, type WithPrepareOption } from "eve/sandbox/provider";
|
|
3
4
|
interface HostOptions {
|
|
4
5
|
readonly root?: string;
|
|
5
6
|
}
|
|
6
7
|
type HostArtifact = {
|
|
7
8
|
readonly root: string;
|
|
8
9
|
};
|
|
9
|
-
|
|
10
|
+
type HostState = {
|
|
10
11
|
readonly root: string;
|
|
11
12
|
readonly version: 1;
|
|
12
13
|
readonly worktree?: SessionWorktree;
|
|
13
14
|
readonly rootSessionId?: string;
|
|
14
|
-
}
|
|
15
|
+
};
|
|
15
16
|
interface SessionRef {
|
|
16
17
|
readonly id: string;
|
|
17
18
|
readonly parent?: {
|
|
@@ -19,11 +20,20 @@ interface SessionRef {
|
|
|
19
20
|
readonly sessionId?: string;
|
|
20
21
|
};
|
|
21
22
|
}
|
|
22
|
-
|
|
23
|
+
type SessionWorktree = {
|
|
23
24
|
readonly path: string;
|
|
24
25
|
readonly branch: string;
|
|
25
26
|
readonly repo: string;
|
|
27
|
+
};
|
|
28
|
+
export interface HostExtensions {
|
|
29
|
+
root(): string;
|
|
30
|
+
description(): string;
|
|
31
|
+
readonly worktree?: SessionWorktree;
|
|
26
32
|
}
|
|
33
|
+
export type HostConnection = SandboxConnection<HostExtensions> & {
|
|
34
|
+
readonly workspaceRoot: string;
|
|
35
|
+
ready(): Promise<void>;
|
|
36
|
+
};
|
|
27
37
|
export declare const HOST_ROOT: string;
|
|
28
38
|
export declare function hostEnvironment(options?: HostOptions): {
|
|
29
39
|
prepare(): Promise<{
|
|
@@ -32,42 +42,19 @@ export declare function hostEnvironment(options?: HostOptions): {
|
|
|
32
42
|
start(ctx: {
|
|
33
43
|
session: SessionRef;
|
|
34
44
|
}, _open: unknown, artifact: HostArtifact): Promise<{
|
|
35
|
-
|
|
36
|
-
sandbox: SandboxSession & {
|
|
37
|
-
description: string;
|
|
38
|
-
root: string;
|
|
39
|
-
worktree?: SessionWorktree;
|
|
40
|
-
};
|
|
41
|
-
onRuntimeShutdown: () => Promise<void>;
|
|
42
|
-
onSandboxStop: () => Promise<void>;
|
|
43
|
-
onSandboxDelete: () => Promise<void>;
|
|
44
|
-
};
|
|
45
|
+
connection: HostConnection;
|
|
45
46
|
state: HostState;
|
|
46
47
|
}>;
|
|
47
48
|
resume(ctx: {
|
|
48
49
|
session: SessionRef;
|
|
49
|
-
}, _artifact: unknown, state: HostState): Promise<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
worktree?: SessionWorktree;
|
|
54
|
-
};
|
|
55
|
-
onRuntimeShutdown: () => Promise<void>;
|
|
56
|
-
onSandboxStop: () => Promise<void>;
|
|
57
|
-
onSandboxDelete: () => Promise<void>;
|
|
58
|
-
}>;
|
|
59
|
-
};
|
|
60
|
-
export declare const HostSandbox: import("eve/sandbox/provider").SandboxProvider<HostOptions, undefined, SandboxSession>;
|
|
61
|
-
export declare function hostHandle(rootOrState: string | HostState, sessionId: string): {
|
|
62
|
-
sandbox: SandboxSession & {
|
|
63
|
-
description: string;
|
|
64
|
-
root: string;
|
|
65
|
-
worktree?: SessionWorktree;
|
|
66
|
-
};
|
|
67
|
-
onRuntimeShutdown: () => Promise<void>;
|
|
68
|
-
onSandboxStop: () => Promise<void>;
|
|
69
|
-
onSandboxDelete: () => Promise<void>;
|
|
50
|
+
}, _artifact: unknown, state: HostState): Promise<HostConnection>;
|
|
51
|
+
onSessionEnd(ctx: {
|
|
52
|
+
session: SessionRef;
|
|
53
|
+
}): Promise<void>;
|
|
70
54
|
};
|
|
55
|
+
type HostSession = SandboxSession & HostExtensions;
|
|
56
|
+
export declare const HostSandbox: SandboxProvider<WithPrepareOption<HostOptions, HostSession>, undefined, HostSession>;
|
|
57
|
+
export declare function hostConnection(rootOrState: string | HostState, sessionId: string): Promise<HostConnection>;
|
|
71
58
|
export declare function killTree(child: ChildProcess): void;
|
|
72
59
|
declare const forkConfig: (sessionId: string) => Promise<{
|
|
73
60
|
root: string;
|
|
@@ -116,9 +103,5 @@ export declare function hostSession(baseRoot: string, children: Set<ChildProcess
|
|
|
116
103
|
rootSessionId?: string;
|
|
117
104
|
lookup?: typeof forkConfig;
|
|
118
105
|
guard?: Fence | ((what: string) => Promise<void>);
|
|
119
|
-
}):
|
|
120
|
-
description: string;
|
|
121
|
-
root: string;
|
|
122
|
-
worktree?: SessionWorktree;
|
|
123
|
-
};
|
|
106
|
+
}): HostConnection;
|
|
124
107
|
export {};
|
package/host-sandbox.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// A sandbox provider that is the host machine itself: commands run in the
|
|
2
2
|
// user's real shell and files are the user's real files. `/workspace` maps
|
|
3
|
-
// to `root` (default ~/wrk); other absolute paths
|
|
3
|
+
// to `root` (default ~/wrk), `~` to the user's home; other absolute paths
|
|
4
|
+
// pass through.
|
|
4
5
|
//
|
|
5
6
|
// There is no isolation. Every bot using this can do anything the user can.
|
|
6
7
|
import { execFile, spawn as spawnChild } from "node:child_process";
|
|
7
8
|
import { createHash, randomUUID } from "node:crypto";
|
|
8
|
-
import {
|
|
9
|
-
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
9
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
10
10
|
import { request as httpRequest } from "node:http";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
|
-
import { dirname,
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
13
|
import { Readable } from "node:stream";
|
|
14
14
|
import { promisify } from "node:util";
|
|
15
15
|
import { defineSandboxProvider } from "eve/sandbox/provider";
|
|
@@ -31,7 +31,7 @@ export function hostEnvironment(options) {
|
|
|
31
31
|
const rootSessionId = ctx.session.parent?.rootSessionId;
|
|
32
32
|
const worktree = await sessionWorktree(ctx.session.id, rootSessionId);
|
|
33
33
|
const state = { ...(worktree ? { root: worktree.path, worktree } : { root: artifact.root }), version: 1, ...(rootSessionId ? { rootSessionId } : {}) };
|
|
34
|
-
return {
|
|
34
|
+
return { connection: await hostConnection(state, ctx.session.id), state };
|
|
35
35
|
},
|
|
36
36
|
async resume(ctx, _artifact, state) {
|
|
37
37
|
// Asked again: the registry restores a worktree whose directory went
|
|
@@ -41,10 +41,16 @@ export function hostEnvironment(options) {
|
|
|
41
41
|
const next = { ...(worktree ? { ...state, root: worktree.path, worktree } : state), ...(rootSessionId ? { rootSessionId } : {}) };
|
|
42
42
|
if (!worktree)
|
|
43
43
|
await mkdir(next.root, { recursive: true }); // never fake a missing worktree with an empty dir
|
|
44
|
-
return
|
|
44
|
+
return hostConnection(next, ctx.session.id);
|
|
45
|
+
},
|
|
46
|
+
// The session is over (or its sandbox deleted): stop what it still runs.
|
|
47
|
+
// The files are the user's own and stay.
|
|
48
|
+
async onSessionEnd(ctx) {
|
|
49
|
+
await stopSession(ctx.session.id);
|
|
45
50
|
},
|
|
46
51
|
};
|
|
47
52
|
}
|
|
53
|
+
// Annotated: the inferred type names eve's internal session module, which the published .d.ts can't reference (TS2883).
|
|
48
54
|
export const HostSandbox = defineSandboxProvider({
|
|
49
55
|
name: "host",
|
|
50
56
|
environment: hostEnvironment,
|
|
@@ -109,23 +115,35 @@ async function sessionWorktree(sessionId, rootSessionId) {
|
|
|
109
115
|
await new Promise((r) => setTimeout(r, 300));
|
|
110
116
|
}
|
|
111
117
|
}
|
|
112
|
-
// A
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
118
|
+
// A session's processes, shared by every connection eve opens to it (eve
|
|
119
|
+
// reconnects for each step), so stop() and session end reach all of them.
|
|
120
|
+
const sessionChildren = new Map();
|
|
121
|
+
const childrenOf = (sessionId) => {
|
|
122
|
+
let children = sessionChildren.get(sessionId);
|
|
123
|
+
if (!children)
|
|
124
|
+
sessionChildren.set(sessionId, (children = new Set()));
|
|
125
|
+
return children;
|
|
126
|
+
};
|
|
127
|
+
async function stopSession(sessionId, children = sessionChildren.get(sessionId)) {
|
|
128
|
+
if (children) {
|
|
117
129
|
for (const child of children)
|
|
118
130
|
killTree(child);
|
|
119
131
|
children.clear();
|
|
120
132
|
liveSessions.delete(children);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
133
|
+
}
|
|
134
|
+
if (sessionChildren.get(sessionId) === children)
|
|
135
|
+
sessionChildren.delete(sessionId);
|
|
136
|
+
await closeBrowser(sessionId);
|
|
137
|
+
}
|
|
138
|
+
// A root path (the shared checkout; tests call it this way) or a session's
|
|
139
|
+
// state. Resolves once a fork's root is known, since eve reads workspaceRoot
|
|
140
|
+
// once, when it opens the connection: a failed fork lookup rejects here rather
|
|
141
|
+
// than anchor the connection at the shared checkout.
|
|
142
|
+
export async function hostConnection(rootOrState, sessionId) {
|
|
143
|
+
const { root, worktree, rootSessionId } = typeof rootOrState === "string" ? { root: rootOrState, worktree: undefined, rootSessionId: undefined } : rootOrState;
|
|
144
|
+
const connection = hostSession(root, childrenOf(sessionId), sessionId, { worktree, rootSessionId });
|
|
145
|
+
await connection.ready();
|
|
146
|
+
return connection;
|
|
129
147
|
}
|
|
130
148
|
// SIGTERM the whole process group; whatever ignores it gets SIGKILL after
|
|
131
149
|
// KILL_AFTER_MS, so a timed-out or stopped command can't linger.
|
|
@@ -598,6 +616,9 @@ const killGroup = (pid) => {
|
|
|
598
616
|
// `guard`: a Fence (ownershipGuard) that admits every op with a ticket, or a
|
|
599
617
|
// plain `(what) => Promise<void>` that is asked before each op instead, with
|
|
600
618
|
// no tickets at all (tests stand in an unguarded sandbox this way).
|
|
619
|
+
// Returns eve's byte-level connection: eve derives the session (resolvePath,
|
|
620
|
+
// run, the text and binary helpers) from it, anchoring relative paths at
|
|
621
|
+
// workspaceRoot, so every path that arrives here is absolute.
|
|
601
622
|
export function hostSession(baseRoot, children, sessionId, { worktree, rootSessionId, lookup = forkConfig, guard = ownershipGuard(sessionId, { rootSessionId }), } = {}) {
|
|
602
623
|
let root = baseRoot;
|
|
603
624
|
let forkEnv = {};
|
|
@@ -626,19 +647,19 @@ export function hostSession(baseRoot, children, sessionId, { worktree, rootSessi
|
|
|
626
647
|
if (forkEnv[dir])
|
|
627
648
|
await mkdir(forkEnv[dir], { recursive: true });
|
|
628
649
|
};
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
|
|
632
|
-
const resolvePath = (path) => {
|
|
650
|
+
// eve hands over `/workspace/...` as is, and a model's `~/x` anchored at the
|
|
651
|
+
// root (`<root>/~/x`).
|
|
652
|
+
const hostPath = (path) => {
|
|
633
653
|
if (path === "/workspace" || path.startsWith("/workspace/"))
|
|
634
654
|
return join(root, path.slice("/workspace".length));
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
655
|
+
const home = join(root, "~");
|
|
656
|
+
if (path === home || path.startsWith(`${home}/`))
|
|
657
|
+
return join(homedir(), path.slice(home.length));
|
|
658
|
+
return path;
|
|
638
659
|
};
|
|
639
660
|
const spawn = async ({ command, workingDirectory, env, abortSignal }) => {
|
|
640
661
|
await ready();
|
|
641
|
-
const cwd =
|
|
662
|
+
const cwd = hostPath(workingDirectory);
|
|
642
663
|
// Paused first: nothing of the user's runs until the ticket is granted.
|
|
643
664
|
const { child, go } = spawnPaused(process.env.SHELL ?? "/bin/bash", command, cwd, { ...process.env, ...env, ...forkEnv });
|
|
644
665
|
await new Promise((ok, fail) => {
|
|
@@ -705,17 +726,6 @@ export function hostSession(baseRoot, children, sessionId, { worktree, rootSessi
|
|
|
705
726
|
kill: async () => killTree(child),
|
|
706
727
|
};
|
|
707
728
|
};
|
|
708
|
-
const readBinary = async (path) => {
|
|
709
|
-
await ready();
|
|
710
|
-
try {
|
|
711
|
-
return new Uint8Array(await readFile(resolvePath(path)));
|
|
712
|
-
}
|
|
713
|
-
catch (error) {
|
|
714
|
-
if (error.code === "ENOENT")
|
|
715
|
-
return null;
|
|
716
|
-
throw error;
|
|
717
|
-
}
|
|
718
|
-
};
|
|
719
729
|
// The effect runs under the ticket and the ticket is released with how it went.
|
|
720
730
|
const fenced = async (ticket, effect) => {
|
|
721
731
|
try {
|
|
@@ -730,7 +740,7 @@ export function hostSession(baseRoot, children, sessionId, { worktree, rootSessi
|
|
|
730
740
|
};
|
|
731
741
|
const write = async (path, bytes) => {
|
|
732
742
|
await ready();
|
|
733
|
-
const target =
|
|
743
|
+
const target = hostPath(path);
|
|
734
744
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
735
745
|
const ticket = await admit("write", { path: target, sha256 }, `write ${path}`);
|
|
736
746
|
await fenced(ticket, async () => {
|
|
@@ -739,63 +749,49 @@ export function hostSession(baseRoot, children, sessionId, { worktree, rootSessi
|
|
|
739
749
|
});
|
|
740
750
|
};
|
|
741
751
|
return {
|
|
742
|
-
|
|
743
|
-
|
|
752
|
+
ready,
|
|
753
|
+
// Read once, when eve opens the connection; start/resume wait for ready().
|
|
754
|
+
get workspaceRoot() {
|
|
744
755
|
return root;
|
|
745
756
|
},
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
757
|
+
extensions: {
|
|
758
|
+
// Read by the bash tool (bash-run.mjs): where this session's commands run.
|
|
759
|
+
root: () => root,
|
|
760
|
+
...(worktree ? { worktree } : {}),
|
|
761
|
+
description: () => {
|
|
762
|
+
const shell = `The host machine's own shell (${process.platform}), not an isolated sandbox.`;
|
|
763
|
+
const tail = "You have the user's full environment, credentials, and filesystem, so be deliberate with anything destructive.";
|
|
764
|
+
if (forkEnv.ROSTER_FORK)
|
|
765
|
+
return `${shell} The working directory is ${root}; /workspace maps to it. This session is fork ${forkEnv.ROSTER_FORK}: commands default to its worktree with PORT=${forkEnv.PORT} and fork-local build/cache dirs. ${tail}`;
|
|
766
|
+
if (worktree)
|
|
767
|
+
return `${shell} The working directory is ${root}: a git worktree of ${worktree.repo} on branch ${worktree.branch}, private to this thread; /workspace maps to it. Do this thread's work in ${worktree.repo} here, not in ${worktree.repo} itself (other threads use that checkout). Other paths are the user's real files. ${tail}`;
|
|
768
|
+
return `${shell} The working directory is ${root}; /workspace maps to it. ${tail}`;
|
|
769
|
+
},
|
|
755
770
|
},
|
|
756
|
-
resolvePath,
|
|
757
771
|
spawn,
|
|
758
|
-
async run(options) {
|
|
759
|
-
const proc = await spawn(options);
|
|
760
|
-
const [stdout, stderr, { exitCode }] = await Promise.all([
|
|
761
|
-
new Response(proc.stdout).text(),
|
|
762
|
-
new Response(proc.stderr).text(),
|
|
763
|
-
proc.wait(),
|
|
764
|
-
]);
|
|
765
|
-
return { exitCode, stdout, stderr };
|
|
766
|
-
},
|
|
767
772
|
async readFile({ path }) {
|
|
768
773
|
await ready();
|
|
769
|
-
const target = resolvePath(path);
|
|
770
774
|
try {
|
|
771
|
-
await
|
|
775
|
+
return new Uint8Array(await readFile(hostPath(path)));
|
|
772
776
|
}
|
|
773
|
-
catch {
|
|
774
|
-
|
|
777
|
+
catch (error) {
|
|
778
|
+
if (error.code === "ENOENT")
|
|
779
|
+
return null;
|
|
780
|
+
throw error;
|
|
775
781
|
}
|
|
776
|
-
return Readable.toWeb(createReadStream(target));
|
|
777
|
-
},
|
|
778
|
-
readBinaryFile: ({ path }) => readBinary(path),
|
|
779
|
-
async readTextFile({ path, encoding = "utf-8", startLine, endLine }) {
|
|
780
|
-
const bytes = await readBinary(path);
|
|
781
|
-
if (bytes === null)
|
|
782
|
-
return null;
|
|
783
|
-
const text = Buffer.from(bytes).toString(encoding);
|
|
784
|
-
if (startLine === undefined && endLine === undefined)
|
|
785
|
-
return text;
|
|
786
|
-
const lines = text.split("\n");
|
|
787
|
-
return lines.slice((startLine ?? 1) - 1, endLine ?? lines.length).join("\n");
|
|
788
782
|
},
|
|
789
|
-
async
|
|
790
|
-
|
|
783
|
+
async writeFiles({ files }) {
|
|
784
|
+
for (const file of files)
|
|
785
|
+
await write(file.path, file.content);
|
|
791
786
|
},
|
|
792
|
-
writeBinaryFile: ({ path, content }) => write(path, content),
|
|
793
|
-
writeTextFile: ({ path, content, encoding = "utf-8" }) => write(path, new Uint8Array(Buffer.from(content, encoding))),
|
|
794
787
|
async removePath({ path, force, recursive }) {
|
|
795
788
|
await ready();
|
|
796
|
-
const target =
|
|
789
|
+
const target = hostPath(path);
|
|
797
790
|
const ticket = await admit("delete", { path: target, ...(recursive === undefined ? {} : { recursive }), ...(force === undefined ? {} : { force }) }, `remove ${path}`);
|
|
798
791
|
await fenced(ticket, () => rm(target, { force, recursive }));
|
|
799
792
|
},
|
|
793
|
+
// Releases compute: the session's processes and its browser. Also what
|
|
794
|
+
// eve calls on every open connection when the runtime shuts down.
|
|
795
|
+
stop: () => stopSession(sessionId, children),
|
|
800
796
|
};
|
|
801
797
|
}
|
package/hub-upload.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type SandboxFiles = {
|
|
2
|
+
readBinaryFile(input: {
|
|
3
|
+
path: string;
|
|
4
|
+
}): PromiseLike<Uint8Array | null | undefined>;
|
|
5
|
+
resolvePath(path: string): string;
|
|
6
|
+
};
|
|
7
|
+
export type Uploaded = {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
size: number;
|
|
11
|
+
};
|
|
12
|
+
export declare const hostedHub: () => boolean;
|
|
13
|
+
export declare const IMAGE_FILE: RegExp;
|
|
14
|
+
/** A markdown link target that names a sandbox file: an absolute path, or one under the home directory. */
|
|
15
|
+
export declare const isSandboxPath: (target: string) => boolean;
|
|
16
|
+
export declare function readSandboxFile(sandbox: SandboxFiles, path: string): Promise<Uint8Array>;
|
|
17
|
+
export declare function uploadBytes(bytes: Uint8Array, name: string, what: string): Promise<Uploaded>;
|
|
18
|
+
export declare const uploadFile: (sandbox: SandboxFiles, path: string, what: string) => Promise<Uploaded>;
|
|
19
|
+
/** Where the human's app opens a sandbox document from: hub://upload/<id> on a hosted hub, file:// on a local one. */
|
|
20
|
+
export declare function documentUrl(sandbox: SandboxFiles, path: string, what: string): Promise<string>;
|
package/hub-upload.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// A bot's sandbox files on the hub: the upload path `show` and `post_message` share. An image
|
|
2
|
+
// is uploaded on both hubs (it rides a message, store/attachments). A document goes as
|
|
3
|
+
// file:// on a local hub (the bot's files are the human's) and, on a hosted one (a deployed
|
|
4
|
+
// bot, or HUB_TOKEN set), as a snapshot uploaded to the hub (hub://upload/<sha>.<ext>),
|
|
5
|
+
// since the human's Mac can't read the sandbox.
|
|
6
|
+
import { basename } from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { registryFetch } from "./registry-fetch.mjs";
|
|
9
|
+
export const hostedHub = () => Boolean(process.env.VERCEL || process.env.HUB_TOKEN);
|
|
10
|
+
export const IMAGE_FILE = /\.(png|jpe?g|gif|webp|heic)$/i;
|
|
11
|
+
/** A markdown link target that names a sandbox file: an absolute path, or one under the home directory. */
|
|
12
|
+
export const isSandboxPath = (target) => /^(\/|\$HOME\/|~\/)/.test(target);
|
|
13
|
+
// The sandbox resolves `~/`; `$HOME/` is the same place spelled as the shell does.
|
|
14
|
+
const sandboxPath = (path) => path.replace(/^\$HOME\//, "~/");
|
|
15
|
+
export async function readSandboxFile(sandbox, path) {
|
|
16
|
+
const bytes = await sandbox.readBinaryFile({ path: sandboxPath(path) });
|
|
17
|
+
if (!bytes)
|
|
18
|
+
throw new Error(`no file at ${path}`);
|
|
19
|
+
return bytes;
|
|
20
|
+
}
|
|
21
|
+
export async function uploadBytes(bytes, name, what) {
|
|
22
|
+
const res = await registryFetch(`/api/uploads`, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "content-type": "application/octet-stream", "x-filename": encodeURIComponent(name) },
|
|
25
|
+
body: new Uint8Array(bytes),
|
|
26
|
+
signal: AbortSignal.timeout(30_000),
|
|
27
|
+
}).catch((error) => {
|
|
28
|
+
throw new Error(`${what} failed: the upload didn't finish (${error.message})`);
|
|
29
|
+
});
|
|
30
|
+
const body = (await res.json().catch(() => ({})));
|
|
31
|
+
if (!res.ok || typeof body.id !== "string")
|
|
32
|
+
throw new Error(`${what} failed: the upload was refused (${body.error ?? res.status})`);
|
|
33
|
+
return { id: body.id, name: body.name ?? name, size: body.size ?? bytes.length };
|
|
34
|
+
}
|
|
35
|
+
export const uploadFile = async (sandbox, path, what) => uploadBytes(await readSandboxFile(sandbox, path), basename(path), what);
|
|
36
|
+
/** Where the human's app opens a sandbox document from: hub://upload/<id> on a hosted hub, file:// on a local one. */
|
|
37
|
+
export async function documentUrl(sandbox, path, what) {
|
|
38
|
+
if (hostedHub())
|
|
39
|
+
return `hub://upload/${(await uploadFile(sandbox, path, what)).id}`;
|
|
40
|
+
await readSandboxFile(sandbox, path);
|
|
41
|
+
return pathToFileURL(sandbox.resolvePath(sandboxPath(path))).href;
|
|
42
|
+
}
|
package/kit-modules.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eve-hub",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.5",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Bot-side code shared by every hub agent: registry tools, the roster hook, the eve channel, the host sandbox and browser, and model helpers.",
|
|
6
6
|
"type": "module",
|
package/stubs.mjs
CHANGED
|
@@ -10,7 +10,6 @@ export const KIT_FILES = {
|
|
|
10
10
|
"tools/react.ts": ["React to a message with an emoji."],
|
|
11
11
|
"tools/show.ts": ["Move the human's screen to a thread, DM, or message, or open a page or file beside the thread."],
|
|
12
12
|
"tools/browser.ts": ["Its own headless browser, watched live in the Computer view."],
|
|
13
|
-
"tools/attach_image.ts": ["Puts an image (a sandbox file or a browser screenshot) on this turn's reply."],
|
|
14
13
|
"tools/view_image.ts": ["Shows the bot an image (a sandbox file or a browser screenshot) so it can see it."],
|
|
15
14
|
"tools/update_thread.ts": ["Update a thread this bot is in: title, track, domain and summary."],
|
|
16
15
|
"tools/list_threads.ts": ["Every workspace thread, from any session."],
|
|
@@ -50,7 +49,7 @@ export const MAIN_KIT_FILES = {
|
|
|
50
49
|
// hub's machine: the sandbox hook (it migrates host sessions, and deletes any
|
|
51
50
|
// sandbox that resolves /workspace as itself, which a Vercel Sandbox does),
|
|
52
51
|
// the kit's bash (HostSandbox's root, the daemon's env; eve's own bash runs
|
|
53
|
-
// instead), the browser and the screenshot
|
|
52
|
+
// instead), the browser and the screenshot path of view_image,
|
|
54
53
|
// restart_hub (the hub's own code), memory (the workspace's files), and main's prune schedule (it cleans the hub machine's disk).
|
|
55
54
|
// main's dash schedule stays: off the hub's machine it has no PR data (its lookup runs `gh`).
|
|
56
55
|
export const DEPLOYED_KIT_FILES = new Set([
|
package/templates/writing.md
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
- How: a `flow`, `sequence`, `chart` or `ask` form sent with `post_message`; load the `forms` skill for the shapes, tables and syntax.
|
|
8
8
|
- Never draw with characters: no arrows, boxes, numbered timelines or tables in a code fence.
|
|
9
9
|
- **Disclose one step at a time**
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
10
|
+
- A post is at most 2 sentences (the point or the question; the inbox shows only its first line), then at most 3 bullets or one small table: what the reader needs to act, and nothing more. `post_message` refuses more than 700 characters.
|
|
11
|
+
- Depth (evidence, analysis, options, a long table) goes in a doc: write a markdown file in your sandbox and link it in the post, `[the analysis](/workspace/notes/analysis.md)`; `post_message` uploads it and the link opens it beside the thread, already styled. Never paste it. Write HTML only for a layout markdown can't hold, following the `vercel-design` skill when you have it.
|
|
12
|
+
- Images go in `post_message`'s `images` (sandbox paths), never in the text.
|
|
13
|
+
- A file reaches the human only as a markdown link in a post or through `show`, which upload it. A raw sandbox path in text is a file they can't open.
|
|
14
|
+
- Once you have posted into the thread, your turn's closing text is not posted: say nothing after a post.
|
|
14
15
|
- Why: the first line is often all that's read; depth one click away costs nothing until it's wanted.
|
|
15
16
|
- **Say only what's new**
|
|
16
17
|
- Post only what changes what the reader does next; react when nothing is new.
|
package/tools/bash.js
CHANGED
|
@@ -27,6 +27,6 @@ export default defineTool({
|
|
|
27
27
|
async execute(input, ctx) {
|
|
28
28
|
// HostSandbox exposes the session's real root (its thread worktree, if any).
|
|
29
29
|
const sandbox = await ctx.getSandbox();
|
|
30
|
-
return runBash(sandbox, input, { hostRoot: sandbox.root ?? HOST_ROOT, code: workspaceCode() });
|
|
30
|
+
return runBash(sandbox, input, { hostRoot: sandbox.root?.() ?? HOST_ROOT, code: workspaceCode() });
|
|
31
31
|
},
|
|
32
32
|
});
|
package/tools/post_message.d.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
+
export declare const MAX_CHARS = 700;
|
|
2
|
+
/** The characters the cap counts: the text without its links' targets. */
|
|
3
|
+
export declare const postLength: (text: string) => number;
|
|
4
|
+
/** The error for a text the thread shouldn't carry, or null. */
|
|
5
|
+
export declare function textError(text: string): string | null;
|
|
6
|
+
/** The text with each sandbox-path link target replaced by where the human's app opens it. */
|
|
7
|
+
export declare function rewriteDocLinks(text: string, open: (path: string) => Promise<string>): Promise<string>;
|
|
1
8
|
declare const _default: import("eve/tools").ToolDefinition<{
|
|
2
9
|
threadId?: string | undefined;
|
|
3
10
|
agent?: string | undefined;
|
|
4
11
|
text: string;
|
|
12
|
+
images?: string[] | undefined;
|
|
5
13
|
policy?: "abort" | "queue" | "steer" | undefined;
|
|
6
14
|
status?: "done" | "planning" | "reviewing" | "working" | undefined;
|
|
7
15
|
metadata?: {
|
|
@@ -27,6 +35,7 @@ declare const _default: import("eve/tools").ToolDefinition<{
|
|
|
27
35
|
threadId?: string | undefined;
|
|
28
36
|
agent?: string | undefined;
|
|
29
37
|
text: string;
|
|
38
|
+
images?: string[] | undefined;
|
|
30
39
|
policy?: "abort" | "queue" | "steer" | undefined;
|
|
31
40
|
status?: "done" | "planning" | "reviewing" | "working" | undefined;
|
|
32
41
|
metadata?: {
|
package/tools/post_message.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { defineTool } from "eve/tools";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { documentUrl, hostedHub, isSandboxPath, uploadBytes, uploadFile } from "../hub-upload.js";
|
|
3
4
|
import { registryFetch } from "../registry-fetch.mjs";
|
|
4
5
|
// Posts as you into a thread or DM, from whichever session you're in. With no
|
|
5
|
-
// target it posts into the thread you're in, as part of the running turn:
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
6
|
+
// target it posts into the thread you're in, as part of the running turn: once
|
|
7
|
+
// a turn has posted there, its end-of-turn text isn't posted. Into another
|
|
8
|
+
// thread, mentions wake bots exactly as your replies do; posting into a bot's
|
|
9
|
+
// DM wakes that bot; archived threads refuse it. Your session in the target
|
|
10
|
+
// thread, if any, isn't woken; it sees the message as yours the next time it's
|
|
11
|
+
// addressed.
|
|
12
|
+
//
|
|
13
|
+
// The text is capped (MAX_CHARS, link targets excluded): depth goes in a
|
|
14
|
+
// document the post links by its sandbox path, which is uploaded and the link
|
|
15
|
+
// rewritten to where the hub serves it (hub-upload.ts). Images go in `images`
|
|
16
|
+
// and ride the post.
|
|
12
17
|
//
|
|
13
18
|
// `policy` is how a post reaches the one bot it @-mentions: queue (default)
|
|
14
19
|
// waits for that bot's turn to end, steer folds into its running turn, abort
|
|
@@ -17,12 +22,48 @@ import { registryFetch } from "../registry-fetch.mjs";
|
|
|
17
22
|
// A status post: `status` is the thread's phase after it (the hub checks the
|
|
18
23
|
// move), the text is its summary, `metadata` its PR and sha. The hub keeps the
|
|
19
24
|
// latest one per bot and thread for the workspace state and the Dash.
|
|
25
|
+
export const MAX_CHARS = 700;
|
|
26
|
+
const LINK = /\[([^\]]*)\]\(([^)\s]+)\)/g;
|
|
27
|
+
const IMAGE_SYNTAX = /!\[[^\]]*\]\([^)]*\)/;
|
|
28
|
+
const SCREENSHOT = "screenshot";
|
|
29
|
+
/** The characters the cap counts: the text without its links' targets. */
|
|
30
|
+
export const postLength = (text) => text.replace(LINK, (_, label) => `[${label}]()`).length;
|
|
31
|
+
/** The error for a text the thread shouldn't carry, or null. */
|
|
32
|
+
export function textError(text) {
|
|
33
|
+
if (IMAGE_SYNTAX.test(text))
|
|
34
|
+
return "Images don't go in the text: pass the file's sandbox path in `images` and it shows inline";
|
|
35
|
+
if (postLength(text) > MAX_CHARS)
|
|
36
|
+
return "Too long: move the detail into a doc and link it";
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/** The text with each sandbox-path link target replaced by where the human's app opens it. */
|
|
40
|
+
export async function rewriteDocLinks(text, open) {
|
|
41
|
+
const links = [...text.matchAll(LINK)].filter((m) => isSandboxPath(m[2]));
|
|
42
|
+
const urls = await Promise.all(links.map((m) => open(m[2])));
|
|
43
|
+
let out = "", at = 0;
|
|
44
|
+
links.forEach((m, i) => {
|
|
45
|
+
const start = m.index + m[0].length - m[2].length - 1;
|
|
46
|
+
out += text.slice(at, start) + urls[i];
|
|
47
|
+
at = start + m[2].length;
|
|
48
|
+
});
|
|
49
|
+
return out + text.slice(at);
|
|
50
|
+
}
|
|
51
|
+
async function screenshot(sessionId) {
|
|
52
|
+
if (hostedHub())
|
|
53
|
+
throw new Error("a browser screenshot needs the hub's machine: on a hosted hub save the image to a sandbox file and pass its path in `images`");
|
|
54
|
+
const { browserFor } = await import("../host-browser.js");
|
|
55
|
+
const browser = await browserFor(sessionId);
|
|
56
|
+
if (browser.url === "about:blank")
|
|
57
|
+
throw new Error("your browser has no page open; open one with the browser tool first");
|
|
58
|
+
return browser.screenshot();
|
|
59
|
+
}
|
|
20
60
|
export default defineTool({
|
|
21
|
-
description: "Post a message as you. Leave out threadId and agent to post into the thread you're in, while you work: posting doesn't end your turn, and once your turn has posted into this thread, its end-of-turn text isn't posted, so put your result in a post. Or post into another workspace thread or a bot's DM. @-mentions wake bots the same way your replies do (at most one, never yourself); `@all` wakes every participant; each bot's gate decides whether it replies. Posting into a bot's DM wakes that bot. Archived threads are refused. `policy` says how a post reaches the one bot it @-mentions: queue (default) runs as its next turn, or starts one now if it is idle; steer lands in its RUNNING turn at the next step boundary and the bot folds it in without a restart, to correct course mid-turn; abort stops its turn in that thread (running commands are killed, the process keeps running) and the text starts its fresh turn, \"stop, do this instead\". Steer and abort need exactly one @-mention, not `@all`, and cut into work already under way: use them only to change what a running turn is doing. A status post sets the thread's phase: planning, working, reviewing or done; allowed moves are start → planning or working, planning → working, working → reviewing, reviewing → working (fix first), planning (rethink) or done. Any other move is refused with the allowed next phases. The text is a phase marker, not a report: at most 8 plain words on what is happening now (\"Building the input-count fix\"), with no bot or role names, no restating the phase, no parentheticals. Test results, branch names and caveats go in a separate message. The PR URL and sha go in metadata, never in the text.",
|
|
61
|
+
description: "Post a message as you. Leave out threadId and agent to post into the thread you're in, while you work: posting doesn't end your turn, and once your turn has posted into this thread, its end-of-turn text isn't posted, so put your result in a post. Or post into another workspace thread or a bot's DM. The text is at most 700 characters (link targets don't count): the point in 2 sentences, then at most 3 bullets or one small table. Depth goes in a document: write it to a file and link it in the text by its sandbox path, `[the plan](/workspace/plan.md)`; the file is uploaded and the link opens it beside the thread. Images go in `images` (sandbox paths; `screenshot` for your browser's page on a local hub) and show inline; never write `![]()`. @-mentions wake bots the same way your replies do (at most one, never yourself); `@all` wakes every participant; each bot's gate decides whether it replies. Posting into a bot's DM wakes that bot. Archived threads are refused. `policy` says how a post reaches the one bot it @-mentions: queue (default) runs as its next turn, or starts one now if it is idle; steer lands in its RUNNING turn at the next step boundary and the bot folds it in without a restart, to correct course mid-turn; abort stops its turn in that thread (running commands are killed, the process keeps running) and the text starts its fresh turn, \"stop, do this instead\". Steer and abort need exactly one @-mention, not `@all`, and cut into work already under way: use them only to change what a running turn is doing. A status post sets the thread's phase: planning, working, reviewing or done; allowed moves are start → planning or working, planning → working, working → reviewing, reviewing → working (fix first), planning (rethink) or done. Any other move is refused with the allowed next phases. The text is a phase marker, not a report: at most 8 plain words on what is happening now (\"Building the input-count fix\"), with no bot or role names, no restating the phase, no parentheticals. Test results, branch names and caveats go in a separate message. The PR URL and sha go in metadata, never in the text.",
|
|
22
62
|
inputSchema: z.object({
|
|
23
63
|
threadId: z.string().optional().describe("Another thread's id, e.g. thr_0002, or a DM id, dm-<bot>. Omit it to post into the thread you're in."),
|
|
24
64
|
agent: z.string().optional().describe("Post into this bot's DM instead of a thread."),
|
|
25
|
-
text: z.string().describe("The message. @-mention the one bot who should act. On a status post: at most 8 plain words on what is happening now."),
|
|
65
|
+
text: z.string().describe("The message, at most 700 characters (link targets don't count). A markdown link to a sandbox path uploads that document. @-mention the one bot who should act. On a status post: at most 8 plain words on what is happening now."),
|
|
66
|
+
images: z.array(z.string()).optional().describe("Images to show inline on this post: sandbox paths (PNG, JPEG, GIF or WebP, up to 3 MB each), or `screenshot` for your browser's current page (local hub only)."),
|
|
26
67
|
policy: z.enum(["queue", "steer", "abort"]).optional().describe("How the post reaches the one bot it @-mentions: queue (default), steer into its running turn, or abort its turn and start a fresh one on this text."),
|
|
27
68
|
status: z.enum(["planning", "working", "reviewing", "done"]).optional().describe("The thread's phase after this post. Makes it a status post."),
|
|
28
69
|
metadata: z
|
|
@@ -39,12 +80,19 @@ export default defineTool({
|
|
|
39
80
|
.describe("With a verdict: what the human has to pick. [] when nothing; required with ship."),
|
|
40
81
|
tested: z.string().optional().describe("With a verdict: what you ran to check it, in a sentence (shown on the Dash row)."),
|
|
41
82
|
}),
|
|
42
|
-
async execute({ threadId, agent, text, policy, status, metadata, verdict, decisions, tested }, ctx) {
|
|
83
|
+
async execute({ threadId, agent, text, images, policy, status, metadata, verdict, decisions, tested }, ctx) {
|
|
84
|
+
const bad = textError(text);
|
|
85
|
+
if (bad)
|
|
86
|
+
throw new Error(bad);
|
|
43
87
|
const target = threadId ?? (agent ? `dm-${agent.replace(/^@/, "")}` : "here");
|
|
88
|
+
let sandbox;
|
|
89
|
+
const files = async () => (sandbox ??= await ctx.getSandbox());
|
|
90
|
+
const posted = await rewriteDocLinks(text, async (path) => documentUrl(await files(), path, "post_message"));
|
|
91
|
+
const attachments = await Promise.all((images ?? []).map(async (image) => image === SCREENSHOT ? uploadBytes(await screenshot(ctx.session.id), "screenshot.png", "post_message") : uploadFile(await files(), image, "post_message")));
|
|
44
92
|
const res = await registryFetch(`/api/threads/${encodeURIComponent(target)}/messages`, {
|
|
45
93
|
method: "POST",
|
|
46
94
|
headers: { "content-type": "application/json" },
|
|
47
|
-
body: JSON.stringify({ text, sessionId: ctx.session.id, ...(policy ? { policy } : {}), ...(status ? { status } : {}), ...(metadata ? { metadata } : {}), ...(verdict ? { verdict } : {}), ...(decisions ? { decisions } : {}), ...(tested ? { tested } : {}) }),
|
|
95
|
+
body: JSON.stringify({ text: posted, sessionId: ctx.session.id, ...(attachments.length ? { attachments: attachments.map(({ id, name }) => ({ id, name })) } : {}), ...(policy ? { policy } : {}), ...(status ? { status } : {}), ...(metadata ? { metadata } : {}), ...(verdict ? { verdict } : {}), ...(decisions ? { decisions } : {}), ...(tested ? { tested } : {}) }),
|
|
48
96
|
});
|
|
49
97
|
if (!res.ok)
|
|
50
98
|
throw new Error(`post_message failed: ${res.status} ${await res.text()}`);
|
package/tools/show.js
CHANGED
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
-
import { pathToFileURL } from "node:url";
|
|
3
2
|
import { defineTool } from "eve/tools";
|
|
4
3
|
import { z } from "zod";
|
|
4
|
+
import { documentUrl, IMAGE_FILE } from "../hub-upload.js";
|
|
5
5
|
import { registryFetch } from "../registry-fetch.mjs";
|
|
6
6
|
// Moves the human's workspace UI, e.g. to a thread you just opened or a
|
|
7
7
|
// message they need to look at, or opens a page or file in the pane beside
|
|
8
8
|
// the thread. They see a note saying you did it and why.
|
|
9
9
|
//
|
|
10
|
-
// A file
|
|
11
|
-
//
|
|
12
|
-
// (hub://upload/<sha>.<ext>), since the human's Mac can't read the sandbox.
|
|
10
|
+
// A file opens from where the hub can serve it (hub-upload.ts). An image is
|
|
11
|
+
// refused: it belongs on a post, where the thread shows it inline.
|
|
13
12
|
export default defineTool({
|
|
14
|
-
description: "Move the human's screen to a place in the workspace: a thread (threadId), a bot's DM (agent), optionally scrolled to and highlighting one message (messageId). Or open a web page (uri) or a
|
|
13
|
+
description: "Move the human's screen to a place in the workspace: a thread (threadId), a bot's DM (agent), optionally scrolled to and highlighting one message (messageId). Or open a web page (uri) or a document from your sandbox (path: markdown, html, text or code, a PDF; line for text) in a tab beside this thread, without moving them. Not for images: those go in post_message's `images`. A document the human should keep is better linked from a post (post_message uploads a sandbox path in a markdown link); show only when they must look now. Rarely move their screen.",
|
|
15
14
|
inputSchema: z.object({
|
|
16
15
|
threadId: z.string().optional().describe("Thread id, e.g. thr_0002."),
|
|
17
16
|
agent: z.string().optional().describe("Open this bot's DM instead of a thread."),
|
|
18
17
|
messageId: z.string().optional().describe("Also scroll to and highlight this message, like opening a link to it."),
|
|
19
18
|
uri: z.string().optional().describe("An http(s) page to open beside the thread."),
|
|
20
|
-
path: z.string().optional().describe("A
|
|
19
|
+
path: z.string().optional().describe("A document in your sandbox to open beside the thread, e.g. /workspace/repo/PLAN.md. Not an image."),
|
|
21
20
|
line: z.number().int().positive().optional().describe("With path: the line a text or code file opens at."),
|
|
22
21
|
reason: z.string().describe("One short line shown to the human, e.g. 'the plan is ready for review'."),
|
|
23
22
|
}),
|
|
@@ -26,28 +25,12 @@ export default defineTool({
|
|
|
26
25
|
throw new Error("pass path or uri, not both");
|
|
27
26
|
if (line && !path)
|
|
28
27
|
throw new Error("line goes with path");
|
|
28
|
+
if (path && IMAGE_FILE.test(path))
|
|
29
|
+
throw new Error(`${basename(path)} is an image: post it with post_message's images instead, so it shows in the thread`);
|
|
29
30
|
let open = uri, title;
|
|
30
31
|
if (path) {
|
|
31
32
|
title = basename(path);
|
|
32
|
-
|
|
33
|
-
if (process.env.VERCEL || process.env.HUB_TOKEN) {
|
|
34
|
-
const bytes = await sandbox.readBinaryFile({ path });
|
|
35
|
-
if (!bytes)
|
|
36
|
-
throw new Error(`no file at ${path}`);
|
|
37
|
-
const res = await registryFetch(`/api/uploads`, {
|
|
38
|
-
method: "POST",
|
|
39
|
-
headers: { "content-type": "application/octet-stream", "x-filename": encodeURIComponent(basename(path)) },
|
|
40
|
-
body: new Uint8Array(bytes),
|
|
41
|
-
signal: AbortSignal.timeout(30_000),
|
|
42
|
-
});
|
|
43
|
-
const body = await res.json().catch(() => ({}));
|
|
44
|
-
if (!res.ok)
|
|
45
|
-
throw new Error(`show failed: the upload was refused (${body.error ?? res.status})`);
|
|
46
|
-
open = `hub://upload/${body.id}`;
|
|
47
|
-
}
|
|
48
|
-
else {
|
|
49
|
-
open = pathToFileURL(sandbox.resolvePath(path)).href;
|
|
50
|
-
}
|
|
33
|
+
open = await documentUrl(await ctx.getSandbox(), path, "show");
|
|
51
34
|
}
|
|
52
35
|
const res = await registryFetch(`/api/ui`, {
|
|
53
36
|
method: "POST",
|
package/tools/view_image.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The `view_image` tool: shows the bot an image so it can actually see it.
|
|
2
|
-
// `read_file` only reads text and `
|
|
3
|
-
// bot seeing it, so without this a bot can take a screenshot but
|
|
2
|
+
// `read_file` only reads text and post_message's `images` posts an image
|
|
3
|
+
// without the bot seeing it, so without this a bot can take a screenshot but
|
|
4
|
+
// not check it.
|
|
4
5
|
// The bytes come from the bot's sandbox (a file path) or its browser (a
|
|
5
6
|
// screenshot) and reach the model as an image part of the tool result.
|
|
6
7
|
//
|
|
@@ -101,7 +102,7 @@ export function toModelOutput(output) {
|
|
|
101
102
|
]);
|
|
102
103
|
}
|
|
103
104
|
export default defineTool({
|
|
104
|
-
description: "Look at an image yourself: a PNG, JPEG, GIF, or WebP file from your sandbox (path), or a screenshot of your browser's current page (screenshot: true). The image comes back to you so you can see it and describe it. Use it to check screenshots and captures before you
|
|
105
|
+
description: "Look at an image yourself: a PNG, JPEG, GIF, or WebP file from your sandbox (path), or a screenshot of your browser's current page (screenshot: true). The image comes back to you so you can see it and describe it. Use it to check screenshots and captures before you post them (post_message `images`); `read_file` can't read images. Large images are scaled to 1568px on the long edge.",
|
|
105
106
|
inputSchema: z.object({
|
|
106
107
|
path: z.string().optional().describe("Image file in your sandbox, e.g. /tmp/shot.png."),
|
|
107
108
|
screenshot: z.boolean().optional().describe("View a screenshot of your browser instead of a file."),
|
package/tools/attach_image.d.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
declare const _default: import("eve/tools").ToolDefinition<{
|
|
2
|
-
path?: string | undefined;
|
|
3
|
-
screenshot?: boolean | undefined;
|
|
4
|
-
}, {
|
|
5
|
-
attachment: any;
|
|
6
|
-
note: string;
|
|
7
|
-
}> & {
|
|
8
|
-
execute(input: {
|
|
9
|
-
path?: string | undefined;
|
|
10
|
-
screenshot?: boolean | undefined;
|
|
11
|
-
}, ctx: import("eve/tools").ToolContext): Promise<{
|
|
12
|
-
attachment: any;
|
|
13
|
-
note: string;
|
|
14
|
-
}>;
|
|
15
|
-
};
|
|
16
|
-
export default _default;
|
package/tools/attach_image.js
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
// The `attach_image` tool: puts an image on the bot's reply. The bytes come
|
|
2
|
-
// from the bot's own sandbox (a file path) or its browser (a screenshot) and
|
|
3
|
-
// go to the registry's upload store; the registry posts them with the turn's
|
|
4
|
-
// final message.
|
|
5
|
-
import { basename } from "node:path";
|
|
6
|
-
import { defineTool } from "eve/tools";
|
|
7
|
-
import { z } from "zod";
|
|
8
|
-
import { browserFor } from "../host-browser.js";
|
|
9
|
-
import { registryFetch } from "../registry-fetch.mjs";
|
|
10
|
-
export default defineTool({
|
|
11
|
-
description: "Attach an image to your reply in this thread: a PNG, JPEG, GIF, or WebP file from your sandbox (path), or a screenshot of your browser's current page (screenshot: true). Up to 3 MB each. It is posted with your final message for this turn, so mention what it shows there. Never write `![]()` for it.",
|
|
12
|
-
inputSchema: z.object({
|
|
13
|
-
path: z.string().optional().describe("Image file in your sandbox, e.g. /workspace/out/chart.png."),
|
|
14
|
-
screenshot: z.boolean().optional().describe("Attach a screenshot of your browser instead of a file."),
|
|
15
|
-
}),
|
|
16
|
-
async execute({ path, screenshot }, ctx) {
|
|
17
|
-
if (Boolean(path) === Boolean(screenshot))
|
|
18
|
-
throw new Error("pass exactly one of path or screenshot");
|
|
19
|
-
let bytes;
|
|
20
|
-
let name;
|
|
21
|
-
if (screenshot) {
|
|
22
|
-
const browser = await browserFor(ctx.session.id);
|
|
23
|
-
if (browser.url === "about:blank")
|
|
24
|
-
throw new Error("your browser has no page open; open one with the browser tool first");
|
|
25
|
-
bytes = await browser.screenshot();
|
|
26
|
-
name = "screenshot.png";
|
|
27
|
-
}
|
|
28
|
-
else {
|
|
29
|
-
const sandbox = await ctx.getSandbox();
|
|
30
|
-
const file = await sandbox.readBinaryFile({ path: path });
|
|
31
|
-
if (!file)
|
|
32
|
-
throw new Error(`no file at ${path}`);
|
|
33
|
-
bytes = file;
|
|
34
|
-
name = basename(path);
|
|
35
|
-
}
|
|
36
|
-
const res = await registryFetch(`/api/uploads`, {
|
|
37
|
-
method: "POST",
|
|
38
|
-
headers: { "content-type": "application/octet-stream", "x-filename": encodeURIComponent(name) },
|
|
39
|
-
body: new Uint8Array(bytes),
|
|
40
|
-
signal: AbortSignal.timeout(30_000),
|
|
41
|
-
}).catch((error) => {
|
|
42
|
-
throw new Error(`attach failed: the upload didn't finish (${error.message})`);
|
|
43
|
-
});
|
|
44
|
-
const body = await res.json().catch(() => ({}));
|
|
45
|
-
if (!res.ok)
|
|
46
|
-
throw new Error(`attach failed: ${body.error ?? res.status}`);
|
|
47
|
-
// The action.result stream event carries this reference on the exact turn
|
|
48
|
-
// that owns the call. Uploading bytes does not mutate session lifecycle.
|
|
49
|
-
return { attachment: body, note: "posts with your final message" };
|
|
50
|
-
},
|
|
51
|
-
});
|