@hachej/boring-agent 0.1.5 → 0.1.7

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.
@@ -725,6 +725,11 @@ function logSandboxStop(logger, metadata) {
725
725
  ...metadata
726
726
  });
727
727
  }
728
+ function isSandboxAlreadyExistsError(error) {
729
+ const code = error?.json?.error?.code;
730
+ const message = error?.json?.error?.message;
731
+ return code === "bad_request" && typeof message === "string" && message.includes("already exists");
732
+ }
728
733
  async function createFresh(workspaceId, snapshotId, tarballUrl, previous, store, vercel, logger) {
729
734
  let sandbox;
730
735
  let sourceType = "empty";
@@ -733,21 +738,25 @@ async function createFresh(workspaceId, snapshotId, tarballUrl, previous, store,
733
738
  persistent: true,
734
739
  snapshotExpiration: 0
735
740
  };
736
- if (snapshotId) {
737
- sourceType = "snapshot";
738
- sandbox = await vercel.create({
739
- ...base,
740
- source: { type: "snapshot", snapshotId }
741
- });
742
- } else if (tarballUrl) {
743
- sourceType = "tarball";
744
- sandbox = await vercel.create({
745
- ...base,
746
- source: { type: "tarball", url: tarballUrl }
747
- });
748
- } else {
749
- sandbox = await vercel.create(base);
750
- }
741
+ const tryCreate = async () => {
742
+ try {
743
+ if (snapshotId) {
744
+ sourceType = "snapshot";
745
+ return await vercel.create({ ...base, source: { type: "snapshot", snapshotId } });
746
+ } else if (tarballUrl) {
747
+ sourceType = "tarball";
748
+ return await vercel.create({ ...base, source: { type: "tarball", url: tarballUrl } });
749
+ } else {
750
+ return await vercel.create(base);
751
+ }
752
+ } catch (error) {
753
+ if (isSandboxAlreadyExistsError(error)) {
754
+ return await vercel.get({ name: base.name, sandboxId: base.name, resume: true });
755
+ }
756
+ throw error;
757
+ }
758
+ };
759
+ sandbox = await tryCreate();
751
760
  logSandboxCreate(logger, {
752
761
  workspaceId,
753
762
  sandboxId: getSandboxIdentifier(sandbox),
@@ -1380,10 +1389,18 @@ function createNodeWorkspace(root) {
1380
1389
  const absPath = await ensureExistingWorkspacePath(root, relPath);
1381
1390
  return await readFile3(absPath, "utf-8");
1382
1391
  },
1392
+ async readBinaryFile(relPath) {
1393
+ const absPath = await ensureExistingWorkspacePath(root, relPath);
1394
+ return new Uint8Array(await readFile3(absPath));
1395
+ },
1383
1396
  async writeFile(relPath, data) {
1384
1397
  const absPath = await ensureWritableWorkspacePath(root, relPath);
1385
1398
  await writeFile3(absPath, data, "utf-8");
1386
1399
  },
1400
+ async writeBinaryFile(relPath, data) {
1401
+ const absPath = await ensureWritableWorkspacePath(root, relPath);
1402
+ await writeFile3(absPath, data);
1403
+ },
1387
1404
  async readFileWithStat(relPath) {
1388
1405
  const absPath = await ensureExistingWorkspacePath(root, relPath);
1389
1406
  const [content, fileStat] = await Promise.all([
@@ -1409,6 +1426,16 @@ function createNodeWorkspace(root) {
1409
1426
  kind: fileStat.isDirectory() ? "dir" : "file"
1410
1427
  };
1411
1428
  },
1429
+ async writeBinaryFileWithStat(relPath, data) {
1430
+ const absPath = await ensureWritableWorkspacePath(root, relPath);
1431
+ await writeFile3(absPath, data);
1432
+ const fileStat = await stat2(absPath);
1433
+ return {
1434
+ size: fileStat.size,
1435
+ mtimeMs: fileStat.mtimeMs,
1436
+ kind: fileStat.isDirectory() ? "dir" : "file"
1437
+ };
1438
+ },
1412
1439
  async unlink(relPath) {
1413
1440
  const absPath = await ensureExistingWorkspacePath(root, relPath);
1414
1441
  const pathStat = await lstat2(absPath);
@@ -1819,6 +1846,20 @@ function createVercelSandboxWorkspace(sandbox, workspaceOpts = {}) {
1819
1846
  }
1820
1847
  return Buffer.from(content).toString("utf-8");
1821
1848
  },
1849
+ async readBinaryFile(relPath) {
1850
+ const sandboxPath = toSandboxPath(relPath);
1851
+ if (remote.fs?.readFile) {
1852
+ const content2 = await remote.fs.readFile(sandboxPath);
1853
+ return new Uint8Array(Buffer.from(content2));
1854
+ }
1855
+ const content = await remote.readFileToBuffer?.({ path: sandboxPath });
1856
+ if (!content) {
1857
+ const err = new Error(`ENOENT: file not found, open '${sandboxPath}'`);
1858
+ err.code = "ENOENT";
1859
+ throw err;
1860
+ }
1861
+ return new Uint8Array(Buffer.from(content));
1862
+ },
1822
1863
  async writeFile(relPath, data) {
1823
1864
  const sandboxPath = toSandboxPath(relPath);
1824
1865
  await sandbox.writeFiles([
@@ -1831,6 +1872,18 @@ function createVercelSandboxWorkspace(sandbox, workspaceOpts = {}) {
1831
1872
  workspaceOpts.onMutation?.();
1832
1873
  emitChange({ op: "write", path: relPath });
1833
1874
  },
1875
+ async writeBinaryFile(relPath, data) {
1876
+ const sandboxPath = toSandboxPath(relPath);
1877
+ await sandbox.writeFiles([
1878
+ {
1879
+ path: sandboxPath,
1880
+ content: Buffer.from(data)
1881
+ }
1882
+ ]);
1883
+ invalidateMetadataCache();
1884
+ workspaceOpts.onMutation?.();
1885
+ emitChange({ op: "write", path: relPath });
1886
+ },
1834
1887
  async readFileWithStat(relPath) {
1835
1888
  const sandboxPath = toSandboxPath(relPath);
1836
1889
  const cachedStat = statCache.get(sandboxPath);
@@ -1905,6 +1958,44 @@ function createVercelSandboxWorkspace(sandbox, workspaceOpts = {}) {
1905
1958
  emitChange({ op: "write", path: relPath, mtimeMs: writtenStat.mtimeMs });
1906
1959
  return cloneStat(writtenStat);
1907
1960
  },
1961
+ async writeBinaryFileWithStat(relPath, data) {
1962
+ const sandboxPath = toSandboxPath(relPath);
1963
+ const payload = Buffer.from(data);
1964
+ if (payload.byteLength > MAX_INLINE_WRITE_BYTES) {
1965
+ await sandbox.writeFiles([
1966
+ {
1967
+ path: sandboxPath,
1968
+ content: payload
1969
+ }
1970
+ ]);
1971
+ invalidateMetadataCache();
1972
+ workspaceOpts.onMutation?.();
1973
+ const writtenStat2 = remote.fs?.stat ? await (async () => {
1974
+ const fileStat = await remote.fs.stat(sandboxPath);
1975
+ return {
1976
+ size: fileStat.size,
1977
+ mtimeMs: fileStat.mtimeMs,
1978
+ kind: fileStat.isDirectory() ? "dir" : "file"
1979
+ };
1980
+ })() : await runJson(
1981
+ remote,
1982
+ `node -e ${shellQuote2(`const fs=require('fs'); const p=process.argv[1]; const s=fs.statSync(p); process.stdout.write(JSON.stringify({size:s.size,mtimeMs:s.mtimeMs,kind:s.isDirectory()?'dir':'file'}))`)} ${shellQuote2(sandboxPath)}`
1983
+ );
1984
+ statCache.set(sandboxPath, writtenStat2);
1985
+ emitChange({ op: "write", path: relPath, mtimeMs: writtenStat2.mtimeMs });
1986
+ return cloneStat(writtenStat2);
1987
+ }
1988
+ const encoded = payload.toString("base64");
1989
+ const writtenStat = await runJson(
1990
+ remote,
1991
+ `node -e ${shellQuote2(`const fs=require('fs'); const p=process.argv[1]; const data=Buffer.from(process.argv[2],'base64'); fs.writeFileSync(p,data); const s=fs.statSync(p); process.stdout.write(JSON.stringify({size:s.size,mtimeMs:s.mtimeMs,kind:s.isDirectory()?'dir':'file'}))`)} ${shellQuote2(sandboxPath)} ${shellQuote2(encoded)}`
1992
+ );
1993
+ invalidateMetadataCache();
1994
+ statCache.set(sandboxPath, writtenStat);
1995
+ workspaceOpts.onMutation?.();
1996
+ emitChange({ op: "write", path: relPath, mtimeMs: writtenStat.mtimeMs });
1997
+ return cloneStat(writtenStat);
1998
+ },
1908
1999
  async unlink(relPath) {
1909
2000
  const sandboxPath = toSandboxPath(relPath);
1910
2001
  if (remote.fs?.rm) await remote.fs.rm(sandboxPath, { recursive: false, force: false });
@@ -2410,6 +2501,39 @@ async function ensureVercelWorkspaceRoot(sandbox) {
2410
2501
  throw new Error(`failed to initialize ${VERCEL_SANDBOX_REMOTE_ROOT} (exit ${result.exitCode ?? "unknown"})`);
2411
2502
  }
2412
2503
  }
2504
+ async function seedTemplateIntoVercelWorkspace(workspace, templatePath, logger) {
2505
+ const files = await collectFiles(templatePath);
2506
+ const hash = computeTemplateHash(files);
2507
+ const markerPath = `.boring-agent/templates/${hash}.json`;
2508
+ try {
2509
+ await workspace.stat(markerPath);
2510
+ logger.info("[vercel-sandbox:mode] template already seeded", {
2511
+ hash,
2512
+ fileCount: files.length
2513
+ });
2514
+ return;
2515
+ } catch {
2516
+ }
2517
+ for (const file of files) {
2518
+ if (workspace.writeBinaryFile) {
2519
+ await workspace.writeBinaryFile(file.rel, new Uint8Array(file.content));
2520
+ } else {
2521
+ await workspace.writeFile(file.rel, file.content.toString("utf-8"));
2522
+ }
2523
+ }
2524
+ await workspace.writeFile(markerPath, JSON.stringify({ hash, seededAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
2525
+ logger.info("[vercel-sandbox:mode] template seeded into workspace", {
2526
+ hash,
2527
+ fileCount: files.length
2528
+ });
2529
+ }
2530
+ async function ensureTemplateExecutables(sandbox) {
2531
+ if (!sandbox.runCommand) return;
2532
+ await sandbox.runCommand({
2533
+ cmd: "sh",
2534
+ args: ["-c", `chmod +x ${VERCEL_SANDBOX_REMOTE_ROOT}/.boring-agent/bin/* 2>/dev/null || true`]
2535
+ });
2536
+ }
2413
2537
  var DEFAULT_MODE_LOGGER = {
2414
2538
  info(message, metadata) {
2415
2539
  process.stderr.write(`${message} ${JSON.stringify(metadata)}
@@ -2521,17 +2645,14 @@ function createVercelSandboxModeAdapter(opts = {}) {
2521
2645
  onMutation: markDirty
2522
2646
  });
2523
2647
  await ensureVercelWorkspaceRoot(sandboxHandle);
2524
- if (ctx.templatePath && !tarballUrl) {
2525
- logger.info("[vercel-sandbox:mode] falling back to writeFiles for template", {
2526
- templatePath: ctx.templatePath
2527
- });
2528
- const files = await collectFiles(ctx.templatePath);
2529
- for (const file of files) {
2530
- await workspace.writeFile(file.rel, file.content.toString("utf-8"));
2648
+ if (ctx.templatePath) {
2649
+ if (!tarballUrl) {
2650
+ logger.info("[vercel-sandbox:mode] falling back to writeFiles for template", {
2651
+ templatePath: ctx.templatePath
2652
+ });
2531
2653
  }
2532
- logger.info("[vercel-sandbox:mode] writeFiles fallback complete", {
2533
- fileCount: files.length
2534
- });
2654
+ await seedTemplateIntoVercelWorkspace(workspace, ctx.templatePath, logger);
2655
+ await ensureTemplateExecutables(sandboxHandle);
2535
2656
  }
2536
2657
  const sandbox = createVercelSandboxExec(sandboxHandle, {
2537
2658
  onMutation: markDirty
@@ -2584,6 +2705,8 @@ import Fastify from "fastify";
2584
2705
  import { basename as basename2 } from "path";
2585
2706
 
2586
2707
  // src/server/harness/pi-coding-agent/createHarness.ts
2708
+ import { mkdir as mkdir9, writeFile as writeFile7 } from "fs/promises";
2709
+ import { extname, join as join6 } from "path";
2587
2710
  import {
2588
2711
  createAgentSession,
2589
2712
  SessionManager,
@@ -2595,6 +2718,86 @@ import {
2595
2718
  loadSkills
2596
2719
  } from "@mariozechner/pi-coding-agent";
2597
2720
 
2721
+ // src/server/logging.ts
2722
+ var SENSITIVE_KEYS = new Set([
2723
+ "apiKey",
2724
+ "api_key",
2725
+ "token",
2726
+ "secret",
2727
+ "password",
2728
+ "authorization",
2729
+ "cookie",
2730
+ "oidcToken",
2731
+ "accessToken",
2732
+ "refreshToken",
2733
+ "ANTHROPIC_API_KEY",
2734
+ "OPENAI_API_KEY",
2735
+ "VERCEL_OIDC_TOKEN",
2736
+ "VERCEL_TEAM_ID"
2737
+ ].map((key) => key.toLowerCase()));
2738
+ function isSensitiveKey(key) {
2739
+ return SENSITIVE_KEYS.has(key.toLowerCase());
2740
+ }
2741
+ function redactValue(key, value, seen) {
2742
+ if (key && isSensitiveKey(key) && value != null) return "***";
2743
+ if (value == null || typeof value !== "object") return value;
2744
+ if (value instanceof Date) return value;
2745
+ if (seen.has(value)) return "[Circular]";
2746
+ seen.add(value);
2747
+ if (Array.isArray(value)) {
2748
+ const out2 = value.map((item) => redactValue(void 0, item, seen));
2749
+ seen.delete(value);
2750
+ return out2;
2751
+ }
2752
+ const out = {};
2753
+ for (const [childKey, childValue] of Object.entries(value)) {
2754
+ out[childKey] = redactValue(childKey, childValue, seen);
2755
+ }
2756
+ seen.delete(value);
2757
+ return out;
2758
+ }
2759
+ function redact(fields) {
2760
+ const out = {};
2761
+ const seen = /* @__PURE__ */ new WeakSet();
2762
+ for (const [k, v] of Object.entries(fields)) {
2763
+ out[k] = redactValue(k, v, seen);
2764
+ }
2765
+ return out;
2766
+ }
2767
+ var verbose = typeof process !== "undefined" && process.env?.BORING_AGENT_VERBOSE === "1";
2768
+ function createLogger(prefix) {
2769
+ function emit(level, msg, fields) {
2770
+ const entry = {
2771
+ level,
2772
+ prefix,
2773
+ msg,
2774
+ ...fields ? redact(fields) : {},
2775
+ t: (/* @__PURE__ */ new Date()).toISOString()
2776
+ };
2777
+ if (level === "error") {
2778
+ console.error(JSON.stringify(entry));
2779
+ } else if (level === "warn") {
2780
+ console.warn(JSON.stringify(entry));
2781
+ } else {
2782
+ console.log(JSON.stringify(entry));
2783
+ }
2784
+ }
2785
+ return {
2786
+ debug(msg, fields) {
2787
+ if (verbose) emit("debug", msg, fields);
2788
+ },
2789
+ info(msg, fields) {
2790
+ emit("info", msg, fields);
2791
+ },
2792
+ warn(msg, fields) {
2793
+ emit("warn", msg, fields);
2794
+ },
2795
+ error(msg, fields) {
2796
+ emit("error", msg, fields);
2797
+ }
2798
+ };
2799
+ }
2800
+
2598
2801
  // src/server/harness/pi-coding-agent/tool-adapter.ts
2599
2802
  function adaptToolForPi(tool) {
2600
2803
  return {
@@ -3159,86 +3362,6 @@ function piMessagesToUIMessages(messages) {
3159
3362
  return result;
3160
3363
  }
3161
3364
 
3162
- // src/server/logging.ts
3163
- var SENSITIVE_KEYS = new Set([
3164
- "apiKey",
3165
- "api_key",
3166
- "token",
3167
- "secret",
3168
- "password",
3169
- "authorization",
3170
- "cookie",
3171
- "oidcToken",
3172
- "accessToken",
3173
- "refreshToken",
3174
- "ANTHROPIC_API_KEY",
3175
- "OPENAI_API_KEY",
3176
- "VERCEL_OIDC_TOKEN",
3177
- "VERCEL_TEAM_ID"
3178
- ].map((key) => key.toLowerCase()));
3179
- function isSensitiveKey(key) {
3180
- return SENSITIVE_KEYS.has(key.toLowerCase());
3181
- }
3182
- function redactValue(key, value, seen) {
3183
- if (key && isSensitiveKey(key) && value != null) return "***";
3184
- if (value == null || typeof value !== "object") return value;
3185
- if (value instanceof Date) return value;
3186
- if (seen.has(value)) return "[Circular]";
3187
- seen.add(value);
3188
- if (Array.isArray(value)) {
3189
- const out2 = value.map((item) => redactValue(void 0, item, seen));
3190
- seen.delete(value);
3191
- return out2;
3192
- }
3193
- const out = {};
3194
- for (const [childKey, childValue] of Object.entries(value)) {
3195
- out[childKey] = redactValue(childKey, childValue, seen);
3196
- }
3197
- seen.delete(value);
3198
- return out;
3199
- }
3200
- function redact(fields) {
3201
- const out = {};
3202
- const seen = /* @__PURE__ */ new WeakSet();
3203
- for (const [k, v] of Object.entries(fields)) {
3204
- out[k] = redactValue(k, v, seen);
3205
- }
3206
- return out;
3207
- }
3208
- var verbose = typeof process !== "undefined" && process.env?.BORING_AGENT_VERBOSE === "1";
3209
- function createLogger(prefix) {
3210
- function emit(level, msg, fields) {
3211
- const entry = {
3212
- level,
3213
- prefix,
3214
- msg,
3215
- ...fields ? redact(fields) : {},
3216
- t: (/* @__PURE__ */ new Date()).toISOString()
3217
- };
3218
- if (level === "error") {
3219
- console.error(JSON.stringify(entry));
3220
- } else if (level === "warn") {
3221
- console.warn(JSON.stringify(entry));
3222
- } else {
3223
- console.log(JSON.stringify(entry));
3224
- }
3225
- }
3226
- return {
3227
- debug(msg, fields) {
3228
- if (verbose) emit("debug", msg, fields);
3229
- },
3230
- info(msg, fields) {
3231
- emit("info", msg, fields);
3232
- },
3233
- warn(msg, fields) {
3234
- emit("warn", msg, fields);
3235
- },
3236
- error(msg, fields) {
3237
- emit("error", msg, fields);
3238
- }
3239
- };
3240
- }
3241
-
3242
3365
  // src/server/harness/pi-coding-agent/sessionTitle.ts
3243
3366
  var DEFAULT_SESSION_TITLE = "New session";
3244
3367
  var FALLBACK_PREFIX = "New chat";
@@ -3637,6 +3760,24 @@ async function applyRequestedSessionOptions(handle, input) {
3637
3760
  handle.piSession.setThinkingLevel(input.thinkingLevel);
3638
3761
  }
3639
3762
  }
3763
+ var log2 = createLogger("pi-harness");
3764
+ var DEFAULT_ATTACHMENT_DIR = "assets/images";
3765
+ function extForAttachment(filename, contentType) {
3766
+ const fromName = extname(filename).toLowerCase().replace(/^\./, "");
3767
+ if (/^[a-z0-9]{1,12}$/.test(fromName)) return fromName;
3768
+ if (contentType === "image/jpeg") return "jpg";
3769
+ if (contentType === "image/png") return "png";
3770
+ if (contentType === "image/gif") return "gif";
3771
+ if (contentType === "image/webp") return "webp";
3772
+ if (contentType === "image/svg+xml") return "svg";
3773
+ return "bin";
3774
+ }
3775
+ function basenameForAttachment(filename) {
3776
+ const base = filename.split("/").pop()?.split("\\").pop() ?? "image";
3777
+ const withoutExt = base.replace(/\.[^.]*$/, "");
3778
+ const safe = withoutExt.normalize("NFKD").replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
3779
+ return safe || "image";
3780
+ }
3640
3781
  function createPiCodingAgentHarness(opts) {
3641
3782
  const sessionStore = new PiSessionStore(opts.cwd);
3642
3783
  const piSessions = /* @__PURE__ */ new Map();
@@ -3733,10 +3874,17 @@ function createPiCodingAgentHarness(opts) {
3733
3874
  await originalDelete(ctx, sessionId);
3734
3875
  disposePiSession(sessionId);
3735
3876
  };
3877
+ const harnessFollowUpQueues = /* @__PURE__ */ new Map();
3736
3878
  return {
3737
3879
  id: "pi-coding-agent",
3738
3880
  placement: "server",
3739
3881
  sessions: sessionStore,
3882
+ followUp(sessionId, text, attachments) {
3883
+ harnessFollowUpQueues.set(sessionId, { message: text, attachments });
3884
+ },
3885
+ clearFollowUp(sessionId) {
3886
+ harnessFollowUpQueues.delete(sessionId);
3887
+ },
3740
3888
  /**
3741
3889
  * Pi exposes the resolved system prompt as a getter on AgentSession.
3742
3890
  * Sessions are created lazily on first sendMessage, so callers may see
@@ -3752,6 +3900,7 @@ function createPiCodingAgentHarness(opts) {
3752
3900
  input,
3753
3901
  ctx
3754
3902
  );
3903
+ harnessFollowUpQueues.delete(input.sessionId);
3755
3904
  const chunks = [];
3756
3905
  let done = false;
3757
3906
  let streamError = null;
@@ -3800,6 +3949,20 @@ function createPiCodingAgentHarness(opts) {
3800
3949
  return out;
3801
3950
  }
3802
3951
  let sawTextChunk = false;
3952
+ let inlineTurnIndex = 0;
3953
+ function namespaceInlinePartIds(input2) {
3954
+ if (inlineTurnIndex === 0) return input2;
3955
+ return input2.map((chunk2) => {
3956
+ const rec = chunk2;
3957
+ if ((rec.type === "text-start" || rec.type === "text-delta" || rec.type === "text-end" || rec.type === "reasoning-start" || rec.type === "reasoning-delta" || rec.type === "reasoning-end") && typeof rec.id === "string") {
3958
+ return { ...rec, id: `turn-${inlineTurnIndex}:${rec.id}` };
3959
+ }
3960
+ return chunk2;
3961
+ });
3962
+ }
3963
+ let pendingFollowUp;
3964
+ let promptSettled = false;
3965
+ let promptPromise = Promise.resolve();
3803
3966
  const unsubscribe = piSession.subscribe((event) => {
3804
3967
  if (event.type === "message_update") {
3805
3968
  const ame = event.assistantMessageEvent;
@@ -3819,11 +3982,11 @@ function createPiCodingAgentHarness(opts) {
3819
3982
  activeTools.delete(event.toolCallId);
3820
3983
  if (activeTools.size === 0) stopHeartbeat();
3821
3984
  }
3822
- let converted = dedupStartChunks(piEventToChunks(event));
3985
+ let converted = namespaceInlinePartIds(dedupStartChunks(piEventToChunks(event)));
3823
3986
  if (event.type === "message_update") {
3824
3987
  const ame = event.assistantMessageEvent;
3825
3988
  if (ame.type === "text_end" && typeof ame.content === "string" && ame.content.length > 0 && !textDeltaSeen.has(ame.contentIndex)) {
3826
- const id = String(ame.contentIndex);
3989
+ const id = inlineTurnIndex === 0 ? String(ame.contentIndex) : `turn-${inlineTurnIndex}:${ame.contentIndex}`;
3827
3990
  converted = [
3828
3991
  ...textStartSeen.has(ame.contentIndex) ? [] : [{ type: "text-start", id }],
3829
3992
  { type: "text-delta", id, delta: ame.content },
@@ -3848,10 +4011,11 @@ function createPiCodingAgentHarness(opts) {
3848
4011
  chunks.push({ type: "error", errorText });
3849
4012
  sawTextChunk = true;
3850
4013
  } else if (role === "assistant" && text.length > 0) {
4014
+ const id = inlineTurnIndex === 0 ? "0" : `turn-${inlineTurnIndex}:0`;
3851
4015
  chunks.push(
3852
- { type: "text-start", id: "0" },
3853
- { type: "text-delta", id: "0", delta: text },
3854
- { type: "text-end", id: "0" }
4016
+ { type: "text-start", id },
4017
+ { type: "text-delta", id, delta: text },
4018
+ { type: "text-end", id }
3855
4019
  );
3856
4020
  sawTextChunk = true;
3857
4021
  assistantText += text;
@@ -3868,30 +4032,95 @@ function createPiCodingAgentHarness(opts) {
3868
4032
  chunks.push({ type: "error", errorText });
3869
4033
  sawTextChunk = true;
3870
4034
  } else if (role === "assistant" && text.length > 0) {
4035
+ const id = inlineTurnIndex === 0 ? "0" : `turn-${inlineTurnIndex}:0`;
3871
4036
  chunks.push(
3872
- { type: "text-start", id: "0" },
3873
- { type: "text-delta", id: "0", delta: text },
3874
- { type: "text-end", id: "0" }
4037
+ { type: "text-start", id },
4038
+ { type: "text-delta", id, delta: text },
4039
+ { type: "text-end", id }
3875
4040
  );
3876
4041
  sawTextChunk = true;
3877
4042
  assistantText += text;
3878
4043
  }
3879
4044
  }
3880
- done = true;
3881
- stopHeartbeat();
4045
+ const followUp = harnessFollowUpQueues.get(input.sessionId);
4046
+ if (followUp !== void 0 && !ctx.abortSignal.aborted) {
4047
+ harnessFollowUpQueues.delete(input.sessionId);
4048
+ chunks.push({ type: "data-followup-consumed" });
4049
+ inlineTurnIndex += 1;
4050
+ sawTextChunk = false;
4051
+ textStartSeen.clear();
4052
+ textDeltaSeen.clear();
4053
+ assistantText = "";
4054
+ pendingFollowUp = followUp;
4055
+ } else {
4056
+ done = true;
4057
+ stopHeartbeat();
4058
+ }
3882
4059
  }
3883
4060
  if (wake) wake();
3884
4061
  });
3885
- let promptSettled = false;
3886
- const promptPromise = piSession.prompt(input.message).then(() => {
3887
- promptSettled = true;
3888
- }).catch((err) => {
3889
- promptSettled = true;
3890
- streamError = err;
3891
- done = true;
3892
- stopHeartbeat();
3893
- if (wake) wake();
3894
- });
4062
+ async function prepareTurn(message, attachments) {
4063
+ const initialImages = [];
4064
+ const savedPaths = [];
4065
+ const writeErrors = [];
4066
+ for (const a of attachments ?? []) {
4067
+ const match = a.url.match(/^data:(image\/[^;]+);base64,(.+)$/);
4068
+ if (!match) continue;
4069
+ const [, contentType, b64] = match;
4070
+ initialImages.push({ type: "image", mimeType: contentType, data: b64 });
4071
+ if (!ctx.workdir) {
4072
+ writeErrors.push(`${a.filename ?? "image"}: no workdir`);
4073
+ continue;
4074
+ }
4075
+ try {
4076
+ const bytes = Buffer.from(b64, "base64");
4077
+ const ext = extForAttachment(a.filename ?? "image", contentType);
4078
+ const base = basenameForAttachment(a.filename ?? "image");
4079
+ const unique = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
4080
+ const relPath = `${DEFAULT_ATTACHMENT_DIR}/${base}-${unique}.${ext}`;
4081
+ await mkdir9(join6(ctx.workdir, DEFAULT_ATTACHMENT_DIR), { recursive: true });
4082
+ await writeFile7(join6(ctx.workdir, relPath), bytes);
4083
+ savedPaths.push(relPath);
4084
+ } catch (err) {
4085
+ const msg = err instanceof Error ? err.message : String(err);
4086
+ log2.error("attachment write failed", { workdir: ctx.workdir, error: msg });
4087
+ writeErrors.push(`${a.filename ?? "image"}: ${msg}`);
4088
+ }
4089
+ }
4090
+ const attachmentNotes = [];
4091
+ if (savedPaths.length > 0) {
4092
+ attachmentNotes.push(`[Attached file(s) saved to workspace:
4093
+ ${savedPaths.map((p) => `- ${p}`).join("\n")}]`);
4094
+ }
4095
+ if (writeErrors.length > 0) {
4096
+ attachmentNotes.push(`[Warning: failed to save attachment(s) to workspace \u2014 ${writeErrors.join("; ")}. The image is available via vision only.]`);
4097
+ }
4098
+ return {
4099
+ message: attachmentNotes.length > 0 ? `${message}
4100
+
4101
+ ${attachmentNotes.join("\n")}` : message,
4102
+ promptOpts: initialImages.length > 0 ? { images: initialImages } : void 0
4103
+ };
4104
+ }
4105
+ async function startTurn(message, attachments) {
4106
+ promptSettled = false;
4107
+ const prepared = await prepareTurn(message, attachments);
4108
+ return piSession.prompt(prepared.message, prepared.promptOpts).then(() => {
4109
+ promptSettled = true;
4110
+ if (pendingFollowUp !== void 0 && !ctx.abortSignal.aborted) {
4111
+ const next = pendingFollowUp;
4112
+ pendingFollowUp = void 0;
4113
+ promptPromise = startTurn(next.message, next.attachments);
4114
+ }
4115
+ }).catch((err) => {
4116
+ promptSettled = true;
4117
+ streamError = err;
4118
+ done = true;
4119
+ stopHeartbeat();
4120
+ if (wake) wake();
4121
+ });
4122
+ }
4123
+ promptPromise = startTurn(input.message, input.attachments);
3895
4124
  const onAbort = () => {
3896
4125
  if (promptSettled) {
3897
4126
  streamError = new Error("Aborted");
@@ -3939,11 +4168,11 @@ function createPiCodingAgentHarness(opts) {
3939
4168
 
3940
4169
  // src/server/harness/pi-coding-agent/pluginLoader.ts
3941
4170
  import { readdir as readdir5, stat as stat5, readFile as readFile7 } from "fs/promises";
3942
- import { join as join6, extname, resolve as resolve4, sep as sep3 } from "path";
4171
+ import { join as join7, extname as extname2, resolve as resolve4, sep as sep3 } from "path";
3943
4172
  import { homedir as homedir4 } from "os";
3944
4173
  import { pathToFileURL } from "url";
3945
4174
  var VALID_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".mjs"]);
3946
- var GLOBAL_DIR = join6(homedir4(), ".pi", "agent", "extensions");
4175
+ var GLOBAL_DIR = join7(homedir4(), ".pi", "agent", "extensions");
3947
4176
  var LOCAL_DIR = ".pi/extensions";
3948
4177
  var EXTENSIONS_JSON = ".pi/extensions.json";
3949
4178
  async function dirExists(path4) {
@@ -3965,7 +4194,7 @@ async function fileExists2(path4) {
3965
4194
  async function discoverFromDir(dir, source) {
3966
4195
  if (!await dirExists(dir)) return [];
3967
4196
  const entries = await readdir5(dir);
3968
- return entries.filter((e) => VALID_EXTENSIONS.has(extname(e))).map((e) => ({ path: join6(dir, e), source }));
4197
+ return entries.filter((e) => VALID_EXTENSIONS.has(extname2(e))).map((e) => ({ path: join7(dir, e), source }));
3969
4198
  }
3970
4199
  function extractTools(mod) {
3971
4200
  const tools = [];
@@ -3995,17 +4224,17 @@ async function loadModule(filePath, importFn) {
3995
4224
  return extractTools(mod);
3996
4225
  }
3997
4226
  async function discoverNpmPlugins(cwd) {
3998
- const nodeModulesDir = join6(cwd, "node_modules");
4227
+ const nodeModulesDir = join7(cwd, "node_modules");
3999
4228
  if (!await dirExists(nodeModulesDir)) return [];
4000
4229
  try {
4001
4230
  const entries = await readdir5(nodeModulesDir);
4002
- return entries.filter((e) => e.startsWith("pi-plugin-")).map((e) => join6(nodeModulesDir, e));
4231
+ return entries.filter((e) => e.startsWith("pi-plugin-")).map((e) => join7(nodeModulesDir, e));
4003
4232
  } catch {
4004
4233
  return [];
4005
4234
  }
4006
4235
  }
4007
4236
  async function loadNpmPlugin(pkgDir, importFn) {
4008
- const pkgJsonPath = join6(pkgDir, "package.json");
4237
+ const pkgJsonPath = join7(pkgDir, "package.json");
4009
4238
  if (!await fileExists2(pkgJsonPath)) return [];
4010
4239
  const pkgJson = JSON.parse(await readFile7(pkgJsonPath, "utf-8"));
4011
4240
  const main = pkgJson.main ?? "index.js";
@@ -4017,7 +4246,7 @@ async function loadNpmPlugin(pkgDir, importFn) {
4017
4246
  return loadModule(resolvedMain, importFn);
4018
4247
  }
4019
4248
  async function loadExtensionsJson(cwd) {
4020
- const configPath = join6(cwd, EXTENSIONS_JSON);
4249
+ const configPath = join7(cwd, EXTENSIONS_JSON);
4021
4250
  if (!await fileExists2(configPath)) return null;
4022
4251
  try {
4023
4252
  const raw = await readFile7(configPath, "utf-8");
@@ -4035,7 +4264,7 @@ async function loadPlugins(options) {
4035
4264
  const globals = await discoverFromDir(GLOBAL_DIR, "global");
4036
4265
  candidates.push(...globals);
4037
4266
  }
4038
- const localDir = join6(options.cwd, LOCAL_DIR);
4267
+ const localDir = join7(options.cwd, LOCAL_DIR);
4039
4268
  const locals = await discoverFromDir(localDir, "local");
4040
4269
  candidates.push(...locals);
4041
4270
  const npmDirs = await discoverNpmPlugins(options.cwd);
@@ -4045,7 +4274,7 @@ async function loadPlugins(options) {
4045
4274
  const config = await loadExtensionsJson(options.cwd);
4046
4275
  if (config?.npm) {
4047
4276
  for (const pkg of config.npm) {
4048
- const pkgDir = join6(options.cwd, "node_modules", pkg);
4277
+ const pkgDir = join7(options.cwd, "node_modules", pkg);
4049
4278
  if (await dirExists(pkgDir)) {
4050
4279
  const already = candidates.some((c) => c.path === pkgDir);
4051
4280
  if (!already) {
@@ -4094,7 +4323,7 @@ import {
4094
4323
 
4095
4324
  // src/server/tools/operations/bound.ts
4096
4325
  import { constants as constants3 } from "fs";
4097
- import { access as access3, lstat as lstat3, mkdir as mkdir9, readFile as readFile8, readdir as readdir6, readlink, realpath as realpath2, stat as stat6, writeFile as writeFile7 } from "fs/promises";
4326
+ import { access as access3, lstat as lstat3, mkdir as mkdir10, readFile as readFile8, readdir as readdir6, readlink, realpath as realpath2, stat as stat6, writeFile as writeFile8 } from "fs/promises";
4098
4327
  import { dirname as dirname6, isAbsolute as isAbsolute4, relative as relative4, resolve as resolve5 } from "path";
4099
4328
  function toPosixPath(value) {
4100
4329
  return value.split("\\").join("/");
@@ -4232,12 +4461,12 @@ function boundFs(workspaceRoot) {
4232
4461
  const write = {
4233
4462
  async writeFile(absolutePath, content) {
4234
4463
  await assertWithinWorkspace(workspaceRoot, absolutePath);
4235
- await mkdir9(dirname6(absolutePath), { recursive: true });
4236
- await writeFile7(absolutePath, content);
4464
+ await mkdir10(dirname6(absolutePath), { recursive: true });
4465
+ await writeFile8(absolutePath, content);
4237
4466
  },
4238
4467
  async mkdir(dir) {
4239
4468
  await assertWithinWorkspace(workspaceRoot, dir);
4240
- await mkdir9(dir, { recursive: true });
4469
+ await mkdir10(dir, { recursive: true });
4241
4470
  }
4242
4471
  };
4243
4472
  const edit = {
@@ -4247,7 +4476,7 @@ function boundFs(workspaceRoot) {
4247
4476
  },
4248
4477
  async writeFile(absolutePath, content) {
4249
4478
  await assertWithinWorkspace(workspaceRoot, absolutePath);
4250
- await writeFile7(absolutePath, content);
4479
+ await writeFile8(absolutePath, content);
4251
4480
  },
4252
4481
  async access(absolutePath) {
4253
4482
  await assertWithinWorkspace(workspaceRoot, absolutePath);
@@ -4920,6 +5149,13 @@ function healthRoutes(app, opts, done) {
4920
5149
  }
4921
5150
 
4922
5151
  // src/server/http/routes/file.ts
5152
+ import { dirname as dirname7, extname as extname3, relative as relative7 } from "path/posix";
5153
+ var BORING_SETTINGS_PATH = ".boring/settings";
5154
+ var DEFAULT_MARKDOWN_IMAGE_UPLOAD_DIR = "assets/images";
5155
+ var MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
5156
+ function defaultWorkspaceSettings() {
5157
+ return { markdown: { imageUploadDir: DEFAULT_MARKDOWN_IMAGE_UPLOAD_DIR } };
5158
+ }
4923
5159
  function isPathValidationError(err) {
4924
5160
  return err instanceof Error && typeof err.reason === "string";
4925
5161
  }
@@ -4965,12 +5201,108 @@ function requireStringParam(value, field, reply) {
4965
5201
  }
4966
5202
  return value;
4967
5203
  }
5204
+ function parseWorkspaceSettings(raw) {
5205
+ try {
5206
+ const parsed = JSON.parse(raw);
5207
+ const dir = parsed?.markdown?.imageUploadDir;
5208
+ return {
5209
+ ...parsed,
5210
+ markdown: {
5211
+ ...parsed.markdown ?? {},
5212
+ imageUploadDir: typeof dir === "string" && dir.trim() ? dir.trim() : DEFAULT_MARKDOWN_IMAGE_UPLOAD_DIR
5213
+ }
5214
+ };
5215
+ } catch {
5216
+ return defaultWorkspaceSettings();
5217
+ }
5218
+ }
5219
+ async function readWorkspaceSettings(workspace) {
5220
+ try {
5221
+ return parseWorkspaceSettings(await workspace.readFile(BORING_SETTINGS_PATH));
5222
+ } catch (error) {
5223
+ const code = error?.code;
5224
+ if (code === "ENOENT") return defaultWorkspaceSettings();
5225
+ throw error;
5226
+ }
5227
+ }
5228
+ function normalizeUploadDir(value) {
5229
+ if (typeof value !== "string") return null;
5230
+ const dir = value.trim().replace(/^\.\/+/, "").replace(/\/+/g, "/");
5231
+ if (!dir || dir.includes("\0") || dir.startsWith("/") || dir.split("/").includes("..")) return null;
5232
+ return dir.replace(/\/+$/, "");
5233
+ }
5234
+ function extForUpload(filename, contentType) {
5235
+ const fromName = extname3(filename).toLowerCase().replace(/^\./, "");
5236
+ if (/^[a-z0-9]{1,12}$/.test(fromName)) return fromName;
5237
+ if (contentType === "image/jpeg") return "jpg";
5238
+ if (contentType === "image/png") return "png";
5239
+ if (contentType === "image/gif") return "gif";
5240
+ if (contentType === "image/webp") return "webp";
5241
+ if (contentType === "image/svg+xml") return "svg";
5242
+ return "bin";
5243
+ }
5244
+ function basenameForUpload(filename) {
5245
+ const base = filename.split("/").pop()?.split("\\").pop() ?? "image";
5246
+ const withoutExt = base.replace(/\.[^.]*$/, "");
5247
+ const safe = withoutExt.normalize("NFKD").replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
5248
+ return safe || "image";
5249
+ }
5250
+ function markdownUrlFor(sourcePath, assetPath) {
5251
+ if (!sourcePath) return assetPath;
5252
+ const fromDir = dirname7(sourcePath.replace(/\\/g, "/"));
5253
+ const rel = relative7(fromDir === "." ? "" : fromDir, assetPath);
5254
+ return rel && !rel.startsWith(".") ? rel : rel || assetPath;
5255
+ }
5256
+ function contentTypeForPath(path4) {
5257
+ switch (extname3(path4).toLowerCase()) {
5258
+ case ".avif":
5259
+ return "image/avif";
5260
+ case ".gif":
5261
+ return "image/gif";
5262
+ case ".jpg":
5263
+ case ".jpeg":
5264
+ return "image/jpeg";
5265
+ case ".png":
5266
+ return "image/png";
5267
+ case ".svg":
5268
+ return "image/svg+xml";
5269
+ case ".webp":
5270
+ return "image/webp";
5271
+ case ".pdf":
5272
+ return "application/pdf";
5273
+ default:
5274
+ return "application/octet-stream";
5275
+ }
5276
+ }
4968
5277
  function fileRoutes(app, opts, done) {
4969
5278
  async function resolveWorkspace(request) {
4970
5279
  if (opts.getWorkspace) return await opts.getWorkspace(request);
4971
5280
  if (opts.workspace) return opts.workspace;
4972
5281
  throw new Error("file route requires workspace or getWorkspace");
4973
5282
  }
5283
+ app.get("/api/v1/files/raw", async (request, reply) => {
5284
+ const query = request.query;
5285
+ const path4 = requireStringParam(query.path, "path", reply);
5286
+ if (path4 === null) return;
5287
+ try {
5288
+ const workspace = await resolveWorkspace(request);
5289
+ if (!workspace.readBinaryFile) {
5290
+ return reply.code(501).send({
5291
+ error: { code: ERROR_CODE_INTERNAL, message: "workspace does not support binary reads" }
5292
+ });
5293
+ }
5294
+ const stat7 = await workspace.stat(path4);
5295
+ if (stat7.kind !== "file") {
5296
+ return reply.code(400).send({
5297
+ error: { code: ERROR_CODE_VALIDATION_ERROR, message: "path is not a file", field: "path" }
5298
+ });
5299
+ }
5300
+ const bytes = await workspace.readBinaryFile(path4);
5301
+ return reply.header("content-type", contentTypeForPath(path4)).header("content-length", String(bytes.byteLength)).header("cache-control", "no-store").send(Buffer.from(bytes));
5302
+ } catch (err) {
5303
+ return classifyError(err, reply, "file");
5304
+ }
5305
+ });
4974
5306
  app.get("/api/v1/files", async (request, reply) => {
4975
5307
  const query = request.query;
4976
5308
  const path4 = requireStringParam(query.path, "path", reply);
@@ -5041,6 +5373,87 @@ function fileRoutes(app, opts, done) {
5041
5373
  return classifyError(err, reply, "file");
5042
5374
  }
5043
5375
  });
5376
+ app.post("/api/v1/files/upload", async (request, reply) => {
5377
+ const body = request.body;
5378
+ const filename = requireStringParam(body?.filename, "filename", reply);
5379
+ if (filename === null) return;
5380
+ const contentBase64 = requireStringParam(body?.contentBase64, "contentBase64", reply);
5381
+ if (contentBase64 === null) return;
5382
+ const contentType = typeof body.contentType === "string" ? body.contentType.trim().toLowerCase() : "";
5383
+ if (!contentType.startsWith("image/")) {
5384
+ return reply.code(400).send({
5385
+ error: { code: ERROR_CODE_VALIDATION_ERROR, message: "contentType must be an image/* MIME type", field: "contentType" }
5386
+ });
5387
+ }
5388
+ try {
5389
+ const workspace = await resolveWorkspace(request);
5390
+ const settings = await readWorkspaceSettings(workspace);
5391
+ const dir = normalizeUploadDir(body.directory) ?? normalizeUploadDir(settings.markdown?.imageUploadDir) ?? DEFAULT_MARKDOWN_IMAGE_UPLOAD_DIR;
5392
+ const ext = extForUpload(filename, contentType);
5393
+ const base = basenameForUpload(filename);
5394
+ const unique = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
5395
+ const path4 = `${dir}/${base}-${unique}.${ext}`;
5396
+ const bytes = Buffer.from(contentBase64, "base64");
5397
+ if (bytes.byteLength === 0 || bytes.byteLength > MAX_UPLOAD_BYTES) {
5398
+ return reply.code(400).send({
5399
+ error: { code: ERROR_CODE_VALIDATION_ERROR, message: `upload must be 1 byte to ${MAX_UPLOAD_BYTES} bytes`, field: "contentBase64" }
5400
+ });
5401
+ }
5402
+ await workspace.mkdir(dir, { recursive: true });
5403
+ const stat7 = workspace.writeBinaryFileWithStat ? await workspace.writeBinaryFileWithStat(path4, bytes) : await (async () => {
5404
+ if (!workspace.writeBinaryFile) {
5405
+ throw new Error("workspace does not support binary uploads");
5406
+ }
5407
+ await workspace.writeBinaryFile(path4, bytes);
5408
+ return await workspace.stat(path4);
5409
+ })();
5410
+ const sourcePath = typeof body.sourcePath === "string" && !body.sourcePath.includes("\0") ? body.sourcePath : null;
5411
+ return {
5412
+ ok: true,
5413
+ path: path4,
5414
+ markdownUrl: markdownUrlFor(sourcePath, path4),
5415
+ mtimeMs: stat7.kind === "file" ? stat7.mtimeMs : void 0
5416
+ };
5417
+ } catch (err) {
5418
+ return classifyError(err, reply, "upload");
5419
+ }
5420
+ });
5421
+ app.get("/api/v1/workspace-settings", async (request, reply) => {
5422
+ try {
5423
+ const workspace = await resolveWorkspace(request);
5424
+ return { settings: await readWorkspaceSettings(workspace) };
5425
+ } catch (err) {
5426
+ return classifyError(err, reply, "settings");
5427
+ }
5428
+ });
5429
+ app.put("/api/v1/workspace-settings", async (request, reply) => {
5430
+ const body = request.body;
5431
+ const incoming = body?.settings ?? body;
5432
+ const markdown = incoming.markdown ?? {};
5433
+ const imageUploadDir = normalizeUploadDir(markdown.imageUploadDir);
5434
+ if (!imageUploadDir) {
5435
+ return reply.code(400).send({
5436
+ error: { code: ERROR_CODE_INVALID_PATH, message: "markdown.imageUploadDir must be a relative workspace path", field: "markdown.imageUploadDir" }
5437
+ });
5438
+ }
5439
+ try {
5440
+ const workspace = await resolveWorkspace(request);
5441
+ const current = await readWorkspaceSettings(workspace);
5442
+ const next = {
5443
+ ...current,
5444
+ markdown: {
5445
+ ...current.markdown ?? {},
5446
+ imageUploadDir
5447
+ }
5448
+ };
5449
+ await workspace.mkdir(".boring", { recursive: true });
5450
+ await workspace.writeFile(BORING_SETTINGS_PATH, `${JSON.stringify(next, null, 2)}
5451
+ `);
5452
+ return { settings: next };
5453
+ } catch (err) {
5454
+ return classifyError(err, reply, "settings");
5455
+ }
5456
+ });
5044
5457
  app.delete("/api/v1/files", async (request, reply) => {
5045
5458
  const query = request.query;
5046
5459
  const path4 = requireStringParam(query.path, "path", reply);
@@ -5568,7 +5981,7 @@ function chatRoutes(app, opts, done) {
5568
5981
  "/api/v1/agent/chat",
5569
5982
  { preHandler: validateBody },
5570
5983
  async (request, reply) => {
5571
- const { sessionId, message, model, thinkingLevel } = request.body;
5984
+ const { sessionId, message, model, thinkingLevel, attachments } = request.body;
5572
5985
  const turnId = randomUUID3();
5573
5986
  request.log.info({ sessionId, turnId, model, thinkingLevel }, "[chat] start");
5574
5987
  const runtime = await resolveRuntime(request);
@@ -5587,7 +6000,7 @@ function chatRoutes(app, opts, done) {
5587
6000
  const buf = buffers.create(sessionId, turnId);
5588
6001
  try {
5589
6002
  const chunks = runtime.harness.sendMessage(
5590
- { sessionId, message, model, thinkingLevel },
6003
+ { sessionId, message, model, thinkingLevel, attachments },
5591
6004
  ctx
5592
6005
  );
5593
6006
  const stream = createUIMessageStream({
@@ -5719,6 +6132,36 @@ function chatRoutes(app, opts, done) {
5719
6132
  }
5720
6133
  }
5721
6134
  );
6135
+ app.post(
6136
+ "/api/v1/agent/chat/:sessionId/followup",
6137
+ async (request, reply) => {
6138
+ const { sessionId } = request.params;
6139
+ const body = request.body;
6140
+ if (typeof body?.message !== "string" || body.message.length === 0) {
6141
+ return reply.code(400).send({
6142
+ error: { code: ERROR_CODE_VALIDATION_ERROR, message: "message is required" }
6143
+ });
6144
+ }
6145
+ const parsedAttachments = chatBodySchema.shape.attachments.safeParse(body.attachments);
6146
+ if (!parsedAttachments.success) {
6147
+ return reply.code(400).send({
6148
+ error: { code: ERROR_CODE_VALIDATION_ERROR, message: "attachments is invalid" }
6149
+ });
6150
+ }
6151
+ const runtime = await resolveRuntime(request);
6152
+ runtime.harness.followUp?.(sessionId, body.message, parsedAttachments.data);
6153
+ return reply.code(202).send({ queued: true });
6154
+ }
6155
+ );
6156
+ app.delete(
6157
+ "/api/v1/agent/chat/:sessionId/followup",
6158
+ async (request, reply) => {
6159
+ const { sessionId } = request.params;
6160
+ const runtime = await resolveRuntime(request);
6161
+ runtime.harness.clearFollowUp?.(sessionId);
6162
+ return reply.code(204).send();
6163
+ }
6164
+ );
5722
6165
  app.put(
5723
6166
  "/api/v1/agent/chat/:sessionId/messages",
5724
6167
  async (request, reply) => {
@@ -5806,7 +6249,11 @@ function skillsRoutes(app, opts, done) {
5806
6249
  return reply.code(200).send({ skills: cached.skills });
5807
6250
  }
5808
6251
  try {
5809
- const result = loadSkills2({ cwd: opts.workspaceRoot, includeDefaults: true });
6252
+ const result = loadSkills2({
6253
+ cwd: opts.workspaceRoot,
6254
+ skillPaths: opts.additionalSkillPaths ?? [],
6255
+ includeDefaults: true
6256
+ });
5810
6257
  const skills = result.skills.map((s) => ({
5811
6258
  name: s.name,
5812
6259
  description: s.description
@@ -6221,7 +6668,7 @@ async function createAgentApp(opts = {}) {
6221
6668
  const workspaceRoot = opts.workspaceRoot ?? process.cwd();
6222
6669
  const sessionId = opts.sessionId ?? DEFAULT_SESSION_ID;
6223
6670
  const templatePath = opts.templatePath ?? getEnv("BORING_AGENT_TEMPLATE_PATH");
6224
- const app = Fastify({ logger: opts.logger ?? true });
6671
+ const app = Fastify({ logger: opts.logger ?? true, bodyLimit: 16 * 1024 * 1024 });
6225
6672
  const resolvedMode = opts.mode ?? autoDetectMode();
6226
6673
  const runtimeBundle = await resolveMode(resolvedMode).create({
6227
6674
  workspaceRoot,
@@ -6289,7 +6736,10 @@ async function createAgentApp(opts = {}) {
6289
6736
  });
6290
6737
  await app.register(systemPromptRoutes, { harness });
6291
6738
  await app.register(modelsRoutes);
6292
- await app.register(skillsRoutes, { workspaceRoot });
6739
+ await app.register(skillsRoutes, {
6740
+ workspaceRoot,
6741
+ additionalSkillPaths: opts.resourceLoaderOptions?.additionalSkillPaths
6742
+ });
6293
6743
  await app.register(sessionChangesRoutes, { tracker: sessionChangesTracker });
6294
6744
  await app.register(catalogRoutes, { tools });
6295
6745
  await app.register(readyStatusRoutes, { tracker: readyTracker });
@@ -6350,6 +6800,113 @@ function mergeTools(options) {
6350
6800
  return [...merged.values()];
6351
6801
  }
6352
6802
 
6803
+ // src/server/tools/upload/index.ts
6804
+ import { readFile as readFile9 } from "fs/promises";
6805
+ import { extname as extname4, join as join8 } from "path";
6806
+ var DEFAULT_UPLOAD_DIR = "assets/images";
6807
+ var MAX_UPLOAD_BYTES2 = 10 * 1024 * 1024;
6808
+ function contentTypeFromExt(path4) {
6809
+ switch (extname4(path4).toLowerCase()) {
6810
+ case ".jpg":
6811
+ case ".jpeg":
6812
+ return "image/jpeg";
6813
+ case ".png":
6814
+ return "image/png";
6815
+ case ".gif":
6816
+ return "image/gif";
6817
+ case ".webp":
6818
+ return "image/webp";
6819
+ case ".svg":
6820
+ return "image/svg+xml";
6821
+ case ".avif":
6822
+ return "image/avif";
6823
+ default:
6824
+ return "application/octet-stream";
6825
+ }
6826
+ }
6827
+ function extForUpload2(filename, contentType) {
6828
+ const fromName = extname4(filename).toLowerCase().replace(/^\./, "");
6829
+ if (/^[a-z0-9]{1,12}$/.test(fromName)) return fromName;
6830
+ if (contentType === "image/jpeg") return "jpg";
6831
+ if (contentType === "image/png") return "png";
6832
+ if (contentType === "image/gif") return "gif";
6833
+ if (contentType === "image/webp") return "webp";
6834
+ if (contentType === "image/svg+xml") return "svg";
6835
+ return "bin";
6836
+ }
6837
+ function basenameForUpload2(filename) {
6838
+ const base = filename.split("/").pop()?.split("\\").pop() ?? "image";
6839
+ const withoutExt = base.replace(/\.[^.]*$/, "");
6840
+ const safe = withoutExt.normalize("NFKD").replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
6841
+ return safe || "image";
6842
+ }
6843
+ function buildUploadAgentTools(bundle) {
6844
+ const { workspace } = bundle;
6845
+ const cwd = workspace.root;
6846
+ return [
6847
+ {
6848
+ name: "upload_file",
6849
+ description: "Copy a workspace file into artifact storage (assets/images by default) and return its workspace-relative path. Use this when you want to embed an image in markdown or make a generated file accessible as a stable artifact. The returned path can be used directly in markdown image syntax: ![](returned-path)",
6850
+ parameters: {
6851
+ type: "object",
6852
+ properties: {
6853
+ path: {
6854
+ type: "string",
6855
+ description: 'Workspace-relative path of the source file (e.g. "chart.png" or "output/plot.png")'
6856
+ },
6857
+ directory: {
6858
+ type: "string",
6859
+ description: `Destination directory inside the workspace. Defaults to "${DEFAULT_UPLOAD_DIR}".`
6860
+ }
6861
+ },
6862
+ required: ["path"],
6863
+ additionalProperties: false
6864
+ },
6865
+ async execute(input) {
6866
+ const filePath = typeof input.path === "string" ? input.path.trim() : "";
6867
+ if (!filePath || filePath.includes("\0") || filePath.startsWith("/") || filePath.split("/").includes("..")) {
6868
+ return { content: [{ type: "text", text: "invalid path" }], isError: true };
6869
+ }
6870
+ const rawDir = typeof input.directory === "string" ? input.directory.trim() : "";
6871
+ const dir = rawDir ? rawDir.replace(/^\.\/+/, "").replace(/\/+$/, "") : DEFAULT_UPLOAD_DIR;
6872
+ try {
6873
+ const bytes = await readFile9(join8(cwd, filePath));
6874
+ if (bytes.byteLength === 0 || bytes.byteLength > MAX_UPLOAD_BYTES2) {
6875
+ return {
6876
+ content: [{ type: "text", text: `file must be between 1 byte and ${MAX_UPLOAD_BYTES2} bytes` }],
6877
+ isError: true
6878
+ };
6879
+ }
6880
+ const contentType = contentTypeFromExt(filePath);
6881
+ const ext = extForUpload2(filePath, contentType);
6882
+ const base = basenameForUpload2(filePath);
6883
+ const unique = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
6884
+ const destPath = `${dir}/${base}-${unique}.${ext}`;
6885
+ await workspace.mkdir(dir, { recursive: true });
6886
+ if (workspace.writeBinaryFile) {
6887
+ await workspace.writeBinaryFile(destPath, bytes);
6888
+ } else {
6889
+ return {
6890
+ content: [{ type: "text", text: "workspace does not support binary file writes" }],
6891
+ isError: true
6892
+ };
6893
+ }
6894
+ return {
6895
+ content: [{ type: "text", text: `Uploaded to ${destPath}` }],
6896
+ isError: false,
6897
+ details: { path: destPath }
6898
+ };
6899
+ } catch (err) {
6900
+ return {
6901
+ content: [{ type: "text", text: err instanceof Error ? err.message : "upload failed" }],
6902
+ isError: true
6903
+ };
6904
+ }
6905
+ }
6906
+ }
6907
+ ];
6908
+ }
6909
+
6353
6910
  // src/server/registerAgentRoutes.ts
6354
6911
  var DEFAULT_VERSION2 = "0.1.0-dev";
6355
6912
  var DEFAULT_WORKSPACE_ID3 = "default";
@@ -6420,16 +6977,18 @@ var registerAgentRoutes = async (app, opts) => {
6420
6977
  key: `${resolvedMode}:${workspaceId}:${root}`
6421
6978
  };
6422
6979
  }
6423
- async function createRuntimeBinding(workspaceId, root) {
6980
+ async function createRuntimeBinding(workspaceId, root, request) {
6981
+ const scopedTemplatePath = opts.getTemplatePath ? await opts.getTemplatePath({ workspaceId, workspaceRoot: root, request }) : templatePath;
6424
6982
  const runtimeBundle = await modeAdapter.create({
6425
6983
  workspaceRoot: root,
6426
6984
  sessionId: workspaceId,
6427
6985
  workspaceId,
6428
- templatePath
6986
+ templatePath: scopedTemplatePath
6429
6987
  });
6430
6988
  const standardTools = [
6431
6989
  ...buildHarnessAgentTools(runtimeBundle),
6432
- ...buildFilesystemAgentTools(runtimeBundle)
6990
+ ...buildFilesystemAgentTools(runtimeBundle),
6991
+ ...buildUploadAgentTools(runtimeBundle)
6433
6992
  ];
6434
6993
  const pluginTools = [];
6435
6994
  if (resolvedMode !== "vercel-sandbox") {
@@ -6494,7 +7053,7 @@ var registerAgentRoutes = async (app, opts) => {
6494
7053
  await existing
6495
7054
  );
6496
7055
  }
6497
- const created = createRuntimeBinding(workspaceId, scope.root);
7056
+ const created = createRuntimeBinding(workspaceId, scope.root, request);
6498
7057
  runtimeBindings.set(scope.key, created);
6499
7058
  try {
6500
7059
  return await ensureRuntimeBindingReady(
@@ -6631,7 +7190,10 @@ var registerAgentRoutes = async (app, opts) => {
6631
7190
  getHarness: async (request) => (await getBindingForRequest(request)).harness
6632
7191
  });
6633
7192
  await app.register(modelsRoutes);
6634
- await app.register(skillsRoutes, { workspaceRoot });
7193
+ await app.register(skillsRoutes, {
7194
+ workspaceRoot,
7195
+ additionalSkillPaths: opts.resourceLoaderOptions?.additionalSkillPaths
7196
+ });
6635
7197
  await app.register(sessionChangesRoutes, { tracker: sessionChangesTracker });
6636
7198
  await app.register(
6637
7199
  catalogRoutes,