@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
@@ -0,0 +1,137 @@
1
+ "use client";
2
+
3
+ import { Loader2, RefreshCw } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { StatusPill } from "@/components/ui/status-pill";
6
+ import { CopyButton } from "@/components/Chat/CopyButton";
7
+ import { useReachQuery, useRecheckReach } from "@/data/reach";
8
+ import type { ReachFinding, ReachReport, ReachStatus } from "@/lib/reach/types";
9
+ import { cn } from "@/lib/utils";
10
+
11
+ const TONE: Record<ReachStatus, "green" | "amber" | "red"> = {
12
+ ok: "green",
13
+ warn: "amber",
14
+ fail: "red",
15
+ };
16
+
17
+ // Amber has three stories: only retrying, one way in working, or a warning.
18
+ function pillLabel(report: ReachReport, problems: ReachFinding[]): string {
19
+ if (report.status === "ok") return "Reachable";
20
+ if (report.status === "fail") return "Not reachable";
21
+ if (problems.every((f) => f.needs === "nothing")) return "Reconnecting";
22
+ return report.url ? "Partly reachable" : "Needs a look";
23
+ }
24
+
25
+ // A fix is a command when it starts with one; otherwise it's plain advice.
26
+ const isCommand = (fix: string) => /^(agent-os|sudo)( |$)/.test(fix);
27
+
28
+ function Problem({ f }: { f: ReachFinding }) {
29
+ return (
30
+ <li className="flex flex-col gap-1">
31
+ <p className="text-sm font-medium">{f.title}</p>
32
+ {f.needs === "nothing" ? (
33
+ <p className="text-muted-foreground flex items-center gap-1.5 text-xs">
34
+ <Loader2 className="size-3 animate-spin" />
35
+ Retrying on its own, nothing to do
36
+ </p>
37
+ ) : (
38
+ <>
39
+ {f.fix &&
40
+ (isCommand(f.fix) ? (
41
+ <code className="bg-background block rounded px-2 py-1 text-xs break-all">
42
+ {f.fix}
43
+ </code>
44
+ ) : (
45
+ <p className="text-muted-foreground text-xs break-words">
46
+ {f.fix}
47
+ </p>
48
+ ))}
49
+ {f.guide && (
50
+ <a
51
+ href={f.guide}
52
+ target="_blank"
53
+ rel="noopener noreferrer"
54
+ className="text-muted-foreground hover:text-foreground flex min-h-11 w-fit items-center text-xs underline underline-offset-2 sm:min-h-6"
55
+ >
56
+ How to fix
57
+ </a>
58
+ )}
59
+ </>
60
+ )}
61
+ </li>
62
+ );
63
+ }
64
+
65
+ /** Whether other devices can reach this machine, and the fix when they can't. */
66
+ export function ReachRow({
67
+ enabled = true,
68
+ className,
69
+ }: {
70
+ enabled?: boolean;
71
+ className?: string;
72
+ }) {
73
+ const { data, isPending, isError } = useReachQuery(enabled);
74
+ const recheck = useRecheckReach();
75
+ if (isPending)
76
+ return (
77
+ <div
78
+ className={cn("bg-muted/40 h-16 animate-pulse rounded-lg", className)}
79
+ />
80
+ );
81
+ const problems = data?.findings.filter((f) => f.status !== "ok") ?? [];
82
+ const pill = data && {
83
+ tone: TONE[data.status],
84
+ label: pillLabel(data, problems),
85
+ };
86
+ return (
87
+ <div
88
+ className={cn(
89
+ "bg-muted/40 flex flex-col gap-2 rounded-lg px-3 py-2.5",
90
+ className
91
+ )}
92
+ >
93
+ <div className="flex min-h-11 items-center gap-2 sm:min-h-0">
94
+ <p className="min-w-0 flex-1 text-sm font-medium">
95
+ Reachable from other devices
96
+ </p>
97
+ {pill && (
98
+ <StatusPill tone={pill.tone} className="shrink-0 whitespace-nowrap">
99
+ {pill.label}
100
+ </StatusPill>
101
+ )}
102
+ </div>
103
+ {isError && (
104
+ <p className="text-destructive text-xs">Couldn&apos;t run the check.</p>
105
+ )}
106
+ {data?.url && (
107
+ <div className="flex min-w-0 items-center gap-1">
108
+ <span className="text-foreground min-w-0 flex-1 truncate font-mono text-xs">
109
+ {data.url}
110
+ </span>
111
+ <CopyButton
112
+ text={data.url}
113
+ label="Copy link"
114
+ className="h-11 w-11 shrink-0 md:h-7 md:w-7"
115
+ />
116
+ </div>
117
+ )}
118
+ {problems.length > 0 && (
119
+ <ul className="flex flex-col gap-3">
120
+ {problems.map((f, i) => (
121
+ <Problem key={`${f.route ?? ""}-${f.cause ?? i}`} f={f} />
122
+ ))}
123
+ </ul>
124
+ )}
125
+ <Button
126
+ size="sm"
127
+ variant="ghost"
128
+ className="h-11 self-end sm:h-8"
129
+ disabled={recheck.isPending}
130
+ onClick={() => recheck.mutate()}
131
+ >
132
+ <RefreshCw className={recheck.isPending ? "animate-spin" : ""} />
133
+ Check again
134
+ </Button>
135
+ </div>
136
+ );
137
+ }
@@ -3,6 +3,7 @@
3
3
  import { Button } from "@/components/ui/button";
4
4
  import { HealthList } from "@/components/Welcome/HealthList";
5
5
  import { AgentList } from "@/components/Welcome/AgentList";
6
+ import { ReachRow } from "@/components/Reach/ReachRow";
6
7
  import { settingsUiActions } from "@/stores/settingsUi";
7
8
  import { welcomeUiActions } from "@/stores/welcomeUi";
8
9
 
@@ -32,6 +33,7 @@ export function SetupSection() {
32
33
  <div className="flex flex-col gap-2">
33
34
  <h3 className="text-sm font-medium">Health</h3>
34
35
  <HealthList />
36
+ <ReachRow />
35
37
  </div>
36
38
  <div className="flex flex-col gap-2">
37
39
  <h3 className="text-sm font-medium">Agents on this machine</h3>
@@ -18,6 +18,7 @@ import {
18
18
  CONNECT_STATE,
19
19
  connectView,
20
20
  } from "@/lib/connect/view";
21
+ import { ReachRow } from "@/components/Reach/ReachRow";
21
22
  import { Block } from "./Block";
22
23
 
23
24
  const COMMAND = "agent-os connect";
@@ -155,6 +156,7 @@ export function ConnectBlock() {
155
156
  {update.error && (
156
157
  <p className="text-destructive text-xs">{update.error.message}</p>
157
158
  )}
159
+ <ReachRow className="mt-2 px-0" />
158
160
  </div>
159
161
  <Switch
160
162
  checked={wanted ?? view.on}
package/content/tips.json CHANGED
@@ -536,6 +536,16 @@
536
536
  "anchor": "whats-new"
537
537
  },
538
538
  "added": "2026-10-31"
539
+ },
540
+ {
541
+ "id": "reach-check",
542
+ "title": "Can't reach your machine?",
543
+ "body": "Run agent-os check on the computer. It names the cause, like a stopped server or Wi-Fi access being off, and the one line that fixes it.",
544
+ "guide": {
545
+ "file": "reachability",
546
+ "anchor": "run-the-check"
547
+ },
548
+ "added": "2026-10-31"
539
549
  }
540
550
  ]
541
551
  }
@@ -1865,6 +1865,46 @@
1865
1865
  "area": "Updates",
1866
1866
  "text": "Update notices now appear as a small pop-up instead of a bar across the top of the app.",
1867
1867
  "url": "https://github.com/saadnvd1/agent-os/pull/543"
1868
+ },
1869
+ {
1870
+ "id": 544,
1871
+ "date": "2026-10-31",
1872
+ "kind": "new",
1873
+ "area": "AgentOS",
1874
+ "text": "Run agent-os setup to get your phone connected in a few steps, and agent-os check tells you why a device can't reach your computer and how to fix it.",
1875
+ "url": "https://github.com/saadnvd1/agent-os/pull/544"
1876
+ },
1877
+ {
1878
+ "id": 545,
1879
+ "date": "2026-10-31",
1880
+ "kind": "improved",
1881
+ "area": "AgentOS",
1882
+ "text": "The session list loads much faster, especially on a phone.",
1883
+ "url": "https://github.com/saadnvd1/agent-os/pull/545"
1884
+ },
1885
+ {
1886
+ "id": 546,
1887
+ "date": "2026-10-31",
1888
+ "kind": "fixed",
1889
+ "area": "AgentOS",
1890
+ "text": "Session list rows in the installed phone app no longer lose their short task prompt.",
1891
+ "url": "https://github.com/saadnvd1/agent-os/pull/546"
1892
+ },
1893
+ {
1894
+ "id": 547,
1895
+ "date": "2026-10-31",
1896
+ "kind": "fixed",
1897
+ "area": "AgentOS",
1898
+ "text": "The reachability check now tells a stuck AgentOS apart from another program on its port, and setup keeps a copy of a startup file you edited before replacing it.",
1899
+ "url": "https://github.com/saadnvd1/agent-os/pull/547"
1900
+ },
1901
+ {
1902
+ "id": 548,
1903
+ "date": "2026-11-01",
1904
+ "kind": "new",
1905
+ "area": "Chat",
1906
+ "text": "Chats now accept HAR network captures (with logins and cookies hidden), iPhone photos and many more file types.",
1907
+ "url": "https://github.com/saadnvd1/agent-os/pull/548"
1868
1908
  }
1869
1909
  ]
1870
1910
  }
@@ -3,6 +3,7 @@ import type { ListedDevice } from "@/lib/security/devices";
3
3
  import type { NetworkState } from "@/lib/security/network-state";
4
4
  import type { PairingOffer } from "@/lib/security/pair-qr";
5
5
  import { deviceKeys } from "./keys";
6
+ import { reachKeys } from "../reach";
6
7
  import { provePresence } from "../presence";
7
8
 
8
9
  async function json<T>(res: Response): Promise<T> {
@@ -77,7 +78,11 @@ export function useUpdateNetwork() {
77
78
  }),
78
79
  })
79
80
  ),
80
- onSuccess: (data) => queryClient.setQueryData(deviceKeys.network(), data),
81
+ onSuccess: (data) => {
82
+ queryClient.setQueryData(deviceKeys.network(), data);
83
+ // Wi-Fi access or Connect changed: whether devices get in did too.
84
+ void queryClient.invalidateQueries({ queryKey: reachKeys.all });
85
+ },
81
86
  });
82
87
  }
83
88
 
@@ -0,0 +1,33 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { ReachReport } from "@/lib/reach/types";
3
+
4
+ export const reachKeys = {
5
+ all: ["reach"] as const,
6
+ };
7
+
8
+ async function fetchReach(fresh: boolean) {
9
+ const res = await fetch(fresh ? "/api/reach?fresh" : "/api/reach");
10
+ if (!res.ok)
11
+ throw new Error(
12
+ "Couldn't check whether other devices can reach this machine"
13
+ );
14
+ return (await res.json()) as ReachReport;
15
+ }
16
+
17
+ export function useReachQuery(enabled = true) {
18
+ return useQuery({
19
+ queryKey: reachKeys.all,
20
+ queryFn: () => fetchReach(false),
21
+ enabled,
22
+ staleTime: 30_000,
23
+ refetchOnMount: "always",
24
+ });
25
+ }
26
+
27
+ export function useRecheckReach() {
28
+ const client = useQueryClient();
29
+ return useMutation({
30
+ mutationFn: () => fetchReach(true),
31
+ onSuccess: (report) => client.setQueryData(reachKeys.all, report),
32
+ });
33
+ }
@@ -1,6 +1,6 @@
1
1
  import { isText, sniff } from "../intake/sniff";
2
2
  import {
3
- ATTACHMENT_LIMITS,
3
+ limitFor,
4
4
  tooBigMessage,
5
5
  typeFor,
6
6
  unsupportedMessage,
@@ -28,10 +28,7 @@ const bad = (name: string): UploadCheck => ({
28
28
  export function checkUpload(name: string, data: Buffer): UploadCheck {
29
29
  const type = typeFor(name);
30
30
  if (!type) return { ok: false, status: 415, error: unsupportedMessage(name) };
31
- const limit =
32
- type.kind === "image"
33
- ? ATTACHMENT_LIMITS.maxImageBytes
34
- : ATTACHMENT_LIMITS.maxBytes;
31
+ const limit = limitFor(type);
35
32
  if (data.length > limit)
36
33
  return {
37
34
  ok: false,
@@ -0,0 +1,122 @@
1
+ // A HAR is a browser's network log: it carries live cookies and bearer
2
+ // tokens. On the way in the values of the credential headers and every
3
+ // cookie are replaced; names, URLs, timings, bodies and statuses stay.
4
+
5
+ export const REDACTED = "[redacted]";
6
+
7
+ const SECRET_HEADER =
8
+ /^(cookie|set-cookie|authorization|proxy-authorization)$|api[-_]?key|token|secret|^x-(csrf|xsrf)/i;
9
+
10
+ interface Pair {
11
+ name?: unknown;
12
+ value?: unknown;
13
+ }
14
+
15
+ interface Message {
16
+ headers?: Pair[];
17
+ cookies?: Pair[];
18
+ status?: number;
19
+ bodySize?: number;
20
+ content?: { size?: number };
21
+ }
22
+
23
+ interface Entry {
24
+ request?: Message & { method?: string; url?: string };
25
+ response?: Message;
26
+ time?: number;
27
+ }
28
+
29
+ export interface HarResult {
30
+ json: string;
31
+ redacted: number;
32
+ summary: string;
33
+ }
34
+
35
+ function scrub(list: Pair[] | undefined, always: boolean): number {
36
+ let n = 0;
37
+ if (!Array.isArray(list)) return n;
38
+ for (const p of list) {
39
+ if (!p || typeof p !== "object" || p.value === REDACTED) continue;
40
+ if (always || (typeof p.name === "string" && SECRET_HEADER.test(p.name))) {
41
+ p.value = REDACTED;
42
+ n++;
43
+ }
44
+ }
45
+ return n;
46
+ }
47
+
48
+ const hostOf = (url: string) => {
49
+ try {
50
+ return new URL(url).host;
51
+ } catch {
52
+ return "(unparsed)";
53
+ }
54
+ };
55
+
56
+ const top = (counts: Map<string, number>, n: number) =>
57
+ [...counts].sort((a, b) => b[1] - a[1]).slice(0, n);
58
+
59
+ function summarize(entries: Entry[]): string {
60
+ const hosts = new Map<string, number>();
61
+ const statuses = new Map<string, number>();
62
+ const rows = entries.map((e) => {
63
+ const url = e.request?.url ?? "";
64
+ const status = e.response?.status ?? 0;
65
+ const host = hostOf(url);
66
+ hosts.set(host, (hosts.get(host) ?? 0) + 1);
67
+ const klass = status >= 100 ? `${Math.floor(status / 100)}xx` : "failed";
68
+ statuses.set(klass, (statuses.get(klass) ?? 0) + 1);
69
+ const size = Math.max(
70
+ e.response?.content?.size ?? 0,
71
+ e.response?.bodySize ?? 0
72
+ );
73
+ const line = `${e.request?.method ?? "?"} ${url.slice(0, 160)} -> ${status || "no response"}`;
74
+ return { line, size, status, time: e.time ?? 0 };
75
+ });
76
+ const failing = rows.filter((r) => r.status === 0 || r.status >= 400);
77
+ const largest = [...rows].sort((a, b) => b.size - a.size).slice(0, 5);
78
+ const lines = [
79
+ `HAR summary: ${entries.length} requests, ${hosts.size} hosts.`,
80
+ `Hosts: ${top(hosts, 8)
81
+ .map(([h, c]) => `${h} (${c})`)
82
+ .join(", ")}`,
83
+ `Status: ${[...statuses]
84
+ .sort()
85
+ .map(([k, c]) => `${k} ${c}`)
86
+ .join(", ")}`,
87
+ `Largest responses: ${largest.map((r) => `${r.line} [${r.size} B]`).join("; ")}`,
88
+ failing.length
89
+ ? `Failing (${failing.length}${failing.length > 8 ? ", first 8" : ""}): ${failing
90
+ .slice(0, 8)
91
+ .map((r) => r.line)
92
+ .join("; ")}`
93
+ : "Failing: none",
94
+ "Cookie and auth header values were replaced with [redacted].",
95
+ ];
96
+ return lines.join("\n");
97
+ }
98
+
99
+ /** The HAR with its secrets replaced and a short summary, or null when the text isn't a HAR. */
100
+ export function redactHar(text: string): HarResult | null {
101
+ let har: { log?: { entries?: Entry[] } };
102
+ try {
103
+ har = JSON.parse(text);
104
+ } catch {
105
+ return null;
106
+ }
107
+ const entries = har?.log?.entries;
108
+ if (!Array.isArray(entries)) return null;
109
+ let redacted = 0;
110
+ for (const e of entries) {
111
+ for (const m of [e?.request, e?.response]) {
112
+ if (!m || typeof m !== "object") continue;
113
+ redacted += scrub(m.headers, false) + scrub(m.cookies, true);
114
+ }
115
+ }
116
+ // Indented, so the agent can grep and read it by line.
117
+ return {
118
+ json: JSON.stringify(har, null, 1),
119
+ redacted,
120
+ summary: summarize(entries.filter((e) => e && typeof e === "object")),
121
+ };
122
+ }
@@ -1,5 +1,6 @@
1
1
  import fs from "fs";
2
2
  import { ATTACHMENT_LIMITS, formatBytes } from "./policy";
3
+ import { summaryPath } from "./store";
3
4
  import type { ChatFile } from "./types";
4
5
 
5
6
  /**
@@ -8,14 +9,35 @@ import type { ChatFile } from "./types";
8
9
  * spreadsheet through a script, a docx through pandoc). The chat keeps the
9
10
  * plain text and shows the files as chips.
10
11
  */
12
+ // Over this, the agent is told to read in parts.
13
+ const LARGE_BYTES = 1_000_000;
14
+ const SUMMARY_CHARS = 3000;
15
+
16
+ function summaryOf(file: ChatFile): string {
17
+ try {
18
+ return fs
19
+ .readFileSync(summaryPath(file.path), "utf8")
20
+ .slice(0, SUMMARY_CHARS);
21
+ } catch {
22
+ return "";
23
+ }
24
+ }
25
+
11
26
  export function agentText(text: string, files?: ChatFile[]): string {
12
27
  if (!files?.length) return text;
13
28
  const list = files
14
- .map((f) => `- ${f.path} (${f.mediaType}, ${formatBytes(f.size)})`)
29
+ .map((f) => {
30
+ const summary = summaryOf(f);
31
+ const large = f.size > LARGE_BYTES ? ", large: read it in parts" : "";
32
+ return (
33
+ `- ${f.path} (${f.mediaType}, ${formatBytes(f.size)}${large})` +
34
+ (summary ? `\n${summary.replace(/^/gm, " ")}` : "")
35
+ );
36
+ })
15
37
  .join("\n");
16
38
  return [
17
39
  text,
18
- `Attached files, saved on this machine. Open them with your own tools:\n${list}`,
40
+ `Attached files, saved on this machine. They are never pasted into this message; open them with your own tools (grep or head a big one rather than reading it whole):\n${list}`,
19
41
  ]
20
42
  .filter(Boolean)
21
43
  .join("\n\n");
@@ -8,6 +8,8 @@ export interface AttachmentType {
8
8
  ext: string;
9
9
  mediaType: string;
10
10
  kind: AttachmentKind;
11
+ // Shown on the chip: what the server does to this type on the way in.
12
+ note?: string;
11
13
  }
12
14
 
13
15
  const t = (
@@ -34,9 +36,22 @@ export const ATTACHMENT_TYPES: AttachmentType[] = [
34
36
  ...t("doc", `${OOXML}.presentationml.presentation`, "pptx"),
35
37
  ...t("doc", "application/vnd.oasis.opendocument.text", "odt"),
36
38
  ...t("doc", "application/rtf", "rtf"),
37
- ...t("text", "text/plain", "txt", "log", "env"),
39
+ ...t("image", "image/heic", "heic", "heif"),
40
+ ...t("text", "text/plain", "txt", "log", "out", "err", "env"),
38
41
  ...t("text", "text/markdown", "md", "markdown"),
39
- ...t("text", "application/json", "json", "jsonl", "ipynb"),
42
+ ...t("text", "application/json", "json", "jsonl", "ndjson", "ipynb"),
43
+ {
44
+ ext: "har",
45
+ mediaType: "application/json",
46
+ kind: "text",
47
+ note: "auth headers redacted",
48
+ },
49
+ ...t("text", "text/calendar", "ics"),
50
+ ...t("text", "message/rfc822", "eml"),
51
+ ...t("text", "text/vtt", "vtt"),
52
+ ...t("text", "application/x-subrip", "srt"),
53
+ // Svg is kept as text so nothing ever renders it.
54
+ ...t("text", "text/plain", "svg"),
40
55
  ...t(
41
56
  "text",
42
57
  "text/plain",
@@ -67,15 +82,67 @@ export const ATTACHMENT_TYPES: AttachmentType[] = [
67
82
  "cs",
68
83
  "php",
69
84
  "diff",
70
- "patch"
85
+ "patch",
86
+ "sh",
87
+ "bash",
88
+ "zsh",
89
+ "fish",
90
+ "ini",
91
+ "conf",
92
+ "cfg",
93
+ "properties",
94
+ "tf",
95
+ "tfvars",
96
+ "hcl",
97
+ "graphql",
98
+ "gql",
99
+ "proto",
100
+ "vue",
101
+ "svelte",
102
+ "astro",
103
+ "dart",
104
+ "lua",
105
+ "r",
106
+ "scala",
107
+ "ex",
108
+ "exs",
109
+ "erl",
110
+ "zig",
111
+ "nix",
112
+ "lock",
113
+ "sum",
114
+ "mod",
115
+ "gitignore",
116
+ "gitattributes",
117
+ "dockerignore",
118
+ "editorconfig"
119
+ ),
120
+ // Files that are known by their whole name, matched without a dot.
121
+ ...t(
122
+ "text",
123
+ "text/plain",
124
+ "dockerfile",
125
+ "makefile",
126
+ "gnumakefile",
127
+ "procfile",
128
+ "gemfile",
129
+ "rakefile",
130
+ "brewfile",
131
+ "justfile",
132
+ "env.example",
133
+ "env.sample",
134
+ "env.template"
71
135
  ),
72
136
  ];
73
137
 
74
138
  export const ATTACHMENT_LIMITS = {
75
139
  // Files and images on one message.
76
140
  max: 8,
77
- // One non-image file.
141
+ // One document, sheet or PDF.
78
142
  maxBytes: 25_000_000,
143
+ // One text file (logs, HAR captures, code). It is saved to disk and opened
144
+ // by the agent from its path, never put into the prompt, so it can be big.
145
+ maxTextBytes: 100_000_000,
79
146
  // One image, under the 5 MB a model accepts once base64'd.
80
147
  maxImageBytes: 3_750_000,
81
148
  // A PDF up to this goes to Claude as a document block; bigger ones, and
@@ -91,13 +158,41 @@ const BY_EXT = new Map(ATTACHMENT_TYPES.map((x) => [x.ext, x]));
91
158
  export const extOf = (name: string): string =>
92
159
  /\.([A-Za-z0-9]+)$/.exec(name)?.[1].toLowerCase() ?? "";
93
160
 
161
+ // "Dockerfile" and ".env.example" have no extension worth the name; they are
162
+ // matched whole, lower-cased and without a leading dot.
163
+ const wholeName = (name: string): string =>
164
+ (name.split(/[\\/]/).pop() ?? "").toLowerCase().replace(/^\./, "");
165
+
94
166
  export const typeFor = (
95
167
  name: string,
96
168
  types: AttachmentType[] = ATTACHMENT_TYPES
97
- ): AttachmentType | null =>
98
- types === ATTACHMENT_TYPES
99
- ? (BY_EXT.get(extOf(name)) ?? null)
100
- : (types.find((x) => x.ext === extOf(name)) ?? null);
169
+ ): AttachmentType | null => {
170
+ const ext = extOf(name);
171
+ const whole = wholeName(name);
172
+ const find = (key: string) =>
173
+ types === ATTACHMENT_TYPES
174
+ ? BY_EXT.get(key)
175
+ : types.find((x) => x.ext === key);
176
+ return (ext && find(ext)) || find(whole) || null;
177
+ };
178
+
179
+ // iPhone photos: no browser or model reads them, so they are converted on
180
+ // intake and kept as files instead of going inline with the message.
181
+ export const isHeic = (name: string) => /^heic|^heif/.test(extOf(name));
182
+
183
+ /** The byte limit for one file of this type. */
184
+ export const limitFor = (
185
+ type: AttachmentType,
186
+ limits: Pick<
187
+ typeof ATTACHMENT_LIMITS,
188
+ "maxBytes" | "maxTextBytes" | "maxImageBytes"
189
+ > = ATTACHMENT_LIMITS
190
+ ): number => {
191
+ if (type.kind === "text") return limits.maxTextBytes;
192
+ if (type.kind === "image" && !isHeic(`x.${type.ext}`))
193
+ return limits.maxImageBytes;
194
+ return limits.maxBytes;
195
+ };
101
196
 
102
197
  // For <input accept>: every extension, so the picker greys out the rest.
103
198
  export const acceptAttr = (types: AttachmentType[] = ATTACHMENT_TYPES) =>
@@ -110,7 +205,7 @@ export const formatBytes = (n: number): string =>
110
205
 
111
206
  // What a person is told when a file can't be attached.
112
207
  export const unsupportedMessage = (name: string): string =>
113
- `${name}: this file type can't be attached. Supported: PDF, spreadsheets (xlsx, xls, csv), Word (docx), text, markdown, JSON, code and images.`;
208
+ `${name}: this file type can't be attached. Supported: images (including HEIC), PDF, spreadsheets (xlsx, xls, csv), Word and PowerPoint, HAR captures, SVG, and text files: logs, markdown, JSON, config, shell scripts, Dockerfile, Makefile and source code.`;
114
209
 
115
210
  export const tooBigMessage = (name: string, size: number, limit: number) =>
116
211
  `${name} is ${formatBytes(size)}; the limit is ${formatBytes(limit)}.`;