offsite 0.0.23 → 0.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/README.md +10 -0
  2. package/apps/server/dist/main.mjs +673 -583
  3. package/apps/server/dist/main.mjs.map +1 -1
  4. package/apps/web/dist/assets/HugeiconsIcon-eRbI5N8r.js +1 -0
  5. package/apps/web/dist/assets/audio-player-DPpDuqFI.js +1 -0
  6. package/apps/web/dist/assets/{dist-BYj5XneA.js → dist-BxKL_-7_.js} +1 -1
  7. package/apps/web/dist/assets/{image-viewer-DnE3hPpr.js → image-viewer-CXSBWHCX.js} +1 -1
  8. package/apps/web/dist/assets/index-CjjE4vH0.css +2 -0
  9. package/apps/web/dist/assets/index-_B37DCJA.js +781 -0
  10. package/apps/web/dist/assets/{preview-dialog-CApdSumg.js → preview-dialog-Cgeg3W6o.js} +12 -12
  11. package/apps/web/dist/assets/react-BlUBLzv4.js +1416 -0
  12. package/apps/web/dist/assets/{tooltip-DZsSQhD8.js → tooltip-D08x1wZY.js} +1 -1
  13. package/apps/web/dist/assets/utils-PtDt7m8t.js +1 -0
  14. package/apps/web/dist/assets/video-player-DDu2cDQE.js +1 -0
  15. package/apps/web/dist/assets/video-player-DlVZx3V3.js +1 -0
  16. package/apps/web/dist/assets/{video-viewer-B404MUMw.js → video-viewer-Cr1-BYtu.js} +1 -1
  17. package/apps/web/dist/assets/{zoomable-image-CjNpa0bY.js → zoomable-image-CrpD_Zwi.js} +1 -1
  18. package/apps/web/dist/assets/zoomable-image-Fkv4fx_U.js +1 -0
  19. package/apps/web/dist/index.html +6 -5
  20. package/apps/web/dist/sw.js +1 -1
  21. package/package.json +1 -1
  22. package/apps/web/dist/assets/HugeiconsIcon-B46TDTdz.js +0 -1
  23. package/apps/web/dist/assets/index-DMsagQXD.css +0 -2
  24. package/apps/web/dist/assets/index-W2nwhfc9.js +0 -783
  25. package/apps/web/dist/assets/video-player-05lO0mz3.js +0 -1416
  26. package/apps/web/dist/assets/video-player-DGtpeSpK.js +0 -1
  27. package/apps/web/dist/assets/zoomable-image-CzRbnhdc.js +0 -1
@@ -21,8 +21,8 @@ import { DatabaseSync } from "node:sqlite";
21
21
  import { drizzle } from "drizzle-orm/node-sqlite";
22
22
  import { migrate } from "drizzle-orm/node-sqlite/migrator";
23
23
  import { integer, snakeCase, text } from "drizzle-orm/sqlite-core";
24
- import webPush from "web-push";
25
24
  import { promisify } from "node:util";
25
+ import webPush from "web-push";
26
26
  import { spawn as spawn$1 } from "@lydell/node-pty";
27
27
  //#region src/constants.ts
28
28
  const deprecatedModels = {
@@ -818,6 +818,9 @@ const platformDirs = z.strictObject({
818
818
  linux: posixDir,
819
819
  windows: z.string().refine((value) => path.win32.isAbsolute(value), "must be an absolute path").nullable().default(null)
820
820
  }).prefault({});
821
+ function workingDir(isAbsolute) {
822
+ return z.string().refine((value) => value === "~" || value.startsWith("~/") || isAbsolute(value), "must be an absolute path or start with ~").nullable().default(null);
823
+ }
821
824
  function accountSchema(provider) {
822
825
  return z.strictObject({
823
826
  id: z.string(),
@@ -830,6 +833,11 @@ function accountSchema(provider) {
830
833
  }
831
834
  const configSchema = z.strictObject({
832
835
  claudeBaseDir: platformDirs,
836
+ workingDir: z.strictObject({
837
+ macos: workingDir(path.posix.isAbsolute),
838
+ linux: workingDir(path.posix.isAbsolute),
839
+ windows: workingDir(path.win32.isAbsolute)
840
+ }).prefault({}),
833
841
  claudeAccounts: z.array(accountSchema("claude")).default([]),
834
842
  codexAccounts: z.array(accountSchema("codex")).default([]),
835
843
  enabledProviders: z.strictObject({
@@ -930,6 +938,7 @@ function activity() {
930
938
  running: run.running,
931
939
  waiting: run.pending.size > 0,
932
940
  prompted: run.prompted,
941
+ compacting: run.compacting,
933
942
  tasks: run.tasks
934
943
  };
935
944
  return result;
@@ -964,6 +973,48 @@ function scheduleAutoResolve(sessionId, request) {
964
973
  };
965
974
  }
966
975
  //#endregion
976
+ //#region src/lib/codex/presets.ts
977
+ const presets = /* @__PURE__ */ new Map([
978
+ ["read-only", {
979
+ approvalPolicy: "on-request",
980
+ sandbox: "read-only",
981
+ sandboxPolicy: {
982
+ type: "readOnly",
983
+ networkAccess: false
984
+ }
985
+ }],
986
+ ["auto", {
987
+ approvalPolicy: "on-request",
988
+ sandbox: "workspace-write",
989
+ sandboxPolicy: {
990
+ type: "workspaceWrite",
991
+ writableRoots: [],
992
+ networkAccess: false,
993
+ excludeTmpdirEnvVar: false,
994
+ excludeSlashTmp: false
995
+ }
996
+ }],
997
+ ["full-access", {
998
+ approvalPolicy: "never",
999
+ sandbox: "danger-full-access",
1000
+ sandboxPolicy: { type: "dangerFullAccess" }
1001
+ }]
1002
+ ]);
1003
+ function threadPreset(settings) {
1004
+ const preset = modePreset(settings.mode);
1005
+ if (preset === null) return {};
1006
+ return {
1007
+ approvalPolicy: preset.approvalPolicy,
1008
+ sandbox: preset.sandbox
1009
+ };
1010
+ }
1011
+ function modePreset(mode) {
1012
+ if (mode === null) return null;
1013
+ const preset = presets.get(mode);
1014
+ if (preset === void 0) throw new Error(`Codex has no ${mode} mode`);
1015
+ return preset;
1016
+ }
1017
+ //#endregion
967
1018
  //#region src/lib/codex/requests.ts
968
1019
  function toRequest$1(requestId, method, params, items) {
969
1020
  const turnId = params.turnId;
@@ -1055,32 +1106,6 @@ function reply(method, params, decision, answers) {
1055
1106
  //#endregion
1056
1107
  //#region src/lib/codex/run.ts
1057
1108
  const IDLE_LIMIT$2 = 18e5;
1058
- const presets = /* @__PURE__ */ new Map([
1059
- ["read-only", {
1060
- approvalPolicy: "on-request",
1061
- sandbox: "read-only",
1062
- sandboxPolicy: {
1063
- type: "readOnly",
1064
- networkAccess: false
1065
- }
1066
- }],
1067
- ["auto", {
1068
- approvalPolicy: "on-request",
1069
- sandbox: "workspace-write",
1070
- sandboxPolicy: {
1071
- type: "workspaceWrite",
1072
- writableRoots: [],
1073
- networkAccess: false,
1074
- excludeTmpdirEnvVar: false,
1075
- excludeSlashTmp: false
1076
- }
1077
- }],
1078
- ["full-access", {
1079
- approvalPolicy: "never",
1080
- sandbox: "danger-full-access",
1081
- sandboxPolicy: { type: "dangerFullAccess" }
1082
- }]
1083
- ]);
1084
1109
  function codexServer(settings) {
1085
1110
  return appServer(storedAccount("codex", settings.accountId).configDir ?? codexBaseDir);
1086
1111
  }
@@ -1098,6 +1123,7 @@ var CodexRun = class CodexRun {
1098
1123
  partial = "";
1099
1124
  running = false;
1100
1125
  prompted = false;
1126
+ compacting = false;
1101
1127
  backgroundTasks = 0;
1102
1128
  tasks = {};
1103
1129
  activeAt = Date.now();
@@ -1228,9 +1254,16 @@ var CodexRun = class CodexRun {
1228
1254
  return;
1229
1255
  }
1230
1256
  case "item/started":
1231
- case "item/completed":
1232
- for (const item of toItems$1(params.turnId, params.item, null, Date.now())) this.put(item);
1257
+ case "item/completed": {
1258
+ const thread = params.item;
1259
+ if (thread.type === "contextCompaction") {
1260
+ this.compacting = method === "item/started";
1261
+ broadcastActivity();
1262
+ if (this.compacting) return;
1263
+ }
1264
+ for (const item of toItems$1(params.turnId, thread, null, Date.now())) this.put(item);
1233
1265
  return;
1266
+ }
1234
1267
  case "item/agentMessage/delta":
1235
1268
  this.partial += params.delta;
1236
1269
  publish$2(this.sessionId, {
@@ -1370,6 +1403,7 @@ var CodexRun = class CodexRun {
1370
1403
  }
1371
1404
  finishTurn() {
1372
1405
  this.partial = "";
1406
+ this.compacting = false;
1373
1407
  this.turnId = null;
1374
1408
  const [next, ...rest] = this.queue;
1375
1409
  if (next !== void 0) {
@@ -1424,20 +1458,6 @@ var CodexRun = class CodexRun {
1424
1458
  });
1425
1459
  }
1426
1460
  };
1427
- function threadPreset(settings) {
1428
- const preset = modePreset(settings.mode);
1429
- if (preset === null) return {};
1430
- return {
1431
- approvalPolicy: preset.approvalPolicy,
1432
- sandbox: preset.sandbox
1433
- };
1434
- }
1435
- function modePreset(mode) {
1436
- if (mode === null) return null;
1437
- const preset = presets.get(mode);
1438
- if (preset === void 0) throw new Error(`Codex has no ${mode} mode`);
1439
- return preset;
1440
- }
1441
1461
  //#endregion
1442
1462
  //#region src/lib/codex/sessions.ts
1443
1463
  function codexInstalled() {
@@ -1610,13 +1630,19 @@ function messageItems(message, parent) {
1610
1630
  background: null
1611
1631
  }];
1612
1632
  case "assistant": return message.content.flatMap((part, index) => partItems(message, part, index, parent));
1613
- case "compaction": return message.status === "failed" ? [{
1614
- ...errorItem(message.id, message.error.message),
1615
- parent
1616
- }] : [{
1617
- ...base,
1618
- kind: "compaction"
1619
- }];
1633
+ case "compaction":
1634
+ switch (message.status) {
1635
+ case "running": return [];
1636
+ case "completed": return [{
1637
+ ...base,
1638
+ kind: "compaction"
1639
+ }];
1640
+ case "failed": return [{
1641
+ ...errorItem(message.id, message.error.message),
1642
+ parent
1643
+ }];
1644
+ }
1645
+ throw new Error("Unknown compaction status");
1620
1646
  case "idle": return message.outcome === "failed" && message.error !== void 0 ? [{
1621
1647
  ...errorItem(message.id, message.error.message),
1622
1648
  parent
@@ -1828,6 +1854,47 @@ function record(value) {
1828
1854
  return isRecord(value) ? value : {};
1829
1855
  }
1830
1856
  //#endregion
1857
+ //#region src/lib/opencode/prompts.ts
1858
+ async function sendPrompt(sessionId, cwd, prompt) {
1859
+ const client = await openCode();
1860
+ const sessionID = sessionId;
1861
+ const location = { directory: cwd };
1862
+ const match = /^\/(\S+)\s*([\s\S]*)$/.exec(prompt.text);
1863
+ if (match !== null) {
1864
+ if (match[1] === "compact" && prompt.images.length === 0) {
1865
+ await client.session.compact({ sessionID });
1866
+ return;
1867
+ }
1868
+ if ((await client.command.list({ location })).data.some((command) => command.name === match[1])) {
1869
+ await client.session.command({
1870
+ sessionID,
1871
+ name: match[1],
1872
+ text: match[2],
1873
+ files: await files(sessionID, prompt)
1874
+ });
1875
+ return;
1876
+ }
1877
+ const skill = (await client.skill.list({ location })).data.find((item) => item.name === match[1]);
1878
+ if (skill !== void 0) {
1879
+ await client.session.prompt({
1880
+ sessionID,
1881
+ text: match[2],
1882
+ files: await files(sessionID, prompt),
1883
+ skills: [{ id: skill.id }]
1884
+ });
1885
+ return;
1886
+ }
1887
+ }
1888
+ await client.session.prompt({
1889
+ sessionID,
1890
+ text: prompt.text,
1891
+ files: await files(sessionID, prompt)
1892
+ });
1893
+ }
1894
+ function files(sessionId, prompt) {
1895
+ return Promise.all(prompt.images.map(async (image) => ({ uri: pathToFileURL(await saveImage(sessionId, image)).href })));
1896
+ }
1897
+ //#endregion
1831
1898
  //#region src/lib/opencode/requests.ts
1832
1899
  function permissionRequest(permission, item) {
1833
1900
  return {
@@ -2061,6 +2128,7 @@ var OpenCodeRun = class OpenCodeRun {
2061
2128
  partial = "";
2062
2129
  running = false;
2063
2130
  prompted = false;
2131
+ compacting = false;
2064
2132
  backgroundTasks = 0;
2065
2133
  tasks = {};
2066
2134
  close;
@@ -2182,6 +2250,16 @@ var OpenCodeRun = class OpenCodeRun {
2182
2250
  case "session.reasoning.delta":
2183
2251
  case "session.tool.input.delta":
2184
2252
  case "session.compaction.delta": return;
2253
+ case "session.compaction.started":
2254
+ this.compacting = true;
2255
+ broadcastActivity();
2256
+ return;
2257
+ case "session.compaction.ended":
2258
+ case "session.compaction.failed":
2259
+ this.compacting = false;
2260
+ broadcastActivity();
2261
+ this.refresh();
2262
+ return;
2185
2263
  case "session.execution.started":
2186
2264
  clearTimeout(this.idleTimer);
2187
2265
  if (!this.running) this.prompted = false;
@@ -2248,7 +2326,7 @@ var OpenCodeRun = class OpenCodeRun {
2248
2326
  broadcastActivity();
2249
2327
  try {
2250
2328
  await this.apply();
2251
- await this.send(prompt);
2329
+ await sendPrompt(this.sessionId, this.cwd, prompt);
2252
2330
  } catch (error) {
2253
2331
  this.running = false;
2254
2332
  broadcastActivity();
@@ -2256,42 +2334,6 @@ var OpenCodeRun = class OpenCodeRun {
2256
2334
  throw error;
2257
2335
  }
2258
2336
  }
2259
- async send(prompt) {
2260
- const client = await openCode();
2261
- const sessionID = this.sessionId;
2262
- const location = { directory: this.cwd };
2263
- const match = /^\/(\S+)\s*([\s\S]*)$/.exec(prompt.text);
2264
- if (match !== null) {
2265
- if (match[1] === "compact" && prompt.images.length === 0) {
2266
- await client.session.compact({ sessionID });
2267
- return;
2268
- }
2269
- if ((await client.command.list({ location })).data.some((command) => command.name === match[1])) {
2270
- await client.session.command({
2271
- sessionID,
2272
- name: match[1],
2273
- text: match[2],
2274
- files: await files(sessionID, prompt)
2275
- });
2276
- return;
2277
- }
2278
- const skill = (await client.skill.list({ location })).data.find((item) => item.name === match[1]);
2279
- if (skill !== void 0) {
2280
- await client.session.prompt({
2281
- sessionID,
2282
- text: match[2],
2283
- files: await files(sessionID, prompt),
2284
- skills: [{ id: skill.id }]
2285
- });
2286
- return;
2287
- }
2288
- }
2289
- await client.session.prompt({
2290
- sessionID,
2291
- text: prompt.text,
2292
- files: await files(sessionID, prompt)
2293
- });
2294
- }
2295
2337
  async apply() {
2296
2338
  const client = await openCode();
2297
2339
  const settings = this.settings;
@@ -2344,6 +2386,7 @@ var OpenCodeRun = class OpenCodeRun {
2344
2386
  }
2345
2387
  finishTurn() {
2346
2388
  this.partial = "";
2389
+ this.compacting = false;
2347
2390
  const [next, ...rest] = this.queue;
2348
2391
  if (next !== void 0) {
2349
2392
  this.queue = rest;
@@ -2400,9 +2443,6 @@ var OpenCodeRun = class OpenCodeRun {
2400
2443
  });
2401
2444
  }
2402
2445
  };
2403
- function files(sessionId, prompt) {
2404
- return Promise.all(prompt.images.map(async (image) => ({ uri: pathToFileURL(await saveImage(sessionId, image)).href })));
2405
- }
2406
2446
  //#endregion
2407
2447
  //#region src/storage/db.ts
2408
2448
  const file = path.join(home, "offsite.db");
@@ -2456,63 +2496,482 @@ const pushSubscriptions = snakeCase.table("push_subscriptions", {
2456
2496
  server: text().notNull()
2457
2497
  });
2458
2498
  //#endregion
2459
- //#region src/services/projects.ts
2460
- const LOGO_TYPES = [
2461
- "image/png",
2462
- "image/jpeg",
2463
- "image/gif",
2464
- "image/webp",
2465
- "image/svg+xml",
2466
- "image/x-icon",
2467
- "image/vnd.microsoft.icon"
2468
- ];
2469
- const LOGO_LIMIT = 524288;
2470
- function projects$1() {
2471
- return db.select().from(projects$2).all();
2472
- }
2473
- function projectPath(id) {
2474
- const project = db.select().from(projects$2).where(eq(projects$2.id, id)).get();
2475
- if (project === void 0) throw new Error("That project was removed");
2476
- return project.path;
2477
- }
2478
- async function addProject(input) {
2479
- const folder = path.resolve(expandHome(input.trim()));
2480
- if (!(await stat(folder)).isDirectory()) throw new Error(`${folder} is not a folder`);
2481
- db.insert(projects$2).values({
2482
- id: randomUUID(),
2483
- path: folder,
2484
- name: path.basename(folder) || folder,
2485
- logo: null
2486
- }).onConflictDoNothing({ target: projects$2.path }).run();
2487
- publish$1();
2488
- const project = projects$1().find((item) => item.path === folder);
2489
- if (project === void 0) throw new Error(`Could not add ${folder}`);
2490
- return project;
2499
+ //#region src/services/git.ts
2500
+ const DIFF_LIMIT = 8e4;
2501
+ const UNTRACKED_LIMIT = 100;
2502
+ const NEW_FILE_LIMIT = 262144;
2503
+ const REPO_DEPTH = 3;
2504
+ const FETCH_TIMEOUT = 6e4;
2505
+ const run$1 = promisify(execFile);
2506
+ const PULL_REQUEST_INSTRUCTIONS = `You write GitHub pull requests as Markdown.
2507
+
2508
+ ## TITLE RULES
2509
+
2510
+ - Use the conventional commits format: type(scope): subject
2511
+ - Types: feat, fix, docs, style, refactor, perf, test, chore, ci
2512
+ - Scope is optional; include it when the change targets a clear area
2513
+ - Subject: imperative mood, lowercase, no trailing period
2514
+ - Maximum 72 characters in total
2515
+ - Describe the overall change, not a single commit
2516
+
2517
+ ## BODY RULES
2518
+
2519
+ - Use short Markdown sections with level-2 headings. Choose only sections that fit the change: \`New features\`, \`Improvements\`, \`Fixes\`, \`Before\` / \`After\`, or \`Breaking changes\`. Do not use all of them as a template.
2520
+ - Use bullet points under every section, never paragraphs. Each bullet describes one feature or change in one short sentence.
2521
+ - For a small change, use one section with one bullet. For larger changes, use only the sections needed, with at most three short bullets across the body.
2522
+ - Aim for 20-50 words; fewer is better when enough. Never exceed 80 words.
2523
+ - Describe the outcome, not the implementation walkthrough. Do not enumerate changed files, functions, commits, or incidental cleanup; reviewers can read the diff.
2524
+ - Add a \`Breaking changes\` section only for an actual compatibility break or required migration.
2525
+ - No generic \`Summary\` section, bold labels, checklists, boilerplate openings like "This pull request", or closing summaries.
2526
+
2527
+ ## RULES
2528
+
2529
+ Base every statement on the provided commits and diff; do NOT invent details.
2530
+
2531
+ ## OUTPUT
2532
+
2533
+ Return one Markdown document. The title is the level-1 heading (\`# <title>\`), and everything after it is the body.`;
2534
+ async function gitRepos(cwd) {
2535
+ return Promise.all((await projectRepos(cwd)).map(async (repo) => ({
2536
+ ...await gitStatus(path.join(cwd, repo)),
2537
+ path: repo
2538
+ })));
2491
2539
  }
2492
- function updateProject(id, patch) {
2493
- if (patch.name !== void 0) {
2494
- const name = patch.name.trim();
2495
- if (name === "") throw new Error("Project name cannot be empty");
2496
- db.update(projects$2).set({ name }).where(eq(projects$2.id, id)).run();
2497
- }
2498
- if (patch.logo !== void 0) {
2499
- if (patch.logo !== null) checkLogo(patch.logo);
2500
- db.update(projects$2).set({ logo: patch.logo }).where(eq(projects$2.id, id)).run();
2540
+ async function commit(cwd, message, andPush) {
2541
+ const text = message.trim() === "" ? await commitMessage(cwd) : message;
2542
+ await git(cwd, ["add", "--all"]);
2543
+ await git(cwd, [
2544
+ "commit",
2545
+ "--message",
2546
+ text
2547
+ ]);
2548
+ if (andPush) try {
2549
+ await push$1(cwd);
2550
+ } catch (error) {
2551
+ throw new Error(`Committed, but push failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
2501
2552
  }
2502
- publish$1();
2503
- return null;
2553
+ return { message: text };
2504
2554
  }
2505
- function removeProject(id) {
2506
- db.delete(projects$2).where(eq(projects$2.id, id)).run();
2507
- publish$1();
2555
+ async function push$1(cwd) {
2556
+ const status = await gitStatus(cwd);
2557
+ if (status.branch === null) throw new Error("Cannot push a detached HEAD");
2558
+ try {
2559
+ if (status.upstream === null) await git(cwd, [
2560
+ "push",
2561
+ "--set-upstream",
2562
+ "origin",
2563
+ "HEAD"
2564
+ ]);
2565
+ else await git(cwd, ["push"]);
2566
+ } catch (error) {
2567
+ throw gitError(error);
2568
+ }
2508
2569
  }
2509
- async function directories(input) {
2510
- const folder = path.resolve(expandHome(input.trim()));
2511
- const entries = await readdir(folder, { withFileTypes: true });
2512
- const parent = path.dirname(folder);
2513
- return {
2514
- path: folder,
2515
- parent: parent === folder ? null : parent,
2570
+ async function sync(cwd) {
2571
+ if ((await gitStatus(cwd)).upstream === null) {
2572
+ await push$1(cwd);
2573
+ return;
2574
+ }
2575
+ try {
2576
+ await git(cwd, ["fetch"]);
2577
+ const status = await gitStatus(cwd);
2578
+ if (status.behind > 0 && status.ahead === 0) await git(cwd, [
2579
+ "merge",
2580
+ "--ff-only",
2581
+ "@{upstream}"
2582
+ ]);
2583
+ if (status.behind > 0 && status.ahead > 0) await rebaseOnUpstream(cwd);
2584
+ } catch (error) {
2585
+ throw gitError(error);
2586
+ }
2587
+ if ((await gitStatus(cwd)).ahead > 0) await push$1(cwd);
2588
+ }
2589
+ async function fetchProject(cwd) {
2590
+ await Promise.all((await projectRepos(cwd)).map(async (repo) => {
2591
+ try {
2592
+ await run$1("git", ["fetch"], {
2593
+ cwd: path.join(cwd, repo),
2594
+ env: {
2595
+ ...process.env,
2596
+ GIT_TERMINAL_PROMPT: "0"
2597
+ },
2598
+ timeout: FETCH_TIMEOUT
2599
+ });
2600
+ } catch (error) {
2601
+ throw gitError(error);
2602
+ }
2603
+ }));
2604
+ }
2605
+ async function localBranches(cwd) {
2606
+ return (await git(cwd, [
2607
+ "branch",
2608
+ "--sort=-committerdate",
2609
+ "--format=%(refname:short)"
2610
+ ])).stdout.split("\n").filter(Boolean);
2611
+ }
2612
+ async function switchBranch(cwd, branch, create) {
2613
+ await git(cwd, create ? [
2614
+ "switch",
2615
+ "--create",
2616
+ branch
2617
+ ] : ["switch", branch]);
2618
+ }
2619
+ async function pullRequestBranches(cwd) {
2620
+ const head = (await gitStatus(cwd)).branch;
2621
+ if (head === null) throw new Error("Not on a branch");
2622
+ const base = (await run$1("gh", [
2623
+ "repo",
2624
+ "view",
2625
+ "--json",
2626
+ "defaultBranchRef",
2627
+ "--jq",
2628
+ ".defaultBranchRef.name"
2629
+ ], { cwd })).stdout.trim();
2630
+ if (base === head) throw new Error(`${head} is the default branch. Create a branch first.`);
2631
+ return {
2632
+ head,
2633
+ base,
2634
+ branches: (await git(cwd, [
2635
+ "ls-remote",
2636
+ "--heads",
2637
+ "origin"
2638
+ ])).stdout.split("\n").filter(Boolean).map((line) => line.split("refs/heads/")[1]).filter((branch) => branch !== head)
2639
+ };
2640
+ }
2641
+ async function preparePullRequest(cwd, base, commitChanges) {
2642
+ const head = (await gitStatus(cwd)).branch;
2643
+ if (head === null) throw new Error("Not on a branch");
2644
+ if (commitChanges) await commit(cwd, "", true);
2645
+ const status = await gitStatus(cwd);
2646
+ if (status.upstream === null || status.ahead > 0) await push$1(cwd);
2647
+ const open = (await run$1("gh", [
2648
+ "pr",
2649
+ "list",
2650
+ "--head",
2651
+ head,
2652
+ "--base",
2653
+ base,
2654
+ "--state",
2655
+ "open",
2656
+ "--json",
2657
+ "url",
2658
+ "--jq",
2659
+ ".[0].url // \"\""
2660
+ ], { cwd })).stdout.trim();
2661
+ if (open !== "") return {
2662
+ kind: "open",
2663
+ url: open
2664
+ };
2665
+ await git(cwd, [
2666
+ "fetch",
2667
+ "origin",
2668
+ base
2669
+ ]);
2670
+ const commits = (await git(cwd, [
2671
+ "log",
2672
+ "--format=- %B",
2673
+ `origin/${base}..HEAD`
2674
+ ])).stdout.trim();
2675
+ if (commits === "") throw new Error(`No commits between ${base} and ${head}`);
2676
+ const diff = (await git(cwd, [
2677
+ "diff",
2678
+ "--no-color",
2679
+ "--no-ext-diff",
2680
+ `origin/${base}...HEAD`
2681
+ ])).stdout.slice(0, DIFF_LIMIT);
2682
+ const lines = (await generateText(cwd, PULL_REQUEST_INSTRUCTIONS, `Commits between base and head:\n${commits}\n\n${diff}\n\nGenerate the pull request Markdown document from the above.`)).split("\n");
2683
+ const title = lines[0].trim().replace(/^#{1,6}\s*/, "").replace(/^(\*\*|__|\*|_|`)+/, "").replace(/(\*\*|__|\*|_|`)+$/, "").trim();
2684
+ if (title === "") throw new Error("The model returned an empty title");
2685
+ return {
2686
+ kind: "draft",
2687
+ base,
2688
+ head,
2689
+ title,
2690
+ body: lines.slice(1).join("\n").trim()
2691
+ };
2692
+ }
2693
+ async function createPullRequest(cwd, request) {
2694
+ const url = (await run$1("gh", [
2695
+ "pr",
2696
+ "create",
2697
+ "--title",
2698
+ request.title,
2699
+ "--body",
2700
+ request.body,
2701
+ "--base",
2702
+ request.base,
2703
+ "--head",
2704
+ request.head,
2705
+ "--assignee",
2706
+ "@me"
2707
+ ], { cwd })).stdout.trim().split("\n").at(-1);
2708
+ if (url === void 0) throw new Error("gh did not return a pull request URL");
2709
+ return { url };
2710
+ }
2711
+ async function commitMessage(cwd) {
2712
+ const diff = (await repoDiff(cwd, "")).slice(0, DIFF_LIMIT);
2713
+ if (diff === "") throw new Error("There is nothing to commit");
2714
+ return generateText(cwd, "You write concise git commit messages.", `Write a git commit message for this diff. Reply with only the message: a short summary line, optionally followed by a blank line and a brief body.\n\n${diff}`);
2715
+ }
2716
+ async function generateText(cwd, instructions, prompt) {
2717
+ const settings = readConfig().commitMessage;
2718
+ const provider = settings.provider;
2719
+ const accountId = settings.accountId;
2720
+ const model = settings.model;
2721
+ if (provider === null || model === null) throw new Error("Choose a model in Settings → Git");
2722
+ assertEnabled(provider, accountId);
2723
+ if (provider === "codex") return codexText(accountId, cwd, model, instructions, prompt);
2724
+ if (provider === "opencode") return openCodeText(cwd, model, `${instructions}\n\n${prompt}`);
2725
+ const conversation = query({
2726
+ prompt,
2727
+ options: {
2728
+ ...claudeOptions(accountId, cwd),
2729
+ systemPrompt: instructions,
2730
+ tools: [],
2731
+ model,
2732
+ maxTurns: 1,
2733
+ persistSession: false
2734
+ }
2735
+ });
2736
+ for await (const message of conversation) {
2737
+ if (message.type !== "result") continue;
2738
+ if (message.subtype !== "success") throw new Error(message.errors.join("\n"));
2739
+ if (message.is_error) throw new Error(message.result);
2740
+ return message.result.trim();
2741
+ }
2742
+ throw new Error("Claude did not reply");
2743
+ }
2744
+ async function projectDiff(cwd) {
2745
+ const repos = await projectRepos(cwd);
2746
+ if (repos.length === 0) throw new Error(`${cwd} is not a git repository and has none inside it`);
2747
+ return (await Promise.all(repos.map((repo) => repoDiff(path.join(cwd, repo), repo === "" ? "" : `${repo}/`)))).join("");
2748
+ }
2749
+ function git(cwd, args) {
2750
+ return run$1("git", args, {
2751
+ cwd,
2752
+ maxBuffer: 67108864
2753
+ });
2754
+ }
2755
+ async function projectRepos(cwd) {
2756
+ return await isRepo(cwd) ? [""] : childRepos(cwd, "", REPO_DEPTH);
2757
+ }
2758
+ async function gitStatus(cwd) {
2759
+ const output = await git(cwd, [
2760
+ "--no-optional-locks",
2761
+ "status",
2762
+ "--porcelain=v2",
2763
+ "--branch"
2764
+ ]);
2765
+ const status = {
2766
+ branch: null,
2767
+ upstream: null,
2768
+ remote: (await git(cwd, ["remote"])).stdout.trim() !== "",
2769
+ ahead: 0,
2770
+ behind: 0,
2771
+ changed: 0
2772
+ };
2773
+ for (const line of output.stdout.split("\n").filter(Boolean)) {
2774
+ const [marker, key, ...rest] = line.split(" ");
2775
+ if (marker !== "#") status.changed++;
2776
+ else if (key === "branch.head") status.branch = rest[0] === "(detached)" ? null : rest[0];
2777
+ else if (key === "branch.upstream") status.upstream = rest[0];
2778
+ else if (key === "branch.ab") {
2779
+ status.ahead = Number(rest[0]);
2780
+ status.behind = -Number(rest[1]);
2781
+ }
2782
+ }
2783
+ return status;
2784
+ }
2785
+ async function rebaseOnUpstream(cwd) {
2786
+ try {
2787
+ await git(cwd, ["rebase", "@{upstream}"]);
2788
+ } catch (error) {
2789
+ const rebasing = await git(cwd, [
2790
+ "rev-parse",
2791
+ "--git-path",
2792
+ "rebase-merge"
2793
+ ]);
2794
+ if (existsSync(path.resolve(cwd, rebasing.stdout.trim()))) {
2795
+ await git(cwd, ["rebase", "--abort"]);
2796
+ throw new Error("Your commits conflict with the remote branch, so nothing was pulled or pushed. Resolve it in a terminal.", { cause: error });
2797
+ }
2798
+ throw error;
2799
+ }
2800
+ }
2801
+ function gitError(error) {
2802
+ return new Error((error instanceof Error ? error.message : String(error)).split("\n").filter((line) => line.trim() !== "" && !line.startsWith("hint:") && !line.startsWith("Command failed:")).join("\n"), { cause: error });
2803
+ }
2804
+ async function isRepo(cwd) {
2805
+ try {
2806
+ await git(cwd, ["rev-parse", "--is-inside-work-tree"]);
2807
+ return true;
2808
+ } catch {
2809
+ return false;
2810
+ }
2811
+ }
2812
+ async function repoDiff(repo, prefix) {
2813
+ const tracked = await git(repo, [
2814
+ "--no-optional-locks",
2815
+ "diff",
2816
+ await diffBase(repo),
2817
+ "--no-color",
2818
+ "--no-ext-diff",
2819
+ `--src-prefix=a/${prefix}`,
2820
+ `--dst-prefix=b/${prefix}`
2821
+ ]);
2822
+ const added = (await git(repo, [
2823
+ "ls-files",
2824
+ "--others",
2825
+ "--exclude-standard"
2826
+ ])).stdout.split("\n").filter(Boolean).slice(0, UNTRACKED_LIMIT).map((file) => newFilePatch(repo, file, prefix));
2827
+ return [tracked.stdout, ...added].join("");
2828
+ }
2829
+ async function diffBase(repo) {
2830
+ try {
2831
+ await git(repo, [
2832
+ "rev-parse",
2833
+ "--verify",
2834
+ "--quiet",
2835
+ "HEAD"
2836
+ ]);
2837
+ return "HEAD";
2838
+ } catch (error) {
2839
+ if (!(error instanceof Error && "code" in error && error.code === 1)) throw error;
2840
+ }
2841
+ return (await git(repo, [
2842
+ "hash-object",
2843
+ "-t",
2844
+ "tree",
2845
+ "/dev/null"
2846
+ ])).stdout.trim();
2847
+ }
2848
+ function childRepos(root, dir, depth) {
2849
+ if (depth === 0) return [];
2850
+ let entries;
2851
+ try {
2852
+ entries = readdirSync(path.join(root, dir), { withFileTypes: true });
2853
+ } catch {
2854
+ return [];
2855
+ }
2856
+ return entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules").flatMap((entry) => {
2857
+ const relative = dir === "" ? entry.name : `${dir}/${entry.name}`;
2858
+ return existsSync(path.join(root, relative, ".git")) ? [relative] : childRepos(root, relative, depth - 1);
2859
+ });
2860
+ }
2861
+ function newFilePatch(cwd, file, prefix) {
2862
+ const name = `${prefix}${file}`;
2863
+ const header = `diff --git a/${name} b/${name}\nnew file\n--- /dev/null\n+++ b/${name}\n`;
2864
+ const full = path.join(cwd, file);
2865
+ const stats = lstatSync(full);
2866
+ if (stats.isSymbolicLink()) return `${header}@@ -0,0 +1 @@\n+${readlinkSync(full)}\n`;
2867
+ if (stats.isDirectory()) return `${header}Nested repository\n`;
2868
+ if (stats.size > NEW_FILE_LIMIT) return `${header}Binary or large file\n`;
2869
+ const content = readFileSync(full, "utf8");
2870
+ if (content.includes("\0")) return `${header}Binary or large file\n`;
2871
+ const lines = content.split("\n");
2872
+ if (lines.at(-1) === "") lines.pop();
2873
+ return `${header}@@ -0,0 +1,${lines.length} @@\n${lines.map((line) => `+${line}\n`).join("")}`;
2874
+ }
2875
+ const LOGO_FILES = /* @__PURE__ */ new Map([
2876
+ [".svg", "image/svg+xml"],
2877
+ [".png", "image/png"],
2878
+ [".webp", "image/webp"],
2879
+ [".jpg", "image/jpeg"],
2880
+ [".jpeg", "image/jpeg"],
2881
+ [".gif", "image/gif"],
2882
+ [".ico", "image/x-icon"]
2883
+ ]);
2884
+ const PREFERRED = [
2885
+ "logo.svg",
2886
+ "logo.png",
2887
+ "logo.webp",
2888
+ "favicon.ico",
2889
+ "icon.svg",
2890
+ "icon.png",
2891
+ "icon.webp"
2892
+ ];
2893
+ async function findLogo(folder) {
2894
+ const repos = await projectRepos(folder);
2895
+ const candidates = (await Promise.all(repos.map(async (repo) => {
2896
+ return (await git(path.join(folder, repo), ["ls-files", "-z"])).stdout.split("\0").map((file) => repo === "" ? file : `${repo}/${file}`);
2897
+ }))).flat().map((file) => ({
2898
+ file,
2899
+ rank: logoRank(file)
2900
+ })).filter((candidate) => candidate.rank !== -1).sort((a, b) => a.rank - b.rank || a.file.length - b.file.length);
2901
+ for (const { file } of candidates) {
2902
+ const full = path.join(folder, file);
2903
+ if (!existsSync(full)) continue;
2904
+ const logo = `data:${LOGO_FILES.get(path.extname(file).toLowerCase())};base64,${(await readFile(full)).toString("base64")}`;
2905
+ if (logo.length <= 524288) return logo;
2906
+ }
2907
+ return null;
2908
+ }
2909
+ function logoRank(file) {
2910
+ const preferred = PREFERRED.indexOf(path.basename(file).toLowerCase());
2911
+ if (preferred !== -1) return preferred;
2912
+ const extension = path.extname(file).toLowerCase();
2913
+ if (!LOGO_FILES.has(extension)) return -1;
2914
+ const words = path.basename(file, extension).replaceAll(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z]+/);
2915
+ if (words.includes("logo") || words.includes("icon") || words.includes("favicon")) return PREFERRED.length;
2916
+ return -1;
2917
+ }
2918
+ //#endregion
2919
+ //#region src/services/projects.ts
2920
+ const LOGO_TYPES = [
2921
+ "image/png",
2922
+ "image/jpeg",
2923
+ "image/gif",
2924
+ "image/webp",
2925
+ "image/svg+xml",
2926
+ "image/x-icon",
2927
+ "image/vnd.microsoft.icon"
2928
+ ];
2929
+ function projects$1() {
2930
+ return db.select().from(projects$2).all();
2931
+ }
2932
+ function projectPath(id) {
2933
+ const project = db.select().from(projects$2).where(eq(projects$2.id, id)).get();
2934
+ if (project === void 0) throw new Error("That project was removed");
2935
+ return project.path;
2936
+ }
2937
+ async function addProject(input) {
2938
+ const folder = path.resolve(expandHome(input.trim()));
2939
+ if (!(await stat(folder)).isDirectory()) throw new Error(`${folder} is not a folder`);
2940
+ db.insert(projects$2).values({
2941
+ id: randomUUID(),
2942
+ path: folder,
2943
+ name: path.basename(folder) || folder,
2944
+ logo: await findLogo(folder)
2945
+ }).onConflictDoNothing({ target: projects$2.path }).run();
2946
+ publish$1();
2947
+ const project = projects$1().find((item) => item.path === folder);
2948
+ if (project === void 0) throw new Error(`Could not add ${folder}`);
2949
+ return project;
2950
+ }
2951
+ function updateProject(id, patch) {
2952
+ if (patch.name !== void 0) {
2953
+ const name = patch.name.trim();
2954
+ if (name === "") throw new Error("Project name cannot be empty");
2955
+ db.update(projects$2).set({ name }).where(eq(projects$2.id, id)).run();
2956
+ }
2957
+ if (patch.logo !== void 0) {
2958
+ if (patch.logo !== null) checkLogo(patch.logo);
2959
+ db.update(projects$2).set({ logo: patch.logo }).where(eq(projects$2.id, id)).run();
2960
+ }
2961
+ publish$1();
2962
+ return null;
2963
+ }
2964
+ function removeProject(id) {
2965
+ db.delete(projects$2).where(eq(projects$2.id, id)).run();
2966
+ publish$1();
2967
+ }
2968
+ async function directories(input) {
2969
+ const folder = path.resolve(expandHome(input.trim()));
2970
+ const entries = await readdir(folder, { withFileTypes: true });
2971
+ const parent = path.dirname(folder);
2972
+ return {
2973
+ path: folder,
2974
+ parent: parent === folder ? null : parent,
2516
2975
  directories: entries.filter((entry) => entry.isDirectory()).map((entry) => ({
2517
2976
  name: entry.name,
2518
2977
  path: path.join(folder, entry.name)
@@ -2528,7 +2987,7 @@ function publish$1() {
2528
2987
  function checkLogo(logo) {
2529
2988
  const type = /^data:([^;,]+);base64,/.exec(logo)?.[1];
2530
2989
  if (type === void 0 || !LOGO_TYPES.includes(type)) throw new Error("Use a PNG, JPEG, GIF, WebP, SVG or ICO image");
2531
- if (logo.length > LOGO_LIMIT) throw new Error("Use an image smaller than 384 KB");
2990
+ if (logo.length > 524288) throw new Error("Use an image smaller than 384 KB");
2532
2991
  }
2533
2992
  function expandHome(input) {
2534
2993
  if (input === "~") return os.homedir();
@@ -4072,6 +4531,7 @@ var Run = class {
4072
4531
  model = null;
4073
4532
  running = false;
4074
4533
  prompted = false;
4534
+ compacting = false;
4075
4535
  backgroundTasks = 0;
4076
4536
  tasks = {};
4077
4537
  inbox = [];
@@ -4303,6 +4763,10 @@ var Run = class {
4303
4763
  broadcastActivity();
4304
4764
  return;
4305
4765
  case "status":
4766
+ if (this.compacting !== (message.status === "compacting")) {
4767
+ this.compacting = message.status === "compacting";
4768
+ broadcastActivity();
4769
+ }
4306
4770
  if (message.permissionMode !== void 0) this.updateSettings({
4307
4771
  ...this.settings,
4308
4772
  mode: message.permissionMode
@@ -4334,6 +4798,7 @@ var Run = class {
4334
4798
  }
4335
4799
  finishTurn() {
4336
4800
  this.partial = "";
4801
+ this.compacting = false;
4337
4802
  const [next, ...rest] = this.queue;
4338
4803
  if (next !== void 0) {
4339
4804
  this.queue = rest;
@@ -4497,462 +4962,87 @@ function readProjectFile(cwd, file) {
4497
4962
  const mediaType = mediaTypes.get(path.extname(full).toLowerCase());
4498
4963
  if (mediaType?.startsWith("image/") === true) return {
4499
4964
  kind: "image",
4500
- size: stats.size,
4501
- mediaType,
4502
- data: data.toString("base64")
4503
- };
4504
- if (data.includes(0)) return {
4505
- kind: "binary",
4506
- size: stats.size
4507
- };
4508
- return {
4509
- kind: "text",
4510
- size: stats.size,
4511
- content: data.toString("utf8")
4512
- };
4513
- }
4514
- function linkProjectMedia(cwd, file) {
4515
- const full = resolveProjectFile(cwd, file);
4516
- const mediaType = mediaTypes.get(path.extname(full).toLowerCase());
4517
- if (mediaType === void 0) throw new Error(`${file} is not an image, video or audio file`);
4518
- return addLink(full, mediaType, null);
4519
- }
4520
- function linkProjectDownload(cwd, file) {
4521
- return addLink(resolveProjectFile(cwd, file), "application/octet-stream", path.basename(file));
4522
- }
4523
- function addLink(file, mediaType, download) {
4524
- for (const [token, link] of mediaLinks) if (link.expiresAt <= Date.now()) mediaLinks.delete(token);
4525
- const token = randomBytes(32).toString("base64url");
4526
- mediaLinks.set(token, {
4527
- file,
4528
- mediaType,
4529
- download,
4530
- expiresAt: Date.now() + MEDIA_LINK_LIFETIME
4531
- });
4532
- return token;
4533
- }
4534
- function findMediaLink(token) {
4535
- if (token === null) return null;
4536
- const link = mediaLinks.get(token);
4537
- if (link === void 0 || link.expiresAt <= Date.now()) return null;
4538
- return link;
4539
- }
4540
- function saveUpload(name, data) {
4541
- const dir = path.join(home, "uploads", randomUUID());
4542
- mkdirSync(dir, { recursive: true });
4543
- const file = path.join(dir, path.basename(name));
4544
- writeFileSync(file, data);
4545
- return file;
4546
- }
4547
- async function taskOutput(sessionId, file) {
4548
- const tasks = path.dirname(file);
4549
- if (!/^[\w-]+$/.test(sessionId) || !path.isAbsolute(file) || path.normalize(file) !== file || !/^\w+\.output$/.test(path.basename(file)) || path.basename(tasks) !== "tasks" || path.basename(path.dirname(tasks)) !== sessionId) throw new Error("Not a background task output file of this thread");
4550
- let stats;
4551
- try {
4552
- stats = await lstat(file);
4553
- } catch (error) {
4554
- if (hasCode(error, "ENOENT")) return null;
4555
- throw error;
4556
- }
4557
- if (!stats.isFile()) throw new Error("Background task output is not a regular file");
4558
- const start = Math.max(0, stats.size - OUTPUT_LIMIT$1);
4559
- const handle = await open(file, "r");
4560
- try {
4561
- const buffer = Buffer.alloc(stats.size - start);
4562
- const read = await handle.read(buffer, 0, buffer.length, start);
4563
- return {
4564
- text: buffer.subarray(0, read.bytesRead).toString("utf8"),
4565
- truncated: start > 0
4566
- };
4567
- } finally {
4568
- await handle.close();
4569
- }
4570
- }
4571
- function resolveProjectFile(cwd, file) {
4572
- const root = realpathSync(cwd);
4573
- const resolved = path.resolve(root, file);
4574
- if (!existsSync(resolved)) throw new Error(`${file} does not exist`);
4575
- const full = realpathSync(resolved);
4576
- const relative = path.relative(root, full);
4577
- if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error(`${file} is outside the project`);
4578
- return full;
4579
- }
4580
- //#endregion
4581
- //#region src/services/git.ts
4582
- const DIFF_LIMIT = 8e4;
4583
- const UNTRACKED_LIMIT = 100;
4584
- const NEW_FILE_LIMIT = 262144;
4585
- const REPO_DEPTH = 3;
4586
- const FETCH_TIMEOUT = 6e4;
4587
- const run$1 = promisify(execFile);
4588
- const PULL_REQUEST_INSTRUCTIONS = `You write GitHub pull requests as Markdown.
4589
-
4590
- ## TITLE RULES
4591
-
4592
- - Use the conventional commits format: type(scope): subject
4593
- - Types: feat, fix, docs, style, refactor, perf, test, chore, ci
4594
- - Scope is optional; include it when the change targets a clear area
4595
- - Subject: imperative mood, lowercase, no trailing period
4596
- - Maximum 72 characters in total
4597
- - Describe the overall change, not a single commit
4598
-
4599
- ## BODY RULES
4600
-
4601
- - Use short Markdown sections with level-2 headings. Choose only sections that fit the change: \`New features\`, \`Improvements\`, \`Fixes\`, \`Before\` / \`After\`, or \`Breaking changes\`. Do not use all of them as a template.
4602
- - Use bullet points under every section, never paragraphs. Each bullet describes one feature or change in one short sentence.
4603
- - For a small change, use one section with one bullet. For larger changes, use only the sections needed, with at most three short bullets across the body.
4604
- - Aim for 20-50 words; fewer is better when enough. Never exceed 80 words.
4605
- - Describe the outcome, not the implementation walkthrough. Do not enumerate changed files, functions, commits, or incidental cleanup; reviewers can read the diff.
4606
- - Add a \`Breaking changes\` section only for an actual compatibility break or required migration.
4607
- - No generic \`Summary\` section, bold labels, checklists, boilerplate openings like "This pull request", or closing summaries.
4608
-
4609
- ## RULES
4610
-
4611
- Base every statement on the provided commits and diff; do NOT invent details.
4612
-
4613
- ## OUTPUT
4614
-
4615
- Return one Markdown document. The title is the level-1 heading (\`# <title>\`), and everything after it is the body.`;
4616
- async function gitRepos(cwd) {
4617
- return Promise.all((await projectRepos(cwd)).map(async (repo) => ({
4618
- ...await gitStatus(path.join(cwd, repo)),
4619
- path: repo
4620
- })));
4621
- }
4622
- async function commit(cwd, message, andPush) {
4623
- const text = message.trim() === "" ? await commitMessage(cwd) : message;
4624
- await git(cwd, ["add", "--all"]);
4625
- await git(cwd, [
4626
- "commit",
4627
- "--message",
4628
- text
4629
- ]);
4630
- if (andPush) try {
4631
- await push$1(cwd);
4632
- } catch (error) {
4633
- throw new Error(`Committed, but push failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
4634
- }
4635
- return { message: text };
4636
- }
4637
- async function push$1(cwd) {
4638
- const status = await gitStatus(cwd);
4639
- if (status.branch === null) throw new Error("Cannot push a detached HEAD");
4640
- try {
4641
- if (status.upstream === null) await git(cwd, [
4642
- "push",
4643
- "--set-upstream",
4644
- "origin",
4645
- "HEAD"
4646
- ]);
4647
- else await git(cwd, ["push"]);
4648
- } catch (error) {
4649
- throw gitError(error);
4650
- }
4651
- }
4652
- async function sync(cwd) {
4653
- if ((await gitStatus(cwd)).upstream === null) {
4654
- await push$1(cwd);
4655
- return;
4656
- }
4657
- try {
4658
- await git(cwd, ["fetch"]);
4659
- const status = await gitStatus(cwd);
4660
- if (status.behind > 0 && status.ahead === 0) await git(cwd, [
4661
- "merge",
4662
- "--ff-only",
4663
- "@{upstream}"
4664
- ]);
4665
- if (status.behind > 0 && status.ahead > 0) await rebaseOnUpstream(cwd);
4666
- } catch (error) {
4667
- throw gitError(error);
4668
- }
4669
- if ((await gitStatus(cwd)).ahead > 0) await push$1(cwd);
4670
- }
4671
- async function fetchProject(cwd) {
4672
- await Promise.all((await projectRepos(cwd)).map(async (repo) => {
4673
- try {
4674
- await run$1("git", ["fetch"], {
4675
- cwd: path.join(cwd, repo),
4676
- env: {
4677
- ...process.env,
4678
- GIT_TERMINAL_PROMPT: "0"
4679
- },
4680
- timeout: FETCH_TIMEOUT
4681
- });
4682
- } catch (error) {
4683
- throw gitError(error);
4684
- }
4685
- }));
4686
- }
4687
- async function localBranches(cwd) {
4688
- return (await git(cwd, [
4689
- "branch",
4690
- "--sort=-committerdate",
4691
- "--format=%(refname:short)"
4692
- ])).stdout.split("\n").filter(Boolean);
4693
- }
4694
- async function switchBranch(cwd, branch, create) {
4695
- await git(cwd, create ? [
4696
- "switch",
4697
- "--create",
4698
- branch
4699
- ] : ["switch", branch]);
4700
- }
4701
- async function pullRequestBranches(cwd) {
4702
- const head = (await gitStatus(cwd)).branch;
4703
- if (head === null) throw new Error("Not on a branch");
4704
- const base = (await run$1("gh", [
4705
- "repo",
4706
- "view",
4707
- "--json",
4708
- "defaultBranchRef",
4709
- "--jq",
4710
- ".defaultBranchRef.name"
4711
- ], { cwd })).stdout.trim();
4712
- if (base === head) throw new Error(`${head} is the default branch. Create a branch first.`);
4713
- return {
4714
- head,
4715
- base,
4716
- branches: (await git(cwd, [
4717
- "ls-remote",
4718
- "--heads",
4719
- "origin"
4720
- ])).stdout.split("\n").filter(Boolean).map((line) => line.split("refs/heads/")[1]).filter((branch) => branch !== head)
4721
- };
4722
- }
4723
- async function preparePullRequest(cwd, base, commitChanges) {
4724
- const head = (await gitStatus(cwd)).branch;
4725
- if (head === null) throw new Error("Not on a branch");
4726
- if (commitChanges) await commit(cwd, "", true);
4727
- const status = await gitStatus(cwd);
4728
- if (status.upstream === null || status.ahead > 0) await push$1(cwd);
4729
- const open = (await run$1("gh", [
4730
- "pr",
4731
- "list",
4732
- "--head",
4733
- head,
4734
- "--base",
4735
- base,
4736
- "--state",
4737
- "open",
4738
- "--json",
4739
- "url",
4740
- "--jq",
4741
- ".[0].url // \"\""
4742
- ], { cwd })).stdout.trim();
4743
- if (open !== "") return {
4744
- kind: "open",
4745
- url: open
4965
+ size: stats.size,
4966
+ mediaType,
4967
+ data: data.toString("base64")
4968
+ };
4969
+ if (data.includes(0)) return {
4970
+ kind: "binary",
4971
+ size: stats.size
4746
4972
  };
4747
- await git(cwd, [
4748
- "fetch",
4749
- "origin",
4750
- base
4751
- ]);
4752
- const commits = (await git(cwd, [
4753
- "log",
4754
- "--format=- %B",
4755
- `origin/${base}..HEAD`
4756
- ])).stdout.trim();
4757
- if (commits === "") throw new Error(`No commits between ${base} and ${head}`);
4758
- const diff = (await git(cwd, [
4759
- "diff",
4760
- "--no-color",
4761
- "--no-ext-diff",
4762
- `origin/${base}...HEAD`
4763
- ])).stdout.slice(0, DIFF_LIMIT);
4764
- const lines = (await generateText(cwd, PULL_REQUEST_INSTRUCTIONS, `Commits between base and head:\n${commits}\n\n${diff}\n\nGenerate the pull request Markdown document from the above.`)).split("\n");
4765
- const title = lines[0].trim().replace(/^#{1,6}\s*/, "").replace(/^(\*\*|__|\*|_|`)+/, "").replace(/(\*\*|__|\*|_|`)+$/, "").trim();
4766
- if (title === "") throw new Error("The model returned an empty title");
4767
4973
  return {
4768
- kind: "draft",
4769
- base,
4770
- head,
4771
- title,
4772
- body: lines.slice(1).join("\n").trim()
4974
+ kind: "text",
4975
+ size: stats.size,
4976
+ content: data.toString("utf8")
4773
4977
  };
4774
4978
  }
4775
- async function createPullRequest(cwd, request) {
4776
- const url = (await run$1("gh", [
4777
- "pr",
4778
- "create",
4779
- "--title",
4780
- request.title,
4781
- "--body",
4782
- request.body,
4783
- "--base",
4784
- request.base,
4785
- "--head",
4786
- request.head,
4787
- "--assignee",
4788
- "@me"
4789
- ], { cwd })).stdout.trim().split("\n").at(-1);
4790
- if (url === void 0) throw new Error("gh did not return a pull request URL");
4791
- return { url };
4792
- }
4793
- async function commitMessage(cwd) {
4794
- const diff = (await repoDiff(cwd, "")).slice(0, DIFF_LIMIT);
4795
- if (diff === "") throw new Error("There is nothing to commit");
4796
- return generateText(cwd, "You write concise git commit messages.", `Write a git commit message for this diff. Reply with only the message: a short summary line, optionally followed by a blank line and a brief body.\n\n${diff}`);
4797
- }
4798
- async function generateText(cwd, instructions, prompt) {
4799
- const settings = readConfig().commitMessage;
4800
- const provider = settings.provider;
4801
- const accountId = settings.accountId;
4802
- const model = settings.model;
4803
- if (provider === null || model === null) throw new Error("Choose a model in Settings → Git");
4804
- assertEnabled(provider, accountId);
4805
- if (provider === "codex") return codexText(accountId, cwd, model, instructions, prompt);
4806
- if (provider === "opencode") return openCodeText(cwd, model, `${instructions}\n\n${prompt}`);
4807
- const conversation = query({
4808
- prompt,
4809
- options: {
4810
- ...claudeOptions(accountId, cwd),
4811
- systemPrompt: instructions,
4812
- tools: [],
4813
- model,
4814
- maxTurns: 1,
4815
- persistSession: false
4816
- }
4817
- });
4818
- for await (const message of conversation) {
4819
- if (message.type !== "result") continue;
4820
- if (message.subtype !== "success") throw new Error(message.errors.join("\n"));
4821
- if (message.is_error) throw new Error(message.result);
4822
- return message.result.trim();
4823
- }
4824
- throw new Error("Claude did not reply");
4979
+ function linkProjectMedia(cwd, file) {
4980
+ const resolved = path.resolve(realpathSync(cwd), file);
4981
+ if (!existsSync(resolved)) throw new Error(`${file} does not exist`);
4982
+ const full = realpathSync(resolved);
4983
+ const mediaType = mediaTypes.get(path.extname(full).toLowerCase());
4984
+ if (mediaType === void 0) throw new Error(`${file} is not an image, video or audio file`);
4985
+ return addLink(full, mediaType, null);
4825
4986
  }
4826
- async function projectDiff(cwd) {
4827
- const repos = await projectRepos(cwd);
4828
- if (repos.length === 0) throw new Error(`${cwd} is not a git repository and has none inside it`);
4829
- return (await Promise.all(repos.map((repo) => repoDiff(path.join(cwd, repo), repo === "" ? "" : `${repo}/`)))).join("");
4987
+ function linkProjectDownload(cwd, file) {
4988
+ return addLink(resolveProjectFile(cwd, file), "application/octet-stream", path.basename(file));
4830
4989
  }
4831
- function git(cwd, args) {
4832
- return run$1("git", args, {
4833
- cwd,
4834
- maxBuffer: 67108864
4990
+ function addLink(file, mediaType, download) {
4991
+ for (const [token, link] of mediaLinks) if (link.expiresAt <= Date.now()) mediaLinks.delete(token);
4992
+ const token = randomBytes(32).toString("base64url");
4993
+ mediaLinks.set(token, {
4994
+ file,
4995
+ mediaType,
4996
+ download,
4997
+ expiresAt: Date.now() + MEDIA_LINK_LIFETIME
4835
4998
  });
4999
+ return token;
4836
5000
  }
4837
- async function projectRepos(cwd) {
4838
- return await isRepo(cwd) ? [""] : childRepos(cwd, "", REPO_DEPTH);
5001
+ function findMediaLink(token) {
5002
+ if (token === null) return null;
5003
+ const link = mediaLinks.get(token);
5004
+ if (link === void 0 || link.expiresAt <= Date.now()) return null;
5005
+ return link;
4839
5006
  }
4840
- async function gitStatus(cwd) {
4841
- const output = await git(cwd, [
4842
- "--no-optional-locks",
4843
- "status",
4844
- "--porcelain=v2",
4845
- "--branch"
4846
- ]);
4847
- const status = {
4848
- branch: null,
4849
- upstream: null,
4850
- remote: (await git(cwd, ["remote"])).stdout.trim() !== "",
4851
- ahead: 0,
4852
- behind: 0,
4853
- changed: 0
4854
- };
4855
- for (const line of output.stdout.split("\n").filter(Boolean)) {
4856
- const [marker, key, ...rest] = line.split(" ");
4857
- if (marker !== "#") status.changed++;
4858
- else if (key === "branch.head") status.branch = rest[0] === "(detached)" ? null : rest[0];
4859
- else if (key === "branch.upstream") status.upstream = rest[0];
4860
- else if (key === "branch.ab") {
4861
- status.ahead = Number(rest[0]);
4862
- status.behind = -Number(rest[1]);
4863
- }
4864
- }
4865
- return status;
5007
+ function saveUpload(name, data) {
5008
+ const dir = path.join(home, "uploads", randomUUID());
5009
+ mkdirSync(dir, { recursive: true });
5010
+ const file = path.join(dir, path.basename(name));
5011
+ writeFileSync(file, data);
5012
+ return file;
4866
5013
  }
4867
- async function rebaseOnUpstream(cwd) {
5014
+ async function taskOutput(sessionId, file) {
5015
+ const tasks = path.dirname(file);
5016
+ if (!/^[\w-]+$/.test(sessionId) || !path.isAbsolute(file) || path.normalize(file) !== file || !/^\w+\.output$/.test(path.basename(file)) || path.basename(tasks) !== "tasks" || path.basename(path.dirname(tasks)) !== sessionId) throw new Error("Not a background task output file of this thread");
5017
+ let stats;
4868
5018
  try {
4869
- await git(cwd, ["rebase", "@{upstream}"]);
5019
+ stats = await lstat(file);
4870
5020
  } catch (error) {
4871
- const rebasing = await git(cwd, [
4872
- "rev-parse",
4873
- "--git-path",
4874
- "rebase-merge"
4875
- ]);
4876
- if (existsSync(path.resolve(cwd, rebasing.stdout.trim()))) {
4877
- await git(cwd, ["rebase", "--abort"]);
4878
- throw new Error("Your commits conflict with the remote branch, so nothing was pulled or pushed. Resolve it in a terminal.", { cause: error });
4879
- }
5021
+ if (hasCode(error, "ENOENT")) return null;
4880
5022
  throw error;
4881
5023
  }
4882
- }
4883
- function gitError(error) {
4884
- return new Error((error instanceof Error ? error.message : String(error)).split("\n").filter((line) => line.trim() !== "" && !line.startsWith("hint:") && !line.startsWith("Command failed:")).join("\n"), { cause: error });
4885
- }
4886
- async function isRepo(cwd) {
4887
- try {
4888
- await git(cwd, ["rev-parse", "--is-inside-work-tree"]);
4889
- return true;
4890
- } catch {
4891
- return false;
4892
- }
4893
- }
4894
- async function repoDiff(repo, prefix) {
4895
- const tracked = await git(repo, [
4896
- "--no-optional-locks",
4897
- "diff",
4898
- await diffBase(repo),
4899
- "--no-color",
4900
- "--no-ext-diff",
4901
- `--src-prefix=a/${prefix}`,
4902
- `--dst-prefix=b/${prefix}`
4903
- ]);
4904
- const added = (await git(repo, [
4905
- "ls-files",
4906
- "--others",
4907
- "--exclude-standard"
4908
- ])).stdout.split("\n").filter(Boolean).slice(0, UNTRACKED_LIMIT).map((file) => newFilePatch(repo, file, prefix));
4909
- return [tracked.stdout, ...added].join("");
4910
- }
4911
- async function diffBase(repo) {
4912
- try {
4913
- await git(repo, [
4914
- "rev-parse",
4915
- "--verify",
4916
- "--quiet",
4917
- "HEAD"
4918
- ]);
4919
- return "HEAD";
4920
- } catch (error) {
4921
- if (!(error instanceof Error && "code" in error && error.code === 1)) throw error;
4922
- }
4923
- return (await git(repo, [
4924
- "hash-object",
4925
- "-t",
4926
- "tree",
4927
- "/dev/null"
4928
- ])).stdout.trim();
4929
- }
4930
- function childRepos(root, dir, depth) {
4931
- if (depth === 0) return [];
4932
- let entries;
5024
+ if (!stats.isFile()) throw new Error("Background task output is not a regular file");
5025
+ const start = Math.max(0, stats.size - OUTPUT_LIMIT$1);
5026
+ const handle = await open(file, "r");
4933
5027
  try {
4934
- entries = readdirSync(path.join(root, dir), { withFileTypes: true });
4935
- } catch {
4936
- return [];
5028
+ const buffer = Buffer.alloc(stats.size - start);
5029
+ const read = await handle.read(buffer, 0, buffer.length, start);
5030
+ return {
5031
+ text: buffer.subarray(0, read.bytesRead).toString("utf8"),
5032
+ truncated: start > 0
5033
+ };
5034
+ } finally {
5035
+ await handle.close();
4937
5036
  }
4938
- return entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules").flatMap((entry) => {
4939
- const relative = dir === "" ? entry.name : `${dir}/${entry.name}`;
4940
- return existsSync(path.join(root, relative, ".git")) ? [relative] : childRepos(root, relative, depth - 1);
4941
- });
4942
5037
  }
4943
- function newFilePatch(cwd, file, prefix) {
4944
- const name = `${prefix}${file}`;
4945
- const header = `diff --git a/${name} b/${name}\nnew file\n--- /dev/null\n+++ b/${name}\n`;
4946
- const full = path.join(cwd, file);
4947
- const stats = lstatSync(full);
4948
- if (stats.isSymbolicLink()) return `${header}@@ -0,0 +1 @@\n+${readlinkSync(full)}\n`;
4949
- if (stats.isDirectory()) return `${header}Nested repository\n`;
4950
- if (stats.size > NEW_FILE_LIMIT) return `${header}Binary or large file\n`;
4951
- const content = readFileSync(full, "utf8");
4952
- if (content.includes("\0")) return `${header}Binary or large file\n`;
4953
- const lines = content.split("\n");
4954
- if (lines.at(-1) === "") lines.pop();
4955
- return `${header}@@ -0,0 +1,${lines.length} @@\n${lines.map((line) => `+${line}\n`).join("")}`;
5038
+ function resolveProjectFile(cwd, file) {
5039
+ const root = realpathSync(cwd);
5040
+ const resolved = path.resolve(root, file);
5041
+ if (!existsSync(resolved)) throw new Error(`${file} does not exist`);
5042
+ const full = realpathSync(resolved);
5043
+ const relative = path.relative(root, full);
5044
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error(`${file} is outside the project`);
5045
+ return full;
4956
5046
  }
4957
5047
  //#endregion
4958
5048
  //#region src/services/runs.ts