@saadnvd1/agent-os 0.3.810 → 0.3.817

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 (59) hide show
  1. package/README.md +3 -2
  2. package/app/api/files/upload-temp/route.test.ts +55 -3
  3. package/app/api/files/upload-temp/route.ts +15 -10
  4. package/app/api/reach/probe/route.test.ts +22 -0
  5. package/app/api/reach/probe/route.ts +12 -0
  6. package/app/api/reach/route.ts +30 -0
  7. package/app/api/sessions/list-size.test.ts +77 -0
  8. package/app/api/sessions/route.ts +21 -10
  9. package/app/api/tasks/list-size.test.ts +50 -0
  10. package/app/api/tasks/route.ts +20 -3
  11. package/components/Chat/FileChip.tsx +8 -4
  12. package/components/Chat/ViewSwitch.tsx +2 -1
  13. package/components/Chat/composer/paste.ts +5 -2
  14. package/components/Chat/composer/useAttachFiles.ts +8 -5
  15. package/components/Devices/NetworkSection.tsx +2 -0
  16. package/components/Pane/index.tsx +4 -2
  17. package/components/Reach/ReachRow.tsx +137 -0
  18. package/components/Settings/SetupSection.tsx +2 -0
  19. package/components/Welcome/ConnectBlock.tsx +2 -0
  20. package/content/tips.json +10 -0
  21. package/content/whats-new.json +40 -0
  22. package/data/devices/queries.ts +6 -1
  23. package/data/reach/index.ts +33 -0
  24. package/lib/attachments/check.ts +2 -5
  25. package/lib/attachments/har.ts +122 -0
  26. package/lib/attachments/message.ts +24 -2
  27. package/lib/attachments/policy.ts +104 -9
  28. package/lib/attachments/prepare.ts +100 -0
  29. package/lib/attachments/store.ts +7 -1
  30. package/lib/config/client-config-types.ts +2 -0
  31. package/lib/connect/serve.ts +22 -3
  32. package/lib/connect/supervise.ts +33 -9
  33. package/lib/db/queries.ts +31 -4
  34. package/lib/db/types.ts +3 -1
  35. package/lib/guides.ts +6 -0
  36. package/lib/hosts/remote-menu.ts +4 -2
  37. package/lib/reach/check-cli.ts +117 -0
  38. package/lib/reach/checkReach.ts +62 -0
  39. package/lib/reach/classify.ts +283 -0
  40. package/lib/reach/cli.ts +38 -0
  41. package/lib/reach/gatherFacts.ts +167 -0
  42. package/lib/reach/index.ts +4 -0
  43. package/lib/reach/probeConnect.ts +122 -0
  44. package/lib/reach/types.ts +96 -0
  45. package/lib/security/auth.ts +2 -0
  46. package/lib/security/demo.ts +2 -0
  47. package/lib/sessions/has-prompt.ts +7 -0
  48. package/lib/sessions/omit-null.ts +8 -0
  49. package/lib/tasks/contract/views.ts +12 -0
  50. package/lib/tasks/index.ts +9 -5
  51. package/lib/tasks/session.ts +15 -1
  52. package/lib/tips/view.ts +2 -4
  53. package/package.json +1 -1
  54. package/scripts/agent-os +3 -0
  55. package/scripts/install.sh +12 -1
  56. package/scripts/lib/commands.sh +59 -65
  57. package/scripts/lib/service.sh +230 -0
  58. package/scripts/lib/setup.sh +303 -0
  59. package/scripts/lib/setup.test.ts +389 -0
package/README.md CHANGED
@@ -55,16 +55,17 @@ You need Node.js 22+, git, tmux, and at least one agent CLI (the installer offer
55
55
  ```bash
56
56
  npm install -g @saadnvd1/agent-os
57
57
  agent-os install # checks for tmux and ripgrep, installs what's missing, builds the app
58
- agent-os start # runs in the background on http://localhost:3011
58
+ agent-os setup # keeps it running, chooses how your phone reaches it, adds the phone
59
59
  ```
60
60
 
61
61
  Without Node.js, the install script sets up everything:
62
62
 
63
63
  ```bash
64
64
  curl -fsSL https://raw.githubusercontent.com/saadnvd1/agent-os/main/scripts/install.sh | bash
65
- agent-os start
66
65
  ```
67
66
 
67
+ It ends by offering `agent-os setup`. If another device can't reach the machine later, `agent-os check` names the cause and the fix ([guide](docs/guides/reachability.md)).
68
+
68
69
  Then open AgentOS, add a project from ⌘K, and press ⌥N. The [install guide](docs/guides/install.md) covers Windows (WSL2), the supported agents, first run and the `agent-os` commands. To use it from your phone, see the [phone guide](docs/guides/phone.md).
69
70
 
70
71
  ## Connect
@@ -68,7 +68,7 @@ describe("POST /api/files/upload-temp", () => {
68
68
  });
69
69
 
70
70
  it("refuses types it doesn't take, saying which", async () => {
71
- for (const name of ["a.zip", "run.sh", "tool.exe", "noext"]) {
71
+ for (const name of ["a.zip", "tool.exe", "noext", "clip.mov"]) {
72
72
  const res = await upload(name, "x", { sessionId: SESSION });
73
73
  expect(res.status).toBe(415);
74
74
  expect((await res.json()).error).toContain(`${name}: this file type`);
@@ -105,11 +105,61 @@ describe("POST /api/files/upload-temp", () => {
105
105
 
106
106
  it("refuses a file over the limit, and says what the limit is", async () => {
107
107
  const big = Buffer.alloc(ATTACHMENT_LIMITS.maxBytes + 1, 97);
108
- const res = await upload("big.txt", big, { sessionId: SESSION });
108
+ const res = await upload("big.csv", big, { sessionId: SESSION });
109
109
  expect(res.status).toBe(413);
110
110
  expect((await res.json()).error).toContain("the limit is 25.0 MB");
111
111
  });
112
112
 
113
+ it("takes shell scripts, config and extensionless names as text", async () => {
114
+ for (const name of ["run.sh", "Dockerfile", ".env.example", "main.tf"]) {
115
+ const res = await upload(name, "echo hi\n", { sessionId: SESSION });
116
+ expect(res.status, name).toBe(200);
117
+ expect((await res.json()).kind).toBe("text");
118
+ }
119
+ });
120
+
121
+ it("names what is accepted when it refuses a type", async () => {
122
+ const res = await upload("a.zip", "x", { sessionId: SESSION });
123
+ expect((await res.json()).error).toMatch(/HAR.*HEIC|HEIC.*HAR/);
124
+ });
125
+
126
+ it("redacts a HAR on the way in and keeps a summary beside it", async () => {
127
+ const secret = ["s", "3", "cr", "et"].join("") + Date.now();
128
+ const har = JSON.stringify({
129
+ log: {
130
+ entries: [
131
+ {
132
+ request: {
133
+ method: "GET",
134
+ url: "https://api.example.com/me",
135
+ headers: [{ name: "Authorization", value: `Bearer ${secret}` }],
136
+ cookies: [{ name: "sid", value: secret }],
137
+ },
138
+ response: { status: 500, headers: [], cookies: [] },
139
+ },
140
+ ],
141
+ },
142
+ });
143
+ const res = await upload("trace.har", har, { sessionId: SESSION });
144
+ expect(res.status).toBe(200);
145
+ const body = await res.json();
146
+ expect(body.mediaType).toBe("application/json");
147
+ const saved = fs.readFileSync(body.path, "utf8");
148
+ expect(saved).not.toContain(secret);
149
+ expect(saved).toContain("api.example.com/me");
150
+ expect(fs.readFileSync(`${body.path}.summary.txt`, "utf8")).toContain(
151
+ "1 requests"
152
+ );
153
+ });
154
+
155
+ it("refuses a .har that isn't a HAR", async () => {
156
+ for (const data of ["not json", '{"log":{}}']) {
157
+ const res = await upload("x.har", data, { sessionId: SESSION });
158
+ expect(res.status).toBe(400);
159
+ expect((await res.json()).error).toContain("real HAR");
160
+ }
161
+ });
162
+
113
163
  it("refuses an image over the image limit", async () => {
114
164
  const big = Buffer.concat([
115
165
  PNG,
@@ -122,7 +172,9 @@ describe("POST /api/files/upload-temp", () => {
122
172
  const res = await POST(
123
173
  new Request("http://localhost/x", {
124
174
  method: "POST",
125
- headers: { "content-length": String(ATTACHMENT_LIMITS.maxBytes * 2) },
175
+ headers: {
176
+ "content-length": String(ATTACHMENT_LIMITS.maxTextBytes * 2),
177
+ },
126
178
  body: "{}",
127
179
  })
128
180
  );
@@ -4,11 +4,13 @@ import path from "path";
4
4
  import os from "os";
5
5
  import { checkUpload } from "@/lib/attachments/check";
6
6
  import { ATTACHMENT_LIMITS, tooBigMessage } from "@/lib/attachments/policy";
7
+ import { prepareUpload } from "@/lib/attachments/prepare";
7
8
  import { saveAttachment } from "@/lib/attachments/store";
8
9
  import { peerChatLink } from "@/lib/chat/peer-relay";
9
10
 
10
11
  // Base64 is a third bigger than the bytes, and the JSON around it is small.
11
- const MAX_BODY = Math.ceil((ATTACHMENT_LIMITS.maxBytes * 4) / 3) + 64 * 1024;
12
+ const MAX_BODY =
13
+ Math.ceil((ATTACHMENT_LIMITS.maxTextBytes * 4) / 3) + 64 * 1024;
12
14
 
13
15
  const fail = (error: string, status: number) =>
14
16
  NextResponse.json({ error }, { status });
@@ -34,6 +36,8 @@ export async function POST(request: Request) {
34
36
  const buffer = Buffer.from(base64, "base64");
35
37
  const checked = checkUpload(name, buffer);
36
38
  if (!checked.ok) return fail(checked.error, checked.status);
39
+ const prepared = await prepareUpload(checked.type, name, buffer);
40
+ if (!prepared.ok) return fail(prepared.error, prepared.status);
37
41
 
38
42
  if (typeof sessionId === "string" && sessionId) {
39
43
  if (sessionId !== "draft" && peerChatLink(sessionId))
@@ -43,25 +47,26 @@ export async function POST(request: Request) {
43
47
  );
44
48
  const file = saveAttachment(
45
49
  sessionId,
46
- name,
47
- buffer,
48
- checked.type.mediaType
50
+ prepared.name,
51
+ prepared.data,
52
+ prepared.mediaType,
53
+ prepared.summary
49
54
  );
50
- return NextResponse.json({ ...file, kind: checked.type.kind });
55
+ return NextResponse.json({ ...file, kind: prepared.kind });
51
56
  }
52
57
 
53
58
  const tempDir = path.join(os.tmpdir(), "agent-os-screenshots");
54
59
  await mkdir(tempDir, { recursive: true });
55
- const safeName = name.replace(/[^a-zA-Z0-9.-]/g, "_");
60
+ const safeName = prepared.name.replace(/[^a-zA-Z0-9.-]/g, "_");
56
61
  const filePath = path.join(tempDir, `${Date.now()}-${safeName}`);
57
- await writeFile(filePath, buffer);
62
+ await writeFile(filePath, prepared.data);
58
63
 
59
64
  return NextResponse.json({
60
65
  path: filePath,
61
66
  name: safeName,
62
- size: buffer.length,
63
- mediaType: checked.type.mediaType,
64
- kind: checked.type.kind,
67
+ size: prepared.data.length,
68
+ mediaType: prepared.mediaType,
69
+ kind: prepared.kind,
65
70
  });
66
71
  } catch (error) {
67
72
  console.error("Upload error:", error);
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { GET } from "./route";
3
+
4
+ // Public before pairing: it may only ever say "AgentOS, this boot".
5
+ describe("GET /api/reach/probe", () => {
6
+ it("answers the same two fields to everyone, all boot long, uncached", async () => {
7
+ const a = GET();
8
+ const b = GET();
9
+ const body = await a.json();
10
+ expect(Object.keys(body).sort()).toEqual(["boot", "ok"]);
11
+ expect(body).toEqual({
12
+ ok: true,
13
+ boot: expect.stringMatching(/^[0-9a-f]{16}$/),
14
+ });
15
+ expect(await b.json()).toEqual(body);
16
+ expect(a.headers.get("cache-control")).toBe("no-store");
17
+ });
18
+
19
+ it("takes no input", () => {
20
+ expect(GET.length).toBe(0);
21
+ });
22
+ });
@@ -0,0 +1,12 @@
1
+ import { NextResponse } from "next/server";
2
+ import { bootId } from "@/lib/reach/probeConnect";
3
+
4
+ // GET /api/reach/probe - public: the same answer for anyone, all boot long.
5
+ // This machine fetches it through its own Connect address to prove the
6
+ // answer came from this process, not a cache or another AgentOS.
7
+ export function GET() {
8
+ return NextResponse.json(
9
+ { ok: true, boot: bootId() },
10
+ { headers: { "Cache-Control": "no-store" } }
11
+ );
12
+ }
@@ -0,0 +1,30 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { checkReach } from "@/lib/reach/checkReach";
3
+ import { classifyReach } from "@/lib/reach/classify";
4
+ import { demoMode } from "@/lib/security/demo";
5
+ import type { ReachRoute } from "@/lib/reach/types";
6
+
7
+ const ROUTES = new Set<string>(["connect", "tailscale", "lan"]);
8
+
9
+ // GET /api/reach[?fresh][&want=lan,connect] - can other devices reach this
10
+ // machine, and if not, why. `want` adds routes the caller means to use.
11
+ export async function GET(request: NextRequest) {
12
+ if (demoMode()) return NextResponse.json(demoReach());
13
+ const params = request.nextUrl.searchParams;
14
+ const want = (params.get("want") ?? "")
15
+ .split(",")
16
+ .filter((r): r is ReachRoute => ROUTES.has(r));
17
+ return NextResponse.json(await checkReach(params.has("fresh"), want));
18
+ }
19
+
20
+ // Nothing about the machine a demo runs on.
21
+ function demoReach() {
22
+ return classifyReach({
23
+ now: Date.now(),
24
+ port: 3011,
25
+ lan: { on: false, addresses: [] },
26
+ firewall: "unknown",
27
+ tailscale: { state: "missing" },
28
+ connect: { state: "off", linked: false, enabled: false },
29
+ });
30
+ }
@@ -0,0 +1,77 @@
1
+ import { randomUUID } from "crypto";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+
4
+ const { db, insertSessionRow } = await import("@/lib/db");
5
+ const { GET } = await import("./route");
6
+
7
+ type Row = Record<string, unknown> & { id: string };
8
+
9
+ function seed(extra: Record<string, unknown> = {}): string {
10
+ const id = randomUUID();
11
+ insertSessionRow(db, {
12
+ id,
13
+ name: "n",
14
+ tmux_name: `claude-${id}`,
15
+ working_directory: "/tmp",
16
+ ...extra,
17
+ });
18
+ return id;
19
+ }
20
+
21
+ const list = async (query = "") => {
22
+ const res = await GET({
23
+ nextUrl: new URL(`http://x/api/sessions${query}`),
24
+ } as never);
25
+ return (await res.json()).sessions as Row[];
26
+ };
27
+
28
+ beforeEach(() => {
29
+ db.prepare(`DELETE FROM sessions`).run();
30
+ });
31
+
32
+ describe("GET /api/sessions payload", () => {
33
+ it("never carries the full prompt, the chat context or the orchestrator token", async () => {
34
+ const id = seed({
35
+ task_status: "running",
36
+ task_prompt: "x".repeat(5000),
37
+ chat_context: "ctx",
38
+ orch_token: "secret-token",
39
+ });
40
+ const row = (await list()).find((r) => r.id === id)!;
41
+ // Installed phone builds read !!task_prompt, so a short prefix stays.
42
+ expect(row.task_prompt).toBe("x".repeat(80));
43
+ expect(row).not.toHaveProperty("chat_context");
44
+ expect(row).not.toHaveProperty("orch_token");
45
+ expect(row.has_prompt).toBe(1);
46
+ });
47
+
48
+ it("says has_prompt 0 for a plain session", async () => {
49
+ const id = seed();
50
+ expect((await list()).find((r) => r.id === id)!.has_prompt).toBe(0);
51
+ });
52
+
53
+ it("leaves null fields out", async () => {
54
+ const id = seed();
55
+ const row = (await list()).find((r) => r.id === id)!;
56
+ expect(Object.values(row)).not.toContain(null);
57
+ expect(row).not.toHaveProperty("pr_url");
58
+ });
59
+
60
+ it("lists the 100 most recent merged tasks, all with ?done=all", async () => {
61
+ for (let i = 0; i < 103; i++) {
62
+ const day = String(i + 1).padStart(3, "0");
63
+ seed({
64
+ task_status: "merged",
65
+ merged_at: `2026-01-01 00:00:${i % 60}`.replace(/:(\d)$/, ":0$1"),
66
+ updated_at: `2026-02-01 00:00:00`,
67
+ name: `done-${day}`,
68
+ });
69
+ }
70
+ const live = seed({ task_status: "running" });
71
+ const capped = await list();
72
+ expect(capped.filter((r) => r.task_status === "merged")).toHaveLength(100);
73
+ expect(capped.map((r) => r.id)).toContain(live);
74
+ const all = await list("?done=all");
75
+ expect(all.filter((r) => r.task_status === "merged")).toHaveLength(103);
76
+ });
77
+ });
@@ -10,9 +10,14 @@ import { ModelUnavailableError } from "@/lib/models/agent-binding";
10
10
  import { namesLine, waitBadges } from "@/lib/bus/waits/store";
11
11
  import { claimsSnapshot } from "@/lib/claims";
12
12
  import { isProjectRef } from "@/lib/tasks/project-ref";
13
+ import { omitNulls } from "@/lib/sessions/omit-null";
14
+
15
+ // The sidebar's Done shelf lists this many finished tasks; ?done=all lists
16
+ // every one.
17
+ const DONE_LISTED = 100;
13
18
 
14
19
  // GET /api/sessions - List the sidebar's sessions and the user's groups
15
- export async function GET() {
20
+ export async function GET(request?: NextRequest) {
16
21
  try {
17
22
  const db = getDb();
18
23
  // Merged tasks are listed (the sidebar files them under Done). Dropped
@@ -20,12 +25,18 @@ export async function GET() {
20
25
  // and archived sessions in the Archived view: none is in the sidebar.
21
26
  const badges = waitBadges();
22
27
  const claims = claimsSnapshot();
23
- const sessions = (queries.getSidebarSessions(db).all() as Session[]).map(
24
- (s) => {
25
- const b = badges.get(s.id);
26
- const overlap = claims.bySession.get(s.id);
27
- const row = overlap ? { ...s, overlap } : s;
28
- return b
28
+ const all = request?.nextUrl.searchParams.get("done") === "all";
29
+ const rows = (
30
+ all
31
+ ? queries.getSidebarSessions(db).all()
32
+ : queries.getSidebarSessionsCapped(db, DONE_LISTED).all()
33
+ ) as Session[];
34
+ const sessions = rows.map((s) => {
35
+ const b = badges.get(s.id);
36
+ const overlap = claims.bySession.get(s.id);
37
+ const row = overlap ? { ...s, overlap } : s;
38
+ return omitNulls(
39
+ b
29
40
  ? {
30
41
  ...row,
31
42
  waiting_on: b.on.length
@@ -35,9 +46,9 @@ export async function GET() {
35
46
  ? namesLine(b.by.map((w) => w.name))
36
47
  : null,
37
48
  }
38
- : row;
39
- }
40
- );
49
+ : row
50
+ );
51
+ });
41
52
  return NextResponse.json({
42
53
  sessions,
43
54
  sessionGroups: listGroups(),
@@ -0,0 +1,50 @@
1
+ import { randomUUID } from "crypto";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ const { db, insertSessionRow } = await import("@/lib/db");
5
+ const { GET } = await import("./route");
6
+
7
+ describe("GET /api/tasks payload", () => {
8
+ it("lists hasPrompt, not the prompt text, and leaves null fields out", async () => {
9
+ const id = randomUUID();
10
+ insertSessionRow(db, {
11
+ id,
12
+ name: "t",
13
+ tmux_name: `claude-${id}`,
14
+ working_directory: "/tmp",
15
+ task_status: "running",
16
+ task_prompt: "p".repeat(5000),
17
+ });
18
+ const { tasks } = await (await GET()).json();
19
+ const t = tasks.find((x: { id: string }) => x.id === id);
20
+ expect(t.hasPrompt).toBe(true);
21
+ expect(t).not.toHaveProperty("prompt");
22
+ expect(JSON.stringify(t)).not.toContain("ppppp");
23
+ expect(Object.values(t)).not.toContain(null);
24
+ });
25
+ });
26
+
27
+ describe("taskSessions with a merged limit", () => {
28
+ it("keeps the most recently merged ones and every unmerged one", async () => {
29
+ const { taskSessions } = await import("@/lib/tasks/session");
30
+ const add = (status: string, merged_at: string | null) => {
31
+ const id = randomUUID();
32
+ insertSessionRow(db, {
33
+ id,
34
+ name: "t",
35
+ tmux_name: `claude-${id}`,
36
+ working_directory: "/tmp",
37
+ task_status: status,
38
+ merged_at,
39
+ });
40
+ return id;
41
+ };
42
+ const oldest = add("merged", "2020-01-01 00:00:00");
43
+ const newer = add("merged", "2020-01-03 00:00:00");
44
+ const newest = add("merged", "2020-01-04 00:00:00");
45
+ const running = add("running", null);
46
+ const ids = taskSessions(false, 2).map((s) => s.id);
47
+ expect(ids).toEqual(expect.arrayContaining([newer, newest, running]));
48
+ expect(ids).not.toContain(oldest);
49
+ });
50
+ });
@@ -1,6 +1,6 @@
1
1
  import { parseOverride } from "@/lib/models/resolve";
2
2
  import { NextRequest, NextResponse } from "next/server";
3
- import { createTask, listTasks, movedTasks } from "@/lib/tasks";
3
+ import { createTask, listTasks, movedTasks, type TaskView } from "@/lib/tasks";
4
4
  import { stampPriority, startOrQueue } from "@/lib/tasks/queue";
5
5
  import { isPriority } from "@/lib/tasks/schedule-types";
6
6
  import { TASK_CAPABILITIES } from "@/lib/tasks/contract/types";
@@ -13,12 +13,29 @@ import {
13
13
  } from "@/lib/tasks/move-bundle";
14
14
  import { startView } from "@/lib/sessions/launch";
15
15
  import { isValidAgentType } from "@/lib/providers";
16
+ import { omitNulls } from "@/lib/sessions/omit-null";
16
17
  import { isAgentModelName } from "@/lib/providers/registry";
17
18
 
19
+ // The most recent merged tasks the list holds; ?done=all lists every one.
20
+ const MERGED_LISTED = 100;
21
+
22
+ // A task as the list shows it: no prompt text (a mirrored one from a machine
23
+ // that still sends it included) and no null fields.
24
+ function listed(task: TaskView) {
25
+ const { prompt: _prompt, ...rest } = task;
26
+ return omitNulls(rest);
27
+ }
28
+
18
29
  // moved and capabilities are for another machine that mirrors these tasks.
19
- export async function GET() {
30
+ export async function GET(request?: NextRequest) {
20
31
  return NextResponse.json({
21
- tasks: await listTasks(),
32
+ tasks: (
33
+ await listTasks(
34
+ request?.nextUrl.searchParams.get("done") === "all"
35
+ ? undefined
36
+ : MERGED_LISTED
37
+ )
38
+ ).map(listed),
22
39
  moved: movedTasks(),
23
40
  capabilities: TASK_CAPABILITIES,
24
41
  });
@@ -32,14 +32,18 @@ export function FileChip({
32
32
  uploading?: boolean;
33
33
  onRemove?: () => void;
34
34
  }) {
35
- const Icon = ICONS[typeFor(file.name)?.kind ?? "text"];
35
+ const type = typeFor(file.name);
36
+ const Icon = ICONS[type?.kind ?? "text"];
37
+ const detail = uploading
38
+ ? "Uploading…"
39
+ : [formatBytes(file.size), type?.note].filter(Boolean).join(" · ");
36
40
  return (
37
41
  <div
38
42
  className={cn(
39
43
  "bg-foreground/[0.05] flex h-14 max-w-56 min-w-0 items-center gap-2 rounded-lg pl-3",
40
44
  onRemove ? "pr-1" : "pr-3"
41
45
  )}
42
- title={file.name}
46
+ title={type?.note ? `${file.name} (${type.note})` : file.name}
43
47
  >
44
48
  {uploading ? (
45
49
  <Loader2 className="text-muted-foreground h-4 w-4 shrink-0 animate-spin" />
@@ -48,8 +52,8 @@ export function FileChip({
48
52
  )}
49
53
  <span className="min-w-0">
50
54
  <span className="block truncate text-sm">{file.name}</span>
51
- <span className="text-muted-foreground block text-xs">
52
- {uploading ? "Uploading…" : formatBytes(file.size)}
55
+ <span className="text-muted-foreground block truncate text-xs">
56
+ {detail}
53
57
  </span>
54
58
  </span>
55
59
  {onRemove && (
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { MessageSquare, SquareTerminal } from "lucide-react";
4
+ import { hasPrompt } from "@/lib/sessions/has-prompt";
4
5
  import type { Session } from "@/lib/db";
5
6
  import { supportsChat } from "@/lib/chat/capabilities";
6
7
  import { viewSwitchActions } from "@/stores/viewSwitch";
@@ -15,7 +16,7 @@ export function ViewSwitch({
15
16
  if (!session || !supportsChat(session.agent_type)) return null;
16
17
  if (session.host_id && session.host_id !== "local") return null;
17
18
  // A task's agent runs in its terminal with its brief; chat would end it.
18
- if (session.task_prompt) return null;
19
+ if (hasPrompt(session)) return null;
19
20
  const options = [
20
21
  { view: "chat" as const, icon: MessageSquare, label: "Chat" },
21
22
  { view: "terminal" as const, icon: SquareTerminal, label: "Terminal" },
@@ -14,9 +14,12 @@ export interface PasteHandlers {
14
14
  }
15
15
 
16
16
  // Dragged files sometimes arrive without a type; go by the name then.
17
+ // Only the formats a model takes; HEIC photos and SVG go up as files, where
18
+ // the server converts or keeps them.
19
+ const INLINE_IMAGE = /^image\/(png|jpe?g|gif|webp)$/;
17
20
  export const isImage = (f: File) =>
18
- f.type.startsWith("image/") ||
19
- (!f.type && /\.(png|jpe?g|gif|webp|heic)$/i.test(f.name));
21
+ INLINE_IMAGE.test(f.type) ||
22
+ (!f.type && /\.(png|jpe?g|gif|webp)$/i.test(f.name));
20
23
 
21
24
  function editorMode(data: DataTransfer): string | undefined {
22
25
  try {
@@ -9,6 +9,7 @@ import {
9
9
  } from "@/data/client-config/queries";
10
10
  import {
11
11
  acceptAttr,
12
+ limitFor,
12
13
  tooBigMessage,
13
14
  typeFor,
14
15
  unsupportedMessage,
@@ -83,11 +84,13 @@ export function useAttachFiles({
83
84
  const shrunk = await shrinkImage(f, limits.maxImageBytes);
84
85
  if (shrunk.ok) images.push(shrunk.file);
85
86
  else error = tooBigMessage(f.name, f.size, limits.maxImageBytes);
86
- } else if (!typeFor(f.name, limits.types))
87
- error = unsupportedMessage(f.name);
88
- else if (f.size > limits.maxBytes)
89
- error = tooBigMessage(f.name, f.size, limits.maxBytes);
90
- else others.push(f);
87
+ } else {
88
+ const type = typeFor(f.name, limits.types);
89
+ if (!type) error = unsupportedMessage(f.name);
90
+ else if (f.size > limitFor(type, limits))
91
+ error = tooBigMessage(f.name, f.size, limitFor(type, limits));
92
+ else others.push(f);
93
+ }
91
94
  if (error) toast.error(error);
92
95
  }
93
96
  if (images.length) onImages(await Promise.all(images.map(readImage)));
@@ -4,6 +4,7 @@ import { SettingToggle as Toggle } from "@/components/Settings/SettingToggle";
4
4
  import { useNetworkQuery, useUpdateNetwork } from "@/data/devices";
5
5
  import { TailscaleCard } from "./TailscaleCard";
6
6
  import { ConnectCard } from "./ConnectCard";
7
+ import { ReachRow } from "@/components/Reach/ReachRow";
7
8
 
8
9
  export function NetworkSection({
9
10
  open,
@@ -22,6 +23,7 @@ export function NetworkSection({
22
23
 
23
24
  return (
24
25
  <div className="space-y-2">
26
+ <ReachRow enabled={open} />
25
27
  <ConnectCard
26
28
  connect={data.connect}
27
29
  canManage={canManage}
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { hasPrompt } from "@/lib/sessions/has-prompt";
3
4
  import { useTaskLogQuery } from "@/data/session-logs";
4
5
  import { SetupCard } from "@/components/Chat/SetupCard";
5
6
  import { SignInCard } from "@/components/Terminal/SignInCard";
@@ -217,10 +218,11 @@ export const Pane = memo(function Pane({
217
218
 
218
219
  // A merged task keeps the log AgentOS wrote of it.
219
220
  const merged =
220
- !!session?.task_prompt &&
221
+ !!session &&
222
+ hasPrompt(session) &&
221
223
  (session.task_status === "merged" || session.pr_status === "merged");
222
224
  const hasLog = !!useTaskLogQuery(session?.id, merged).data;
223
- const hasReport = !!session?.task_prompt;
225
+ const hasReport = !!session && hasPrompt(session);
224
226
 
225
227
  // Get current project and its repositories
226
228
  const currentProject = useMemo(() => {