@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.
- package/README.md +3 -2
- package/app/api/files/upload-temp/route.test.ts +55 -3
- package/app/api/files/upload-temp/route.ts +15 -10
- package/app/api/reach/probe/route.test.ts +22 -0
- package/app/api/reach/probe/route.ts +12 -0
- package/app/api/reach/route.ts +30 -0
- package/app/api/sessions/list-size.test.ts +77 -0
- package/app/api/sessions/route.ts +21 -10
- package/app/api/tasks/list-size.test.ts +50 -0
- package/app/api/tasks/route.ts +20 -3
- package/components/Chat/FileChip.tsx +8 -4
- package/components/Chat/ViewSwitch.tsx +2 -1
- package/components/Chat/composer/paste.ts +5 -2
- package/components/Chat/composer/useAttachFiles.ts +8 -5
- package/components/Devices/NetworkSection.tsx +2 -0
- package/components/Pane/index.tsx +4 -2
- package/components/Reach/ReachRow.tsx +137 -0
- package/components/Settings/SetupSection.tsx +2 -0
- package/components/Welcome/ConnectBlock.tsx +2 -0
- package/content/tips.json +10 -0
- package/content/whats-new.json +40 -0
- package/data/devices/queries.ts +6 -1
- package/data/reach/index.ts +33 -0
- package/lib/attachments/check.ts +2 -5
- package/lib/attachments/har.ts +122 -0
- package/lib/attachments/message.ts +24 -2
- package/lib/attachments/policy.ts +104 -9
- package/lib/attachments/prepare.ts +100 -0
- package/lib/attachments/store.ts +7 -1
- package/lib/config/client-config-types.ts +2 -0
- package/lib/connect/serve.ts +22 -3
- package/lib/connect/supervise.ts +33 -9
- package/lib/db/queries.ts +31 -4
- package/lib/db/types.ts +3 -1
- package/lib/guides.ts +6 -0
- package/lib/hosts/remote-menu.ts +4 -2
- package/lib/reach/check-cli.ts +117 -0
- package/lib/reach/checkReach.ts +62 -0
- package/lib/reach/classify.ts +283 -0
- package/lib/reach/cli.ts +38 -0
- package/lib/reach/gatherFacts.ts +167 -0
- package/lib/reach/index.ts +4 -0
- package/lib/reach/probeConnect.ts +122 -0
- package/lib/reach/types.ts +96 -0
- package/lib/security/auth.ts +2 -0
- package/lib/security/demo.ts +2 -0
- package/lib/sessions/has-prompt.ts +7 -0
- package/lib/sessions/omit-null.ts +8 -0
- package/lib/tasks/contract/views.ts +12 -0
- package/lib/tasks/index.ts +9 -5
- package/lib/tasks/session.ts +15 -1
- package/lib/tips/view.ts +2 -4
- package/package.json +1 -1
- package/scripts/agent-os +3 -0
- package/scripts/install.sh +12 -1
- package/scripts/lib/commands.sh +59 -65
- package/scripts/lib/service.sh +230 -0
- package/scripts/lib/setup.sh +303 -0
- 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
|
|
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", "
|
|
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.
|
|
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: {
|
|
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 =
|
|
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
|
-
|
|
48
|
-
|
|
50
|
+
prepared.name,
|
|
51
|
+
prepared.data,
|
|
52
|
+
prepared.mediaType,
|
|
53
|
+
prepared.summary
|
|
49
54
|
);
|
|
50
|
-
return NextResponse.json({ ...file, 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,
|
|
62
|
+
await writeFile(filePath, prepared.data);
|
|
58
63
|
|
|
59
64
|
return NextResponse.json({
|
|
60
65
|
path: filePath,
|
|
61
66
|
name: safeName,
|
|
62
|
-
size:
|
|
63
|
-
mediaType:
|
|
64
|
-
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
});
|
package/app/api/tasks/route.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
{
|
|
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
|
|
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
|
|
19
|
-
(!f.type && /\.(png|jpe?g|gif|webp
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
|
225
|
+
const hasReport = !!session && hasPrompt(session);
|
|
224
226
|
|
|
225
227
|
// Get current project and its repositories
|
|
226
228
|
const currentProject = useMemo(() => {
|