@saadnvd1/agent-os 0.3.790 → 0.3.794

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.
@@ -0,0 +1,95 @@
1
+ import { needsShrink, renamed, SHRINK_STEPS, targetSize } from "./shrinkPlan";
2
+
3
+ type Canvas = OffscreenCanvas | HTMLCanvasElement;
4
+
5
+ function makeCanvas(width: number, height: number): Canvas {
6
+ if (typeof OffscreenCanvas !== "undefined")
7
+ return new OffscreenCanvas(width, height);
8
+ const c = document.createElement("canvas");
9
+ c.width = width;
10
+ c.height = height;
11
+ return c;
12
+ }
13
+
14
+ function encode(
15
+ canvas: Canvas,
16
+ type: string,
17
+ quality?: number
18
+ ): Promise<Blob | null> {
19
+ if ("convertToBlob" in canvas) return canvas.convertToBlob({ type, quality });
20
+ return new Promise((resolve) => canvas.toBlob(resolve, type, quality));
21
+ }
22
+
23
+ function hasAlpha(
24
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
25
+ w: number,
26
+ h: number
27
+ ) {
28
+ const data = ctx.getImageData(0, 0, w, h).data;
29
+ for (let i = 3; i < data.length; i += 4) if (data[i] < 255) return true;
30
+ return false;
31
+ }
32
+
33
+ export type ShrinkResult = { ok: true; file: File } | { ok: false };
34
+
35
+ // Downscale an image that is over the byte cap or 2000px on its long edge.
36
+ // Returns the file untouched when it is already fine, and { ok: false } when
37
+ // it can't be decoded or still doesn't fit after every step.
38
+ export async function shrinkImage(
39
+ file: File,
40
+ cap: number
41
+ ): Promise<ShrinkResult> {
42
+ let bitmap: ImageBitmap;
43
+ try {
44
+ bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
45
+ } catch {
46
+ // A file we can't decode is only a problem if it was too big anyway.
47
+ return file.size > cap ? { ok: false } : { ok: true, file };
48
+ }
49
+ try {
50
+ if (!needsShrink(bitmap.width, bitmap.height, file.size, cap))
51
+ return { ok: true, file };
52
+ // Animated GIFs would lose their frames; only touch one that can't go as is.
53
+ if (file.type === "image/gif" && file.size <= cap)
54
+ return { ok: true, file };
55
+
56
+ let keepPng: boolean | null = null;
57
+ for (const { maxEdge, quality } of SHRINK_STEPS) {
58
+ const { width, height } = targetSize(
59
+ bitmap.width,
60
+ bitmap.height,
61
+ maxEdge
62
+ );
63
+ const canvas = makeCanvas(width, height);
64
+ const ctx = canvas.getContext("2d") as CanvasRenderingContext2D | null;
65
+ if (!ctx) return { ok: false };
66
+ ctx.drawImage(bitmap, 0, 0, width, height);
67
+ if (keepPng === null)
68
+ keepPng = file.type === "image/png" && hasAlpha(ctx, width, height);
69
+ if (keepPng) {
70
+ const png = await encode(canvas, "image/png");
71
+ if (png && png.size <= cap)
72
+ return {
73
+ ok: true,
74
+ file: new File([png], file.name, { type: "image/png" }),
75
+ };
76
+ keepPng = false;
77
+ }
78
+ // JPEG has no alpha: put the picture on white first.
79
+ ctx.globalCompositeOperation = "destination-over";
80
+ ctx.fillStyle = "#fff";
81
+ ctx.fillRect(0, 0, width, height);
82
+ const jpeg = await encode(canvas, "image/jpeg", quality);
83
+ if (jpeg && jpeg.size <= cap)
84
+ return {
85
+ ok: true,
86
+ file: new File([jpeg], renamed(file.name, "jpg"), {
87
+ type: "image/jpeg",
88
+ }),
89
+ };
90
+ }
91
+ return { ok: false };
92
+ } finally {
93
+ bitmap.close();
94
+ }
95
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { needsShrink, renamed, SHRINK_STEPS, targetSize } from "./shrinkPlan";
3
+
4
+ describe("shrink plan", () => {
5
+ it("shrinks a big photo and leaves a small one alone", () => {
6
+ expect(needsShrink(5712, 4284, 8_000_000, 3_750_000)).toBe(true);
7
+ expect(needsShrink(1200, 800, 4_000_000, 3_750_000)).toBe(true);
8
+ expect(needsShrink(3000, 1000, 100_000, 3_750_000)).toBe(true);
9
+ expect(needsShrink(2000, 1500, 500_000, 3_750_000)).toBe(false);
10
+ });
11
+
12
+ it("fits the long edge and keeps the aspect ratio", () => {
13
+ expect(targetSize(5712, 4284, 2000)).toEqual({ width: 2000, height: 1500 });
14
+ expect(targetSize(4284, 5712, 1600)).toEqual({ width: 1200, height: 1600 });
15
+ expect(targetSize(800, 600, 2000)).toEqual({ width: 800, height: 600 });
16
+ });
17
+
18
+ it("steps quality down before size", () => {
19
+ expect(SHRINK_STEPS.map((s) => `${s.maxEdge}@${s.quality}`)).toEqual([
20
+ "2000@0.85",
21
+ "2000@0.75",
22
+ "2000@0.65",
23
+ "1600@0.75",
24
+ "1600@0.65",
25
+ ]);
26
+ });
27
+
28
+ it("swaps the extension", () => {
29
+ expect(renamed("IMG_1.HEIC", "jpg")).toBe("IMG_1.jpg");
30
+ expect(renamed("a.b.png", "jpg")).toBe("a.b.jpg");
31
+ expect(renamed("noext", "jpg")).toBe("noext.jpg");
32
+ });
33
+ });
@@ -0,0 +1,39 @@
1
+ // The decisions behind shrinking a big photo before it is attached, kept pure
2
+ // so they can be tested without a canvas.
3
+
4
+ export const MAX_EDGE = 2000;
5
+ const FALLBACK_EDGE = 1600;
6
+
7
+ export interface ShrinkStep {
8
+ maxEdge: number;
9
+ quality: number;
10
+ }
11
+
12
+ // Tried in order until the result fits the cap.
13
+ export const SHRINK_STEPS: ShrinkStep[] = [
14
+ { maxEdge: MAX_EDGE, quality: 0.85 },
15
+ { maxEdge: MAX_EDGE, quality: 0.75 },
16
+ { maxEdge: MAX_EDGE, quality: 0.65 },
17
+ { maxEdge: FALLBACK_EDGE, quality: 0.75 },
18
+ { maxEdge: FALLBACK_EDGE, quality: 0.65 },
19
+ ];
20
+
21
+ export const needsShrink = (
22
+ width: number,
23
+ height: number,
24
+ bytes: number,
25
+ cap: number
26
+ ) => bytes > cap || Math.max(width, height) > MAX_EDGE;
27
+
28
+ // Fit inside maxEdge on the long side; never enlarge.
29
+ export function targetSize(width: number, height: number, maxEdge: number) {
30
+ const scale = Math.min(1, maxEdge / Math.max(width, height));
31
+ return {
32
+ width: Math.max(1, Math.round(width * scale)),
33
+ height: Math.max(1, Math.round(height * scale)),
34
+ };
35
+ }
36
+
37
+ // photo.HEIC -> photo.jpg; a name without an extension just gains one.
38
+ export const renamed = (name: string, ext: "jpg" | "png") =>
39
+ `${name.replace(/\.[^./\\]+$/, "")}.${ext}`;
@@ -16,6 +16,7 @@ import {
16
16
  import type { ChatFile, ChatImage } from "@/lib/chat/events";
17
17
  import { uploadFile } from "@/lib/file-upload";
18
18
  import { isImage } from "./paste";
19
+ import { shrinkImage } from "./shrinkImage";
19
20
 
20
21
  export interface Upload {
21
22
  key: string;
@@ -79,9 +80,9 @@ export function useAttachFiles({
79
80
  if (held.current + images.length + others.length >= limits.max)
80
81
  error = `You can attach up to ${limits.max} files to one message.`;
81
82
  else if (isImage(f)) {
82
- if (f.size > limits.maxImageBytes)
83
- error = tooBigMessage(f.name, f.size, limits.maxImageBytes);
84
- else images.push(f);
83
+ const shrunk = await shrinkImage(f, limits.maxImageBytes);
84
+ if (shrunk.ok) images.push(shrunk.file);
85
+ else error = tooBigMessage(f.name, f.size, limits.maxImageBytes);
85
86
  } else if (!typeFor(f.name, limits.types))
86
87
  error = unsupportedMessage(f.name);
87
88
  else if (f.size > limits.maxBytes)
@@ -1777,6 +1777,22 @@
1777
1777
  "area": "AgentOS",
1778
1778
  "text": "What's New now sits as a small icon in the sidebar footer.",
1779
1779
  "url": "https://github.com/saadnvd1/agent-os/pull/528"
1780
+ },
1781
+ {
1782
+ "id": 529,
1783
+ "date": "2026-10-31",
1784
+ "kind": "fixed",
1785
+ "area": "Chat",
1786
+ "text": "Large photos from your phone now attach to a chat instead of being refused.",
1787
+ "url": "https://github.com/saadnvd1/agent-os/pull/529"
1788
+ },
1789
+ {
1790
+ "id": 530,
1791
+ "date": "2026-10-31",
1792
+ "kind": "new",
1793
+ "area": "Chat",
1794
+ "text": "GitHub Copilot chats can now ask you questions with clickable answer cards, like Claude chats do.",
1795
+ "url": "https://github.com/saadnvd1/agent-os/pull/530"
1780
1796
  }
1781
1797
  ]
1782
1798
  }
@@ -1,6 +1,7 @@
1
1
  import type { ChatAccess, DriverEvent, FileDiff } from "../events";
2
2
  import { unifiedToDiff } from "../diff";
3
3
  import { toolTitle } from "../tools";
4
+ import { isAskUserCall } from "./copilot-ask";
4
5
  import { PendingApprovals } from "./pending-approvals";
5
6
 
6
7
  type P = Record<string, unknown>;
@@ -71,6 +72,7 @@ export class CopilotApprovals {
71
72
  : { outcome: { outcome: "cancelled" } };
72
73
 
73
74
  if (
75
+ isAskUserCall(call) ||
74
76
  this.access === "full" ||
75
77
  (this.access === "edits" && EDITS_KINDS.has(kind))
76
78
  )
@@ -0,0 +1,232 @@
1
+ import crypto from "node:crypto";
2
+ import http from "node:http";
3
+ import type { AddressInfo } from "node:net";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
7
+ import { z } from "zod";
8
+ import type { ChatQuestion } from "../events";
9
+ import {
10
+ answersInOrder,
11
+ type ApprovalOutcome,
12
+ type PendingApprovals,
13
+ } from "./pending-approvals";
14
+
15
+ type P = Record<string, unknown>;
16
+ const str = (v: unknown) => (typeof v === "string" ? v : "");
17
+
18
+ export const ASK_SERVER = "agentos";
19
+ export const ASK_TOOL = "ask_user";
20
+ const MAX_BODY = 64 * 1024;
21
+
22
+ // Copilot CLI doesn't offer its own ask_user over ACP, so each conversation
23
+ // serves one: a question the agent asks becomes the chat's question card.
24
+ export const askInput = {
25
+ question: z.string().min(1).describe("The question to show the user"),
26
+ options: z
27
+ .array(
28
+ z.object({
29
+ label: z.string().min(1),
30
+ description: z.string().optional(),
31
+ })
32
+ )
33
+ .optional()
34
+ .describe("Choices to offer; leave out for a free-text question"),
35
+ multiSelect: z
36
+ .boolean()
37
+ .optional()
38
+ .describe("Let the user pick more than one option"),
39
+ allowFreeText: z
40
+ .boolean()
41
+ .optional()
42
+ .describe("The user can always type their own answer too"),
43
+ header: z.string().optional().describe("A short label for the question"),
44
+ };
45
+
46
+ type AskArgs = {
47
+ question: string;
48
+ options?: { label: string; description?: string }[];
49
+ multiSelect?: boolean;
50
+ header?: string;
51
+ };
52
+
53
+ export function askUserQuestion(a: AskArgs): ChatQuestion {
54
+ const seen = new Set<string>();
55
+ const options = (a.options ?? []).flatMap((o) => {
56
+ const label = o.label.trim();
57
+ if (!label || seen.has(label)) return [];
58
+ seen.add(label);
59
+ return [{ label, description: o.description?.trim() ?? "" }];
60
+ });
61
+ return {
62
+ question: a.question.trim(),
63
+ header: a.header?.trim() ?? "",
64
+ multiSelect: !!a.multiSelect && options.length > 0,
65
+ options,
66
+ };
67
+ }
68
+
69
+ const text = (t: string, isError = false): CallToolResult => ({
70
+ content: [{ type: "text", text: t }],
71
+ ...(isError ? { isError } : {}),
72
+ });
73
+
74
+ export function askUserResult(
75
+ q: ChatQuestion,
76
+ o: ApprovalOutcome
77
+ ): CallToolResult {
78
+ if (o.decision === "answer") {
79
+ const [list] = answersInOrder([q], o.answers);
80
+ return text(
81
+ JSON.stringify(
82
+ q.multiSelect ? { answers: list } : { answer: list[0] ?? "" }
83
+ )
84
+ );
85
+ }
86
+ if (o.decision === "expired")
87
+ return text("The question was cancelled before the user answered.", true);
88
+ return text("The user skipped this question without answering.");
89
+ }
90
+
91
+ // Whether a Copilot tool call (a tool_call update or a permission request) is
92
+ // our ask_user. Its title carries the server and tool name; the input is the
93
+ // fallback for a title that doesn't.
94
+ export function isAskUserCall(call: P): boolean {
95
+ const raw = (call.rawInput ?? {}) as P;
96
+ const title = str(call.title);
97
+ return (
98
+ title.includes(ASK_TOOL) ||
99
+ (title.includes(ASK_SERVER) && typeof raw.question === "string")
100
+ );
101
+ }
102
+
103
+ // One private MCP server per conversation: loopback only, behind a secret
104
+ // path, so nothing else can reach it or answer its cards.
105
+ export class CopilotAskServer {
106
+ private http?: http.Server;
107
+ private secret = crypto.randomBytes(32).toString("hex");
108
+ private next = 0;
109
+ private live = new Set<{ close(): Promise<void> }>();
110
+
111
+ constructor(private pending: PendingApprovals) {}
112
+
113
+ private build(): McpServer {
114
+ const server = new McpServer({ name: ASK_SERVER, version: "1.0.0" });
115
+ server.registerTool(
116
+ ASK_TOOL,
117
+ {
118
+ description:
119
+ "Ask the user a question and wait for the answer. Use it when you need a decision or missing information instead of asking in plain text. Offer options for a choice (multiSelect to allow several); with no options the user types an answer. Blocks until they answer.",
120
+ inputSchema: askInput,
121
+ },
122
+ async (args) => {
123
+ const q = askUserQuestion(args);
124
+ const o = await this.pending.ask({
125
+ id: `approval-ask-${++this.next}`,
126
+ toolName: "AskUserQuestion",
127
+ title: "Questions",
128
+ input: { questions: [q] },
129
+ questions: [q],
130
+ canAlways: false,
131
+ });
132
+ return askUserResult(q, o);
133
+ }
134
+ );
135
+ return server;
136
+ }
137
+
138
+ private allowed(req: http.IncomingMessage): number {
139
+ if (req.headers.origin) return 403;
140
+ const host = (req.headers.host ?? "").replace(/:\d+$/, "");
141
+ if (host !== "127.0.0.1" && host !== "localhost") return 403;
142
+ const want = Buffer.from(`/mcp/${this.secret}`);
143
+ const got = Buffer.from((req.url ?? "").split("?")[0]);
144
+ return got.length === want.length && crypto.timingSafeEqual(got, want)
145
+ ? 200
146
+ : 404;
147
+ }
148
+
149
+ private async handle(
150
+ req: http.IncomingMessage,
151
+ res: http.ServerResponse
152
+ ): Promise<void> {
153
+ const status = this.allowed(req);
154
+ if (status !== 200) {
155
+ res.writeHead(status).end();
156
+ return;
157
+ }
158
+ if (req.method !== "POST") {
159
+ res.writeHead(405, { allow: "POST" }).end();
160
+ return;
161
+ }
162
+ let size = 0;
163
+ const chunks: Buffer[] = [];
164
+ for await (const c of req) {
165
+ size += (c as Buffer).length;
166
+ if (size > MAX_BODY) {
167
+ res.writeHead(413).end();
168
+ return;
169
+ }
170
+ chunks.push(c as Buffer);
171
+ }
172
+ let body: unknown;
173
+ try {
174
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
175
+ } catch {
176
+ res.writeHead(400).end();
177
+ return;
178
+ }
179
+ const server = this.build();
180
+ const transport = new StreamableHTTPServerTransport({
181
+ sessionIdGenerator: undefined,
182
+ enableJsonResponse: true,
183
+ });
184
+ const handle = {
185
+ close: async () => {
186
+ await transport.close();
187
+ await server.close();
188
+ },
189
+ };
190
+ this.live.add(handle);
191
+ res.on("close", () => {
192
+ this.live.delete(handle);
193
+ void handle.close().catch(() => {});
194
+ });
195
+ await server.connect(transport);
196
+ await transport.handleRequest(req, res, body);
197
+ }
198
+
199
+ // The `mcpServers` entry to give ACP session/new and session/load.
200
+ async start(): Promise<P> {
201
+ const server = http.createServer((req, res) => {
202
+ this.handle(req, res).catch(() => {
203
+ if (!res.headersSent) res.writeHead(500);
204
+ res.end();
205
+ });
206
+ });
207
+ await new Promise<void>((resolve, reject) => {
208
+ server.once("error", reject);
209
+ server.listen(0, "127.0.0.1", resolve);
210
+ });
211
+ this.http = server;
212
+ const { port } = server.address() as AddressInfo;
213
+ return {
214
+ type: "http",
215
+ name: ASK_SERVER,
216
+ url: `http://127.0.0.1:${port}/mcp/${this.secret}`,
217
+ headers: [],
218
+ };
219
+ }
220
+
221
+ // Cancels any question still waiting (its call returns a cancellation)
222
+ // and stops listening.
223
+ close(): void {
224
+ this.pending.expireAll();
225
+ const server = this.http;
226
+ this.http = undefined;
227
+ server?.close();
228
+ server?.closeAllConnections();
229
+ for (const h of this.live) void h.close().catch(() => {});
230
+ this.live.clear();
231
+ }
232
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ChatContext, DriverEvent, UsageTotals } from "../events";
2
2
  import { toolTitle } from "../tools";
3
+ import { isAskUserCall } from "./copilot-ask";
3
4
  import { TurnItems, type ToolStart } from "./turn-items";
4
5
 
5
6
  type P = Record<string, unknown>;
@@ -135,14 +136,22 @@ export class CopilotMapper {
135
136
  this.block++;
136
137
  }
137
138
 
139
+ // The card is the question's only trace, so its tool call stays hidden.
140
+ private asks = new Set<string>();
141
+
138
142
  private started(update: P): void {
139
143
  this.items.startTurn();
144
+ if (isAskUserCall(update)) {
145
+ this.asks.add(str(update.toolCallId));
146
+ return;
147
+ }
140
148
  this.close();
141
149
  this.items.tool(str(update.toolCallId), toolStart(update));
142
150
  }
143
151
 
144
152
  private updated(update: P): void {
145
153
  const id = str(update.toolCallId);
154
+ if (this.asks.has(id)) return;
146
155
  const status = str(update.status);
147
156
  if (status !== "completed" && status !== "failed") return;
148
157
  const start = toolStart(update);
@@ -6,6 +6,7 @@ import type { ChatConversation, ChatDriver, ChatStartOptions } from "../driver";
6
6
  import type { ChatImage, ChatModel, DriverEvent } from "../events";
7
7
  import { InputQueue } from "../queue";
8
8
  import { AGENT_DEFAULT_MODEL } from "../../providers/registry";
9
+ import { ASK_SERVER, ASK_TOOL, CopilotAskServer } from "./copilot-ask";
9
10
  import { CopilotApprovals } from "./copilot-approvals";
10
11
  import { CopilotMapper } from "./copilot-mapper";
11
12
  import { CopilotRpc } from "./copilot-rpc";
@@ -33,7 +34,10 @@ export function copilotArgs(
33
34
  fallback = copilotDefaultModel({ ...process.env, ...o.env })
34
35
  ): string[] {
35
36
  const model = o.model && o.model !== AGENT_DEFAULT_MODEL ? o.model : fallback;
36
- return model ? ["--model", model] : [];
37
+ return [
38
+ ...(model ? ["--model", model] : []),
39
+ `--allow-tool=${ASK_SERVER}(${ASK_TOOL})`,
40
+ ];
37
41
  }
38
42
 
39
43
  // The models a new ACP session offers (`models.availableModels`), with
@@ -117,6 +121,7 @@ export const copilotDriver: ChatDriver = {
117
121
  const emit = (e: DriverEvent) => out.push(e);
118
122
  const mapper = new CopilotMapper(emit);
119
123
  const approvals = new CopilotApprovals(emit, options.access);
124
+ const ask = new CopilotAskServer(approvals.pending);
120
125
  let sessionId: string | null = null;
121
126
  let busy = false;
122
127
  let intro = options.resumeId ? "" : (options.systemAppend ?? "").trim();
@@ -136,7 +141,7 @@ export const copilotDriver: ChatDriver = {
136
141
  void approvals.handle(params).then((result) => rpc.reply(id, result));
137
142
  },
138
143
  exit(error) {
139
- approvals.pending.expireAll();
144
+ ask.close();
140
145
  mapper.endTurn({ error });
141
146
  out.end();
142
147
  },
@@ -145,7 +150,15 @@ export const copilotDriver: ChatDriver = {
145
150
  emit({ type: "usage_start", totals: { ...mapper.totals } });
146
151
 
147
152
  const ready = rpc.initialize().then(async () => {
148
- const params = { cwd: options.cwd, mcpServers: [] };
153
+ // Without it the agent just can't ask: the chat still works.
154
+ const mcpServers = await ask.start().then(
155
+ (s) => [s],
156
+ (e: Error) => {
157
+ mapper.items.error(`Questions aren't available: ${e.message}`);
158
+ return [];
159
+ }
160
+ );
161
+ const params = { cwd: options.cwd, mcpServers };
149
162
  if (options.resumeId) {
150
163
  mapper.replaying = true;
151
164
  try {
@@ -170,6 +183,7 @@ export const copilotDriver: ChatDriver = {
170
183
  emit({ type: "resume_id", id: sessionId });
171
184
  });
172
185
  ready.catch((error: Error) => {
186
+ ask.close();
173
187
  mapper.items.error(`GitHub Copilot couldn't start: ${error.message}`);
174
188
  mapper.endTurn({});
175
189
  out.end();
@@ -243,7 +257,7 @@ export const copilotDriver: ChatDriver = {
243
257
  };
244
258
  },
245
259
  close() {
246
- approvals.pending.expireAll();
260
+ ask.close();
247
261
  rpc.close();
248
262
  out.end();
249
263
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.790",
3
+ "version": "0.3.794",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"