@bivy/bivy 0.3.0-staging.46 → 0.3.0-staging.48
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/bin/bivy.mjs +60 -1
- package/dist/repo-workspace.js +52 -7
- package/dist/runtime/claude-code.js +5 -0
- package/dist/server.js +126 -5
- package/dist/session/attach-to-chat.js +134 -0
- package/dist/session/event-log.js +59 -2
- package/package.json +1 -1
package/bin/bivy.mjs
CHANGED
|
@@ -1528,7 +1528,7 @@ function cmdCompletions(args = []) {
|
|
|
1528
1528
|
const shell = (args[0] || "").toLowerCase();
|
|
1529
1529
|
const commands = [
|
|
1530
1530
|
"run", "sessions", "ls", "resume", "promote", "rename", "nodes", "agents", "agents:install", "shim", "takeover", "token", "exec",
|
|
1531
|
-
"send", "kill", "setup", "start", "stop", "restart", "status", "doctor", "logs", "login",
|
|
1531
|
+
"send", "attach", "kill", "setup", "start", "stop", "restart", "status", "doctor", "logs", "login",
|
|
1532
1532
|
"update", "update:log", "open", "service", "secrets", "voice", "link", "relay:setup",
|
|
1533
1533
|
"github:connect", "github:app-create", "github:app-connect", "github:app-sync", "prune", "uninstall", "help", "version",
|
|
1534
1534
|
];
|
|
@@ -1974,6 +1974,62 @@ async function cmdSend(args = []) {
|
|
|
1974
1974
|
process.exit(code);
|
|
1975
1975
|
}
|
|
1976
1976
|
|
|
1977
|
+
// `bivy attach <file> [--caption "…"] [--session <id>]` — surface a file the
|
|
1978
|
+
// agent produced into the chat as an image/file attachment (the reverse of the
|
|
1979
|
+
// composer paperclip). The universal path: any agent that can run a shell command
|
|
1980
|
+
// can call this. The session id defaults to $BIVY_SESSION_ID, which the daemon
|
|
1981
|
+
// injects into the agent's subprocess env. The file is resolved to an absolute
|
|
1982
|
+
// path here (the CLI's cwd is the agent's workdir) and confined to the session
|
|
1983
|
+
// workspace server-side.
|
|
1984
|
+
async function cmdAttach(args = []) {
|
|
1985
|
+
const flag = (name) => {
|
|
1986
|
+
const i = args.indexOf(name);
|
|
1987
|
+
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
|
|
1988
|
+
};
|
|
1989
|
+
const sessionId = flag("--session") || process.env.BIVY_SESSION_ID;
|
|
1990
|
+
const caption = flag("--caption");
|
|
1991
|
+
const name = flag("--name");
|
|
1992
|
+
const mimeType = flag("--mime") || flag("--mimeType");
|
|
1993
|
+
const flagsWithValue = new Set(["--session", "--caption", "--name", "--mime", "--mimeType"]);
|
|
1994
|
+
// First positional that isn't a flag or a flag's value.
|
|
1995
|
+
let file;
|
|
1996
|
+
for (let i = 0; i < args.length; i++) {
|
|
1997
|
+
const a = args[i];
|
|
1998
|
+
if (a.startsWith("-")) { if (flagsWithValue.has(a)) i++; continue; }
|
|
1999
|
+
if (i > 0 && flagsWithValue.has(args[i - 1])) continue;
|
|
2000
|
+
file = a;
|
|
2001
|
+
break;
|
|
2002
|
+
}
|
|
2003
|
+
if (!file) { console.error(c.red('Usage: bivy attach <file> [--caption "…"] [--session <id>]')); process.exit(1); return; }
|
|
2004
|
+
if (!sessionId) { console.error(c.red("No session id. Set --session <id> or run inside an agent session ($BIVY_SESSION_ID).")); process.exit(1); return; }
|
|
2005
|
+
const absPath = path.resolve(process.cwd(), file);
|
|
2006
|
+
if (!fs.existsSync(absPath)) { console.error(c.red(`File not found: ${file}`)); process.exit(1); return; }
|
|
2007
|
+
|
|
2008
|
+
const config = loadConfig();
|
|
2009
|
+
if (!(await ensureNodeRunning(config))) { console.error(c.red(`Could not reach the Bivy node at ${url(config)}.`)); process.exit(1); return; }
|
|
2010
|
+
// A token isn't required on a single-user host (loopback bypasses auth), but
|
|
2011
|
+
// include it when available so multi-user hosts work too.
|
|
2012
|
+
let token;
|
|
2013
|
+
try { token = await localDeviceToken(config); } catch { token = undefined; }
|
|
2014
|
+
const headers = { "content-type": "application/json" };
|
|
2015
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
2016
|
+
let res;
|
|
2017
|
+
try {
|
|
2018
|
+
res = await fetch(`${url(config)}/api/session/${encodeURIComponent(sessionId)}/attach`, {
|
|
2019
|
+
method: "POST",
|
|
2020
|
+
headers,
|
|
2021
|
+
body: JSON.stringify({ path: absPath, caption, name, mimeType }),
|
|
2022
|
+
});
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
console.error(c.red(`Could not reach the Bivy node: ${error?.message || String(error)}`));
|
|
2025
|
+
process.exit(1);
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
const body = await res.json().catch(() => ({}));
|
|
2029
|
+
if (!res.ok) { console.error(c.red(`Attach failed (${res.status}): ${body?.error || "unknown error"}`)); process.exit(1); return; }
|
|
2030
|
+
console.log(c.green(`Attached ${body.name} (${body.kind}, ${body.size} bytes) to the chat.`));
|
|
2031
|
+
}
|
|
2032
|
+
|
|
1977
2033
|
// Map a saved session's runtime id to the `bivy run` agent whose native CLI can
|
|
1978
2034
|
// resume it in a terminal. Only agents with a real native resume qualify; other
|
|
1979
2035
|
// runtimes (generic-cli, SDK-only) have no terminal resume and open in the web app.
|
|
@@ -4128,6 +4184,9 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
|
|
|
4128
4184
|
case "send":
|
|
4129
4185
|
await cmdSend(args);
|
|
4130
4186
|
break;
|
|
4187
|
+
case "attach":
|
|
4188
|
+
await cmdAttach(args);
|
|
4189
|
+
break;
|
|
4131
4190
|
case "completions":
|
|
4132
4191
|
case "completion":
|
|
4133
4192
|
cmdCompletions(args);
|
package/dist/repo-workspace.js
CHANGED
|
@@ -29,15 +29,60 @@ export function parseGitHubRemote(input) {
|
|
|
29
29
|
return parseRepo(`${ssh[1]}/${ssh[2]}`);
|
|
30
30
|
return undefined;
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
33
|
+
/**
|
|
34
|
+
* A git failure that DEFINITIVELY means "this workspace is not a GitHub-connected
|
|
35
|
+
* checkout" — a genuine `undefined` answer — as opposed to a transient failure we
|
|
36
|
+
* must not mistake for one. `git remote get-url origin` reports "not a git
|
|
37
|
+
* repository" (no repo) or "No such remote" (a repo with no origin); both are
|
|
38
|
+
* real, stable answers. Anything else (notably `index.lock`/`config.lock`
|
|
39
|
+
* contention when many sessions touch the same shared clone at once) is transient.
|
|
40
|
+
*/
|
|
41
|
+
function isDefinitiveNonGitHubError(error) {
|
|
42
|
+
const e = error;
|
|
43
|
+
const text = `${e?.stderr ?? ""} ${e?.message ?? String(error)}`;
|
|
44
|
+
return /not a git repository|No such remote|does not appear to be a git repository/i.test(text);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Infer owner/repo from a workspace's origin remote, if it is a GitHub checkout.
|
|
48
|
+
*
|
|
49
|
+
* Retries transient git failures before giving up. Misclassifying a momentarily
|
|
50
|
+
* busy GitHub checkout as "not a repo" is what let a session skip worktree
|
|
51
|
+
* isolation and run directly in the shared clone root, where its `git
|
|
52
|
+
* checkout`/`git stash` collided with a concurrent session (the "sessions
|
|
53
|
+
* mixing" bug). So: a DEFINITIVE non-GitHub result (not a repo / no origin)
|
|
54
|
+
* resolves to `undefined` as before, but a transient error is retried and then
|
|
55
|
+
* THROWN — the caller must fail loudly rather than silently degrade to running
|
|
56
|
+
* the agent in the shared root.
|
|
57
|
+
*/
|
|
33
58
|
export async function inferGitHubRepoFromWorkspace(workspace) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
59
|
+
let lastError;
|
|
60
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
61
|
+
try {
|
|
62
|
+
const { stdout } = await exec("git", ["-C", workspace, "remote", "get-url", "origin"], { cwd: workspace });
|
|
63
|
+
return parseGitHubRemote(stdout);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (isDefinitiveNonGitHubError(error))
|
|
67
|
+
return undefined;
|
|
68
|
+
lastError = error;
|
|
69
|
+
if (attempt < 2)
|
|
70
|
+
await delay(50 * (attempt + 1));
|
|
71
|
+
}
|
|
40
72
|
}
|
|
73
|
+
throw new Error(`Could not determine the GitHub repo for ${workspace} (the checkout may be busy): ` +
|
|
74
|
+
`${lastError?.message ?? String(lastError)}`);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* True when `dir` is a Bivy-managed shared clone root — a direct child of the
|
|
78
|
+
* repos root, i.e. `<reposRoot>/owner__repo` (see `cloneOrUpdateRepo`). Every
|
|
79
|
+
* session for a repo shares that one checkout, so an agent must NEVER run
|
|
80
|
+
* directly in it; it runs in a per-session worktree instead. Worktree paths live
|
|
81
|
+
* DEEPER (`<clone>/.bivy/worktrees/<slug>`) and are intentionally not matched, so
|
|
82
|
+
* this cleanly distinguishes "the shared root" from "an isolated worktree".
|
|
83
|
+
*/
|
|
84
|
+
export function isSharedCloneRoot(dir, reposRoot) {
|
|
85
|
+
return path.resolve(path.dirname(path.resolve(dir))) === path.resolve(reposRoot);
|
|
41
86
|
}
|
|
42
87
|
/** A GitHub token from env or the local `gh` login, or undefined (public only). */
|
|
43
88
|
export async function resolveGitHubToken(env = process.env) {
|
|
@@ -671,6 +671,11 @@ class ClaudeSession {
|
|
|
671
671
|
const env = { ...process.env, ...depCacheEnv(), ...this.runtimeOptions.env };
|
|
672
672
|
const credEnv = await this.resolveCredentialEnv();
|
|
673
673
|
Object.assign(env, credEnv);
|
|
674
|
+
// Let the agent's own shell surface a file into the chat via `bivy attach`
|
|
675
|
+
// (POST /api/session/:id/attach). The session id is otherwise invisible to
|
|
676
|
+
// the subprocess. Other runtimes should set this the same way to enable the
|
|
677
|
+
// universal attach path for their agents.
|
|
678
|
+
env.BIVY_SESSION_ID = this.id;
|
|
674
679
|
this.spawnedToken = authTokenFromEnv(credEnv);
|
|
675
680
|
const options = {
|
|
676
681
|
cwd: this.cwd,
|
package/dist/server.js
CHANGED
|
@@ -47,7 +47,7 @@ import { createCheckpointBundle, applyCheckpointBundle, materializeCheckpoint }
|
|
|
47
47
|
import { PolicyEngine } from "./policy/policy-engine.js";
|
|
48
48
|
import { TerminalManager } from "./terminal.js";
|
|
49
49
|
import { listMultiplexerSessions, attachCommand } from "./multiplexer.js";
|
|
50
|
-
import { createWorktree, removeWorktree, branchSlug } from "./worktree.js";
|
|
50
|
+
import { createWorktree, removeWorktree, branchSlug, gitRepoRoot } from "./worktree.js";
|
|
51
51
|
import { HarnessManager } from "./harness/manager.js";
|
|
52
52
|
import { startEgressProxyIfEnabled } from "./harness/egress.js";
|
|
53
53
|
import { initSharedDepCache, sharedDepCacheRoot } from "./harness/dep-cache.js";
|
|
@@ -55,7 +55,7 @@ import { evictToCap, dirSizeBytes } from "./harness/cache-evict.js";
|
|
|
55
55
|
import { checkDiskAdmission } from "./harness/disk-admission.js";
|
|
56
56
|
import { sandboxTier, setConfiguredSandboxTier, normalizeSandboxTier } from "./harness/sandbox.js";
|
|
57
57
|
import { injectMcpProxyForSession } from "./harness/mcp-inject.js";
|
|
58
|
-
import { parseRepo, inferGitHubRepoFromWorkspace, resolveGitHubToken, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, fetchOrigin } from "./repo-workspace.js";
|
|
58
|
+
import { parseRepo, inferGitHubRepoFromWorkspace, isSharedCloneRoot, resolveGitHubToken, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, fetchOrigin } from "./repo-workspace.js";
|
|
59
59
|
import { configureGitAuth, writeGitCredentialEndpoint } from "./git-auth.js";
|
|
60
60
|
import { GitHubTaskPoller, resolveGitHubTaskConfig, buildTaskPrompt, buildResumePrompt, buildInteractiveResumePrompt, DEFAULT_ISSUE_INSTRUCTIONS, parseBivyDirectives, commitAll, pushBranch, mergeBaseIntoBranch, completeMerge, abortMerge, findOpenPullRequestForBranch, findPullRequestsForBranch, findMergedPullRequestForBranch, issueBranchName, getPullRequest, commentIssue, listOpenLabelledIssues, selectActionableIssues, getIssue, getIssueCommentBody, addLabel, removeLabel, announcePickup, } from "./github-tasks.js";
|
|
61
61
|
import { buildLinearTaskPrompt, getLinearIssue, linearBranchName } from "./linear-tasks.js";
|
|
@@ -71,6 +71,7 @@ import { normalizeMessages } from "./session/transcript-normal.js";
|
|
|
71
71
|
import { buildNativeImportSeedPrompt } from "./session/native-import.js";
|
|
72
72
|
import { EventLog } from "./session/event-log.js";
|
|
73
73
|
import { AttachmentStore, isValidAttachmentHash } from "./session/attachment-store.js";
|
|
74
|
+
import { planAttachment, isAttachPlanError } from "./session/attach-to-chat.js";
|
|
74
75
|
import { ReplicationService } from "./session/replication-service.js";
|
|
75
76
|
import { createSessionNewDedupe } from "./session/session-new-dedupe.js";
|
|
76
77
|
import { evaluateForkPrereqs, blockingForkPrereqs, missingForkPrereqs } from "./session/fork-prereqs.js";
|
|
@@ -1032,6 +1033,40 @@ function materializeAttachments(record, files) {
|
|
|
1032
1033
|
}
|
|
1033
1034
|
return { note: notes.join("\n"), refs };
|
|
1034
1035
|
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Surface an AGENT-produced file into the chat as an attachment (image or file)
|
|
1038
|
+
* — the reverse of the composer paperclip. Confines to the session workspace,
|
|
1039
|
+
* stores the bytes in the content-addressed AttachmentStore, persists a durable
|
|
1040
|
+
* outbound reference anchored at the current transcript position (so a reload or
|
|
1041
|
+
* another device shows it), and emits the live `attachment` event so attached
|
|
1042
|
+
* devices render the chip/thumbnail immediately. Shared by the HTTP endpoint and
|
|
1043
|
+
* the `bivy attach` CLI. Returns the stored ref, or a human-readable error.
|
|
1044
|
+
*/
|
|
1045
|
+
function attachToChat(record, opts) {
|
|
1046
|
+
const plan = planAttachment({
|
|
1047
|
+
workspaceDir: harnessDirFor(record),
|
|
1048
|
+
filePath: opts.filePath,
|
|
1049
|
+
mimeType: opts.mimeType,
|
|
1050
|
+
name: opts.name,
|
|
1051
|
+
});
|
|
1052
|
+
if (isAttachPlanError(plan))
|
|
1053
|
+
return { error: plan.error };
|
|
1054
|
+
let ref;
|
|
1055
|
+
try {
|
|
1056
|
+
ref = attachmentStore.put(plan.bytes, { name: plan.name, mimeType: plan.mimeType, kind: plan.kind });
|
|
1057
|
+
}
|
|
1058
|
+
catch (error) {
|
|
1059
|
+
return { error: `Could not store the attachment: ${error instanceof Error ? error.message : String(error)}` };
|
|
1060
|
+
}
|
|
1061
|
+
const entryId = `att-${randomBytes(8).toString("hex")}`;
|
|
1062
|
+
const caption = opts.caption ? String(opts.caption).slice(0, 2000) : undefined;
|
|
1063
|
+
// Anchor at the current base length so history replay interleaves the
|
|
1064
|
+
// attachment where it was emitted (see event-log outbound projection).
|
|
1065
|
+
const afterMessageCount = record.session.getMessages().length;
|
|
1066
|
+
eventLog.appendOutboundAttachment(record.id, { afterMessageCount, id: entryId, ref, caption });
|
|
1067
|
+
broadcast({ type: "session.event", sessionId: record.id, event: { type: "attachment", id: entryId, ref, caption } });
|
|
1068
|
+
return { ref };
|
|
1069
|
+
}
|
|
1035
1070
|
function approvalModeFrom(value) {
|
|
1036
1071
|
return value === "never" || value === "risky" || value === "always" || value === "autonomous" ? value : undefined;
|
|
1037
1072
|
}
|
|
@@ -5525,6 +5560,22 @@ async function standUpFork(opts) {
|
|
|
5525
5560
|
cwd = wt.path;
|
|
5526
5561
|
worktree = wt;
|
|
5527
5562
|
}
|
|
5563
|
+
else {
|
|
5564
|
+
// Non-repo-backed source. The fork would otherwise reuse the PARENT's cwd,
|
|
5565
|
+
// putting two sessions in one working tree — so when that cwd is itself a git
|
|
5566
|
+
// checkout (a local repo without a GitHub origin), cut the fork its own
|
|
5567
|
+
// worktree on a fresh branch. Best-effort: a non-git workspace has no tree to
|
|
5568
|
+
// isolate, so the fork keeps the fallback cwd (no git collisions possible).
|
|
5569
|
+
const forkRepoRoot = await gitRepoRoot(cwd);
|
|
5570
|
+
if (forkRepoRoot) {
|
|
5571
|
+
const forkBranch = `bivy/fork-${randomBytes(6).toString("hex")}`;
|
|
5572
|
+
const wt = await createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch });
|
|
5573
|
+
applyDirtyPatch(wt.path, bundle.dirtyPatch);
|
|
5574
|
+
workspace = forkRepoRoot;
|
|
5575
|
+
cwd = wt.path;
|
|
5576
|
+
worktree = wt;
|
|
5577
|
+
}
|
|
5578
|
+
}
|
|
5528
5579
|
// Materialise the transcript, then stand the session up — resume the imported
|
|
5529
5580
|
// transcript (full) or a fresh session the caller seeds with plan.seedPrompt.
|
|
5530
5581
|
const plan = await materializeFork({ bundle, targetRuntime, ctx: { workspace, cwd }, seed: { transcriptUrl: opts.transcriptUrl } });
|
|
@@ -7164,9 +7215,52 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
|
|
|
7164
7215
|
if (!admission.allowed)
|
|
7165
7216
|
throw new Error(`Not enough disk to start a new worktree session: ${admission.reason}`);
|
|
7166
7217
|
const wtOpts = typeof opts.worktree === "object" ? opts.worktree : {};
|
|
7167
|
-
|
|
7218
|
+
// A random suffix, never a timestamp: two sessions started in the same
|
|
7219
|
+
// millisecond would otherwise resolve to the same slug → same worktree path
|
|
7220
|
+
// and branch, and `createWorktree` would ADOPT the first's worktree, dropping
|
|
7221
|
+
// the second session into a directory another session already owns.
|
|
7222
|
+
worktree = await createWorktree({ repoDir: workspace, id: wtOpts.branch ?? `session-${randomBytes(6).toString("hex")}`, branch: wtOpts.branch, base: wtOpts.base });
|
|
7168
7223
|
runtimeWorkspace = worktree.path;
|
|
7169
7224
|
}
|
|
7225
|
+
// Resume with a reaped worktree. When a repo-backed session is resumed but its
|
|
7226
|
+
// worktree directory was removed while it was closed (disk cleanup, a manual
|
|
7227
|
+
// rm, `git worktree remove`), restoredWorktree is undefined and we'd otherwise
|
|
7228
|
+
// fall back to the shared clone root — which the invariant below then rejects.
|
|
7229
|
+
// Re-provision a fresh worktree on the SAME branch instead (branches survive
|
|
7230
|
+
// `git worktree remove`, so the agent's committed history is intact), restoring
|
|
7231
|
+
// isolation so the resumed session is usable again. Best-effort: if the clone
|
|
7232
|
+
// or branch is gone, we leave it to the invariant to fail safe rather than
|
|
7233
|
+
// corrupt a neighbour. The clone root is reconstructed from `source`
|
|
7234
|
+
// (`repo:owner/repo`) because stored `workspace` is the old worktree path.
|
|
7235
|
+
if (requestedSessionFile && !worktree && storedMeta?.worktree && storedMeta?.branch) {
|
|
7236
|
+
const parsedSource = parseRepoSource(storedMeta.source);
|
|
7237
|
+
if (parsedSource) {
|
|
7238
|
+
const repoDir = path.join(reposRoot, `${parsedSource.owner}__${parsedSource.repo}`);
|
|
7239
|
+
try {
|
|
7240
|
+
// Clear any stale registration left by a dir that was rm'd out from under
|
|
7241
|
+
// git, so re-adding the branch's worktree doesn't hit "already checked out".
|
|
7242
|
+
runGit(["worktree", "prune"], repoDir);
|
|
7243
|
+
const reprovisioned = await createWorktree({ repoDir, id: storedMeta.branch, branch: storedMeta.branch });
|
|
7244
|
+
worktree = reprovisioned;
|
|
7245
|
+
runtimeWorkspace = reprovisioned.path;
|
|
7246
|
+
}
|
|
7247
|
+
catch (error) {
|
|
7248
|
+
console.warn(`Could not re-provision worktree for resumed session on ${storedMeta.branch}: ${error instanceof Error ? error.message : String(error)}`);
|
|
7249
|
+
}
|
|
7250
|
+
}
|
|
7251
|
+
}
|
|
7252
|
+
// Isolation invariant. A session must NEVER run directly in a Bivy-managed
|
|
7253
|
+
// shared clone root (`<reposRoot>/owner__repo`): every session for that repo
|
|
7254
|
+
// shares that one checkout, so an agent running there collides with concurrent
|
|
7255
|
+
// sessions on `git checkout`/`git stash` — exactly the "sessions mixing" bug.
|
|
7256
|
+
// A GitHub-backed session is supposed to get its own worktree; reaching here
|
|
7257
|
+
// without one means an earlier step degraded (e.g. a transient repo-inference
|
|
7258
|
+
// failure, or a resume whose worktree was reaped). Fail loudly instead of
|
|
7259
|
+
// silently sharing the tree and corrupting a neighbouring session's work.
|
|
7260
|
+
if (!worktree && isSharedCloneRoot(runtimeWorkspace, reposRoot)) {
|
|
7261
|
+
throw new Error(`Refusing to start a session in the shared clone root ${runtimeWorkspace} without an isolated worktree — ` +
|
|
7262
|
+
`this would collide with concurrent sessions on the same repo. Retry; if it persists the checkout may be busy.`);
|
|
7263
|
+
}
|
|
7170
7264
|
const runtimeSessionOptions = { workspace: runtimeWorkspace, toolProvider: integrations.toolProvider(), ...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}) };
|
|
7171
7265
|
// Stage 2/3: prefer re-attaching to a still-live remote session — routed to its
|
|
7172
7266
|
// OWN agent service — over re-opening a fresh copy from disk. Falls back to
|
|
@@ -7194,8 +7288,12 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
|
|
|
7194
7288
|
if (requestedSessionFile && storedMeta?.name && !session.getName())
|
|
7195
7289
|
session.setName(storedMeta.name);
|
|
7196
7290
|
const sessionWorkspace = session.cwd || runtimeWorkspace;
|
|
7197
|
-
|
|
7198
|
-
|
|
7291
|
+
// Best-effort here (unlike createWorkspaceSession, which must fail loudly):
|
|
7292
|
+
// this only decides whether to ADOPT an already-checked-out branch as the
|
|
7293
|
+
// session's worktree label, so a transient inference failure should quietly
|
|
7294
|
+
// skip adoption rather than break resuming the session.
|
|
7295
|
+
const inferredRepo = opts.source || storedMeta?.source ? undefined : await inferGitHubRepoFromWorkspace(sessionWorkspace).catch(() => undefined);
|
|
7296
|
+
if (!worktree && requestedSessionFile && inferredRepo && !isSharedCloneRoot(sessionWorkspace, reposRoot)) {
|
|
7199
7297
|
const branch = runGit(["branch", "--show-current"], sessionWorkspace) || runGit(["rev-parse", "--short", "HEAD"], sessionWorkspace) || undefined;
|
|
7200
7298
|
if (branch) {
|
|
7201
7299
|
const mainWorktree = runGit(["worktree", "list", "--porcelain"], sessionWorkspace)?.split("\n").find((line) => line.startsWith("worktree "))?.slice("worktree ".length);
|
|
@@ -9460,6 +9558,29 @@ app.get("/api/attachment/:hash", (req, res) => {
|
|
|
9460
9558
|
res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
|
|
9461
9559
|
res.end(bytes);
|
|
9462
9560
|
});
|
|
9561
|
+
// Let an AGENT push a file into the chat as an attachment (image/file) — the
|
|
9562
|
+
// reverse of the composer upload. Called by the agent's own shell (`bivy attach`)
|
|
9563
|
+
// or any local tool; on a single-user host the loopback bypass means no token is
|
|
9564
|
+
// needed. Behind /api's authMiddleware. `path` is resolved inside — and confined
|
|
9565
|
+
// to — the session's workspace (see planAttachment's security note).
|
|
9566
|
+
app.post("/api/session/:id/attach", (req, res) => {
|
|
9567
|
+
const record = openSessions.get(String(req.params.id));
|
|
9568
|
+
if (!record)
|
|
9569
|
+
return res.status(404).json({ error: "Session not found" });
|
|
9570
|
+
const filePath = String(req.body?.path ?? req.body?.filePath ?? "").trim();
|
|
9571
|
+
if (!filePath)
|
|
9572
|
+
return res.status(400).json({ error: "Missing file path" });
|
|
9573
|
+
const result = attachToChat(record, {
|
|
9574
|
+
filePath,
|
|
9575
|
+
caption: typeof req.body?.caption === "string" ? req.body.caption : undefined,
|
|
9576
|
+
mimeType: typeof req.body?.mimeType === "string" ? req.body.mimeType : undefined,
|
|
9577
|
+
name: typeof req.body?.name === "string" ? req.body.name : undefined,
|
|
9578
|
+
});
|
|
9579
|
+
if ("error" in result)
|
|
9580
|
+
return res.status(400).json({ error: result.error });
|
|
9581
|
+
const { hash, name, mimeType, size, kind } = result.ref;
|
|
9582
|
+
res.json({ ok: true, hash, name, mimeType, size, kind });
|
|
9583
|
+
});
|
|
9463
9584
|
app.post("/api/session/prompt", async (req, res, next) => {
|
|
9464
9585
|
try {
|
|
9465
9586
|
const text = String(req.body?.text ?? "").trim();
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Plan an AGENT-sent chat attachment: the reverse of the composer paperclip.
|
|
5
|
+
// An agent points at a file it produced in the workspace (a rendered chart, a
|
|
6
|
+
// screenshot, a report), and Bivy surfaces it into the chat as an image/file
|
|
7
|
+
// chip. This module is the PURE, testable half — path confinement, size cap, and
|
|
8
|
+
// mime/kind classification — returning bytes + metadata (or a human-readable
|
|
9
|
+
// error). The server half stores the bytes in the content-addressed
|
|
10
|
+
// AttachmentStore, emits the live `attachment` event, and persists the outbound
|
|
11
|
+
// reference for durable history.
|
|
12
|
+
//
|
|
13
|
+
// Security posture: the resolved file MUST live inside the session's working
|
|
14
|
+
// directory. An agent is a semi-trusted process; without confinement, a prompt
|
|
15
|
+
// injection could turn "attach a file to the chat" into "exfiltrate /etc/passwd
|
|
16
|
+
// (or ~/.ssh/id_rsa) to the user's phone". Symlinks are resolved before the
|
|
17
|
+
// check so a symlink inside the workspace can't point out of it.
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
/** Ceiling for a single agent attachment. Kept comfortably under the relay's
|
|
21
|
+
* 32 MiB reassembly limit (see packages/core/src/wire-format.ts) so a large
|
|
22
|
+
* attachment still travels to a phone over the encrypted relay in chunks. */
|
|
23
|
+
export const MAX_AGENT_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
24
|
+
export function isAttachPlanError(value) {
|
|
25
|
+
return typeof value.error === "string";
|
|
26
|
+
}
|
|
27
|
+
const EXT_MIME = {
|
|
28
|
+
".png": "image/png",
|
|
29
|
+
".jpg": "image/jpeg",
|
|
30
|
+
".jpeg": "image/jpeg",
|
|
31
|
+
".gif": "image/gif",
|
|
32
|
+
".webp": "image/webp",
|
|
33
|
+
".svg": "image/svg+xml",
|
|
34
|
+
".bmp": "image/bmp",
|
|
35
|
+
".ico": "image/x-icon",
|
|
36
|
+
".avif": "image/avif",
|
|
37
|
+
".pdf": "application/pdf",
|
|
38
|
+
".txt": "text/plain",
|
|
39
|
+
".md": "text/markdown",
|
|
40
|
+
".csv": "text/csv",
|
|
41
|
+
".json": "application/json",
|
|
42
|
+
".html": "text/html",
|
|
43
|
+
".zip": "application/zip",
|
|
44
|
+
};
|
|
45
|
+
/** Sniff a mime type from the leading magic bytes for the common image/PDF
|
|
46
|
+
* formats, so a mislabeled or extension-less file still classifies correctly.
|
|
47
|
+
* Returns "" when nothing matches (caller falls back to extension/default). */
|
|
48
|
+
export function sniffMime(bytes) {
|
|
49
|
+
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
|
|
50
|
+
return "image/png";
|
|
51
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
|
|
52
|
+
return "image/jpeg";
|
|
53
|
+
if (bytes.length >= 6 && (bytes.subarray(0, 6).toString("latin1") === "GIF87a" || bytes.subarray(0, 6).toString("latin1") === "GIF89a"))
|
|
54
|
+
return "image/gif";
|
|
55
|
+
if (bytes.length >= 12 && bytes.subarray(0, 4).toString("latin1") === "RIFF" && bytes.subarray(8, 12).toString("latin1") === "WEBP")
|
|
56
|
+
return "image/webp";
|
|
57
|
+
if (bytes.length >= 5 && bytes.subarray(0, 5).toString("latin1") === "%PDF-")
|
|
58
|
+
return "application/pdf";
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
/** Strip directory components and control/path characters from a filename so it
|
|
62
|
+
* is safe to show and to store as an attachment display name. */
|
|
63
|
+
export function sanitizeAttachmentName(name) {
|
|
64
|
+
const base = path.basename(String(name || "").trim());
|
|
65
|
+
const cleaned = base
|
|
66
|
+
.replace(/[/\\]+/g, "_")
|
|
67
|
+
// eslint-disable-next-line no-control-regex
|
|
68
|
+
.replace(/[\x00-\x1f]+/g, "")
|
|
69
|
+
.trim();
|
|
70
|
+
return cleaned.slice(0, 200) || "attachment";
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolve, confine, size-check, read, and classify a file the agent asked to
|
|
74
|
+
* attach. `filePath` may be absolute or relative to `workspaceDir`; either way
|
|
75
|
+
* the resolved real path must sit inside `workspaceDir`.
|
|
76
|
+
*/
|
|
77
|
+
export function planAttachment(opts) {
|
|
78
|
+
const raw = String(opts.filePath || "").trim();
|
|
79
|
+
if (!raw)
|
|
80
|
+
return { error: "No file path given." };
|
|
81
|
+
const workspaceDir = path.resolve(opts.workspaceDir);
|
|
82
|
+
const resolved = path.resolve(workspaceDir, raw);
|
|
83
|
+
// Confinement, symlink-safe: resolve the real path of the file before comparing
|
|
84
|
+
// against the real workspace root. realpathSync also fails cleanly for a missing
|
|
85
|
+
// file.
|
|
86
|
+
let realFile;
|
|
87
|
+
let realRoot;
|
|
88
|
+
try {
|
|
89
|
+
realRoot = fs.realpathSync(workspaceDir);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return { error: "Workspace directory is unavailable." };
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
realFile = fs.realpathSync(resolved);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return { error: `File not found: ${raw}` };
|
|
99
|
+
}
|
|
100
|
+
const rel = path.relative(realRoot, realFile);
|
|
101
|
+
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
102
|
+
return { error: "Refusing to attach a file outside the session workspace." };
|
|
103
|
+
}
|
|
104
|
+
let stat;
|
|
105
|
+
try {
|
|
106
|
+
stat = fs.statSync(realFile);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return { error: `File not found: ${raw}` };
|
|
110
|
+
}
|
|
111
|
+
if (stat.isDirectory())
|
|
112
|
+
return { error: `Not a file: ${raw}` };
|
|
113
|
+
const maxBytes = opts.maxBytes ?? MAX_AGENT_ATTACHMENT_BYTES;
|
|
114
|
+
if (stat.size > maxBytes) {
|
|
115
|
+
return { error: `File is too large to attach (${stat.size} bytes; limit ${maxBytes}).` };
|
|
116
|
+
}
|
|
117
|
+
if (stat.size === 0)
|
|
118
|
+
return { error: "Refusing to attach an empty file." };
|
|
119
|
+
let bytes;
|
|
120
|
+
try {
|
|
121
|
+
bytes = fs.readFileSync(realFile);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return { error: `Could not read file: ${raw}` };
|
|
125
|
+
}
|
|
126
|
+
const ext = path.extname(realFile).toLowerCase();
|
|
127
|
+
const mimeType = (opts.mimeType && String(opts.mimeType).trim()) ||
|
|
128
|
+
sniffMime(bytes) ||
|
|
129
|
+
EXT_MIME[ext] ||
|
|
130
|
+
"application/octet-stream";
|
|
131
|
+
const kind = mimeType.startsWith("image/") ? "image" : "file";
|
|
132
|
+
const name = sanitizeAttachmentName(opts.name || path.basename(realFile));
|
|
133
|
+
return { bytes, name, mimeType, kind };
|
|
134
|
+
}
|
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
// interleaved in any order on disk; each replay reads only its own kind.
|
|
29
29
|
import fs from "node:fs";
|
|
30
30
|
import { normalizedIntermediateText, thinkingTextFromContent, mergeTranscript } from "./transcript-merge.js";
|
|
31
|
+
/** Content-block type carried by a folded outbound attachment. MUST match
|
|
32
|
+
* `AGENT_ATTACHMENT_BLOCK` in packages/core/src/store-render.ts — the client's
|
|
33
|
+
* renderHistory keys on this exact string to render the chip. */
|
|
34
|
+
const AGENT_ATTACHMENT_BLOCK = "bivy_attachment";
|
|
31
35
|
function isOverlay(value) {
|
|
32
36
|
if (!value || typeof value !== "object")
|
|
33
37
|
return false;
|
|
@@ -46,8 +50,19 @@ function isAttachment(value) {
|
|
|
46
50
|
const record = value;
|
|
47
51
|
return record.bivyKind === "attachment" && typeof record.text === "string" && Array.isArray(record.refs);
|
|
48
52
|
}
|
|
53
|
+
function isOutboundAttachment(value) {
|
|
54
|
+
if (!value || typeof value !== "object")
|
|
55
|
+
return false;
|
|
56
|
+
const record = value;
|
|
57
|
+
return (record.bivyKind === "outbound-attachment" &&
|
|
58
|
+
typeof record.afterMessageCount === "number" &&
|
|
59
|
+
typeof record.id === "string" &&
|
|
60
|
+
!!record.ref &&
|
|
61
|
+
typeof record.ref === "object" &&
|
|
62
|
+
typeof record.ref.hash === "string");
|
|
63
|
+
}
|
|
49
64
|
function isRecord(value) {
|
|
50
|
-
return isOverlay(value) || isBase(value) || isAttachment(value);
|
|
65
|
+
return isOverlay(value) || isBase(value) || isAttachment(value) || isOutboundAttachment(value);
|
|
51
66
|
}
|
|
52
67
|
/**
|
|
53
68
|
* Fold attachment records into a text→refs list: last write wins per text (a
|
|
@@ -132,7 +147,32 @@ export function replayExtras(entries) {
|
|
|
132
147
|
else if (entry.bivyKind === "tool")
|
|
133
148
|
tool.push(entry);
|
|
134
149
|
}
|
|
135
|
-
return [...foldIntermediate(intermediate), ...foldTool(tool)];
|
|
150
|
+
return [...foldIntermediate(intermediate), ...foldTool(tool), ...replayOutboundAttachments(entries)];
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Fold the outbound (agent-sent) attachment records into time-anchored synthetic
|
|
154
|
+
* assistant messages `mergeTranscript` interleaves into the transcript. Last write
|
|
155
|
+
* wins per id (a re-emitted id updates in place, matching the log's coalescing),
|
|
156
|
+
* preserving first-seen order. Each becomes one `bivy_attachment` block the client
|
|
157
|
+
* renders as a chip/thumbnail.
|
|
158
|
+
*/
|
|
159
|
+
export function replayOutboundAttachments(entries) {
|
|
160
|
+
const byId = new Map();
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
if (entry.bivyKind !== "outbound-attachment")
|
|
163
|
+
continue;
|
|
164
|
+
// set() on an existing key updates the value in place (Map keeps first-seen
|
|
165
|
+
// insertion order), so last write wins while position is stable. Final
|
|
166
|
+
// placement is by time in mergeTranscript regardless.
|
|
167
|
+
byId.set(entry.id, entry);
|
|
168
|
+
}
|
|
169
|
+
return [...byId.values()].map((entry) => ({
|
|
170
|
+
role: "assistant",
|
|
171
|
+
content: [{ type: AGENT_ATTACHMENT_BLOCK, ref: entry.ref, caption: entry.caption }],
|
|
172
|
+
afterMessageCount: entry.afterMessageCount,
|
|
173
|
+
createdAt: entry.createdAt,
|
|
174
|
+
id: entry.id,
|
|
175
|
+
}));
|
|
136
176
|
}
|
|
137
177
|
/**
|
|
138
178
|
* Replay a session's base records into the base transcript: `reset` replaces the
|
|
@@ -286,6 +326,23 @@ export class EventLog {
|
|
|
286
326
|
readAttachments(id) {
|
|
287
327
|
return replayAttachments(this.entries(id));
|
|
288
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* Record an agent-sent (outbound) attachment, anchored at the current base
|
|
331
|
+
* length so history replay interleaves it where it was emitted. Coalesces on
|
|
332
|
+
* the transcript-entry id so a re-emit of the same attachment updates in place.
|
|
333
|
+
*/
|
|
334
|
+
appendOutboundAttachment(id, entry) {
|
|
335
|
+
this.load(id);
|
|
336
|
+
const record = {
|
|
337
|
+
bivyKind: "outbound-attachment",
|
|
338
|
+
createdAt: Date.now(),
|
|
339
|
+
afterMessageCount: entry.afterMessageCount,
|
|
340
|
+
id: entry.id,
|
|
341
|
+
ref: { ...entry.ref },
|
|
342
|
+
...(entry.caption ? { caption: entry.caption } : {}),
|
|
343
|
+
};
|
|
344
|
+
this.enqueue(id, `oa:${entry.id}`, record);
|
|
345
|
+
}
|
|
289
346
|
/** Replay the overlay entries (disk + pending) into the flat `extras` list. */
|
|
290
347
|
read(id) {
|
|
291
348
|
return replayExtras(this.entries(id));
|
package/package.json
CHANGED