agentus 0.0.0-stage → 0.2.0

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/LICENSE +202 -0
  2. package/NOTICE +11 -0
  3. package/README.md +476 -2
  4. package/README.zh-CN.md +396 -0
  5. package/bin/agentus.mjs +147 -0
  6. package/docs/android-notify-contract.md +658 -0
  7. package/docs/dev/01-dev-loop.md +186 -0
  8. package/docs/dev/02-verify.md +237 -0
  9. package/docs/dev/03-release.md +218 -0
  10. package/docs/dev/04-recover.md +199 -0
  11. package/docs/dev/05-agent-driven.md +114 -0
  12. package/docs/dev/README.md +65 -0
  13. package/docs/installation.md +366 -0
  14. package/docs/installation.zh-CN.md +350 -0
  15. package/docs/refs/aionui-acp-rules.md +46 -0
  16. package/docs/refs/hermes-studio-ui-ideas.md +191 -0
  17. package/docs/transcript-folding.md +139 -0
  18. package/package.json +76 -5
  19. package/packages/server/mcp/plan-server.mjs +217 -0
  20. package/packages/server/mock/agent.mjs +693 -0
  21. package/packages/server/package.json +22 -0
  22. package/packages/server/src/acp/backends.ts +196 -0
  23. package/packages/server/src/acp/errors.ts +46 -0
  24. package/packages/server/src/acp/registry.ts +579 -0
  25. package/packages/server/src/acp/session-manager.ts +1868 -0
  26. package/packages/server/src/auth.ts +688 -0
  27. package/packages/server/src/dashscope.ts +235 -0
  28. package/packages/server/src/fs.ts +379 -0
  29. package/packages/server/src/hotwords.ts +132 -0
  30. package/packages/server/src/index.ts +1486 -0
  31. package/packages/server/src/notify/center.ts +994 -0
  32. package/packages/server/src/notify/types.ts +222 -0
  33. package/packages/server/src/plan/plan.ts +126 -0
  34. package/packages/server/src/pty-helper.py +135 -0
  35. package/packages/server/src/settings.ts +469 -0
  36. package/packages/server/src/store/export.ts +209 -0
  37. package/packages/server/src/store/store.ts +1238 -0
  38. package/packages/server/src/term.ts +201 -0
  39. package/packages/server/src/title.ts +109 -0
  40. package/packages/server/src/voice.ts +251 -0
  41. package/packages/shared/package.json +14 -0
  42. package/packages/shared/src/index.ts +271 -0
  43. package/packages/web/dist/assets/index-D667PX2c.css +32 -0
  44. package/packages/web/dist/assets/index-d9NzwPFu.js +283 -0
  45. package/packages/web/dist/coding-agents/hermes.png +0 -0
  46. package/packages/web/dist/coding-agents/qoder.svg +5 -0
  47. package/packages/web/dist/icons/agentus-icon-inverse.svg +301 -0
  48. package/packages/web/dist/icons/agentus-icon.svg +301 -0
  49. package/packages/web/dist/icons/agentus-mark-inverse.svg +301 -0
  50. package/packages/web/dist/icons/agentus-mark.svg +301 -0
  51. package/packages/web/dist/icons/apple-touch-icon-180.png +0 -0
  52. package/packages/web/dist/icons/favicon.ico +0 -0
  53. package/packages/web/dist/icons/icon-192.png +0 -0
  54. package/packages/web/dist/icons/icon-512.png +0 -0
  55. package/packages/web/dist/icons/icon-maskable-512.png +0 -0
  56. package/packages/web/dist/index.html +30 -0
  57. package/packages/web/dist/manifest.webmanifest +17 -0
  58. package/packages/web/dist/sw.js +61 -0
  59. package/scripts/make-cert.sh +101 -0
@@ -0,0 +1,235 @@
1
+ // DashScope (阿里百炼) voice adapter.
2
+ //
3
+ // Shapes verified against the operator's own MaaS endpoint on 2026-10-02 (probe scripts
4
+ // in ~/.hermes/cache/agentus/night):
5
+ // TTS POST {root}/api/v1/services/audio/tts/SpeechSynthesizer
6
+ // {model:"qwen-audio-3.0-tts-flash", input:{text, voice:"longanhuan_v3.6",
7
+ // format:"wav", sample_rate}} → output.audio.url (fetch server-side, return bytes)
8
+ // ASR POST {compat}/chat/completions with an input_audio base64 data URL
9
+ // (qwen3-asr-flash) → choices[0].message.content; hotwords ride in a system
10
+ // message (the compat surface has no vocabulary parameter)
11
+ // ASR流 WSS {root}/api-ws/v1/inference, run-task/finish-task duplex events, binary
12
+ // PCM frames after task-started, result-generated carries partial + final
13
+ // sentences. Supports instant hotwords (parameters.vocabulary) and context.
14
+ //
15
+ // The base URL is the operator's — every Bailian workspace id is different, and the
16
+ // whole point is that this is NOT the coding-plan endpoint. No URLs or keys are baked in.
17
+ import WebSocket from "ws";
18
+ import { getSettings, dashscopeUrls } from "./settings.js";
19
+
20
+ export class DashscopeError extends Error {
21
+ constructor(message: string, readonly code: "not_configured" | "upstream" | "timeout" | "bad_audio") {
22
+ super(message);
23
+ }
24
+ }
25
+
26
+ const TIMEOUT_MS = 45_000;
27
+
28
+ function creds(): { key: string; root: string; compat: string; ws: string } {
29
+ const v = getSettings().voice;
30
+ if (v.provider !== "dashscope" || !v.baseUrl || !v.apiKey) {
31
+ throw new DashscopeError("dashscope voice is not configured (settings page: endpoint + API key)", "not_configured");
32
+ }
33
+ const urls = dashscopeUrls(v.baseUrl);
34
+ return { key: v.apiKey, ...urls };
35
+ }
36
+
37
+ async function fetchJson(url: string, init: RequestInit, label: string): Promise<Response> {
38
+ const ac = new AbortController();
39
+ const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
40
+ try {
41
+ const res = await fetch(url, { ...init, signal: ac.signal });
42
+ if (!res.ok) {
43
+ const detail = await res.text().catch(() => "");
44
+ throw new DashscopeError(`${label} ${res.status}: ${detail.slice(0, 300) || res.statusText}`, "upstream");
45
+ }
46
+ return res;
47
+ } catch (e) {
48
+ if ((e as Error).name === "AbortError") throw new DashscopeError(`${label} timed out after ${TIMEOUT_MS / 1000}s`, "timeout");
49
+ throw e;
50
+ } finally {
51
+ clearTimeout(timer);
52
+ }
53
+ }
54
+
55
+ /** Text → audio bytes. Non-streaming: the response carries a 24h OSS URL; we fetch it
56
+ * server-side so the browser only ever talks to Agentus (and the key never pairs with
57
+ * a cross-origin request from the client). */
58
+ export async function dashscopeTts(text: string, opts: { voice?: string; speed?: number } = {}): Promise<{
59
+ contentType: string;
60
+ audio: Buffer;
61
+ }> {
62
+ const { key, root } = creds();
63
+ const v = getSettings().voice;
64
+ const rate = Math.min(2, Math.max(0.5, opts.speed ?? 1));
65
+ const res = await fetchJson(`${root}/api/v1/services/audio/tts/SpeechSynthesizer`, {
66
+ method: "POST",
67
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
68
+ body: JSON.stringify({
69
+ model: v.ttsModel,
70
+ input: {
71
+ text,
72
+ voice: opts.voice || v.ttsVoice,
73
+ format: v.ttsFormat,
74
+ sample_rate: v.ttsFormat === "wav" ? 24000 : undefined,
75
+ ...(rate !== 1 ? { rate } : {}),
76
+ },
77
+ }),
78
+ }, "dashscope tts");
79
+ const data = (await res.json()) as { output?: { audio?: { url?: string; data?: string } } };
80
+ const audio = data?.output?.audio;
81
+ if (audio?.data) {
82
+ return { contentType: mimeOf(v.ttsFormat), audio: Buffer.from(audio.data, "base64") };
83
+ }
84
+ if (!audio?.url) throw new DashscopeError(`dashscope tts returned no audio: ${JSON.stringify(data).slice(0, 200)}`, "upstream");
85
+ // the url is http:// on some tenants; fetch as-is (same cloud, short-lived, signed)
86
+ const dl = await fetchJson(audio.url, { method: "GET" }, "dashscope tts download");
87
+ return { contentType: dl.headers.get("content-type") ?? mimeOf(v.ttsFormat), audio: Buffer.from(await dl.arrayBuffer()) };
88
+ }
89
+
90
+ function mimeOf(format: string): string {
91
+ return format === "wav" ? "audio/wav" : format === "opus" ? "audio/ogg" : "audio/mpeg";
92
+ }
93
+
94
+ /** Hotwords for the compat route: a compact system message. The docs describe it as
95
+ * "背景文本和实体词表" — exactly the context-enhancement channel, and it is what makes
96
+ * "Agentus"/"网关" survive instead of "安逸 slot"/"万关". */
97
+ export function hotwordContext(words: string[]): string {
98
+ if (!words.length) return "";
99
+ return `本次语音可能涉及的词汇表(识别时可优先匹配):${words.slice(0, 60).join("、")}。`;
100
+ }
101
+
102
+ /** One-shot transcription (the stop-and-finalise path, and the batch model).
103
+ * `audio` is raw bytes; `mime` decides the data URL prefix the model is told. */
104
+ export async function dashscopeAsr(audio: Buffer, mime: string, opts: { contextWords?: string[] } = {}): Promise<string> {
105
+ const { key, compat } = creds();
106
+ const v = getSettings().voice;
107
+ const dataUrl = `data:${mime || "audio/wav"};base64,${audio.toString("base64")}`;
108
+ const messages: unknown[] = [];
109
+ const ctx = hotwordContext(opts.contextWords ?? []);
110
+ if (ctx) messages.push({ role: "system", content: [{ type: "text", text: ctx }] });
111
+ messages.push({ role: "user", content: [{ type: "input_audio", input_audio: { data: dataUrl } }] });
112
+ const res = await fetchJson(`${compat}/chat/completions`, {
113
+ method: "POST",
114
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
115
+ body: JSON.stringify({ model: v.asrBatchModel, messages, asr_options: { enable_itn: true } }),
116
+ }, "dashscope asr");
117
+ const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
118
+ return String(data?.choices?.[0]?.message?.content ?? "").trim();
119
+ }
120
+
121
+ /**
122
+ * A live streaming recognition task over the DashScope WebSocket.
123
+ *
124
+ * The browser cannot talk to this endpoint itself: the auth header on a WebSocket
125
+ * handshake is forbidden in browsers, and the raw key must not leave the server anyway.
126
+ * So the cockpit server owns the upstream socket and the browser relays PCM through our
127
+ * own /ws/asr (see index.ts). Events:
128
+ * onPartial(text) — running sentence, replaces the previous partial
129
+ * onFinal(text) — a completed sentence (sentence_end)
130
+ */
131
+ export function openDashscopeStream(opts: {
132
+ /** instant hotwords WITH weights: {"词": 1..5}, 50 = super-hotword */
133
+ vocabulary?: Record<string, number>;
134
+ /** plain word list, used when no weighted vocabulary is available */
135
+ contextWords?: string[];
136
+ /** context enhancement: recent lines of the conversation being dictated into */
137
+ context?: { role: "user" | "assistant"; text: string }[];
138
+ onPartial: (text: string) => void;
139
+ onFinal: (text: string) => void;
140
+ onError: (message: string) => void;
141
+ onClose: () => void;
142
+ }): { sendAudio: (chunk: Buffer) => void; stop: () => void; started: Promise<void> } {
143
+ const { key, ws } = creds();
144
+ const v = getSettings().voice;
145
+ const socket = new WebSocket(ws, { headers: { authorization: `Bearer ${key}` } });
146
+ const taskId = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
147
+ let startedResolve: (() => void) | null = null;
148
+ let startedReject: ((e: Error) => void) | null = null;
149
+ const started = new Promise<void>((resolve, reject) => { startedResolve = resolve; startedReject = reject; });
150
+ let closing = false;
151
+ // "done" must be exactly once and must not depend on WHO closed the socket: the first
152
+ // relay had the finalise path suppressed (closing=true swallowed the close), so the
153
+ // browser sat waiting for an end that never came.
154
+ let done = false;
155
+ const notifyDone = (): void => {
156
+ if (done) return;
157
+ done = true;
158
+ opts.onClose();
159
+ };
160
+ const vocab: Record<string, number> = { ...(opts.vocabulary ?? {}) };
161
+ for (const w of opts.contextWords ?? []) if (!(w in vocab)) vocab[w] = 4;
162
+
163
+ socket.on("open", () => {
164
+ socket.send(JSON.stringify({
165
+ header: { action: "run-task", task_id: taskId, streaming: "duplex" },
166
+ payload: {
167
+ task_group: "audio", task: "asr", function: "recognition", model: v.asrModel,
168
+ parameters: { format: "pcm", sample_rate: 16000, ...(Object.keys(vocab).length ? { vocabulary: vocab } : {}) },
169
+ // context enhancement: what the operator has been talking about. Only sent when
170
+ // we have it: an empty array is not the same request as "no context".
171
+ input: opts.context?.length ? { context: opts.context.slice(0, 5) } : {},
172
+ },
173
+ }));
174
+ });
175
+ socket.on("message", (raw: Buffer | string) => {
176
+ let msg: { header?: { event?: string; error_message?: string }; payload?: { output?: { sentence?: { text?: string; sentence_end?: boolean }; text?: string } } };
177
+ try {
178
+ msg = JSON.parse(String(raw)) as typeof msg;
179
+ } catch {
180
+ return;
181
+ }
182
+ const event = msg?.header?.event ?? "";
183
+ if (event === "task-started") { startedResolve?.(); startedResolve = null; return; }
184
+ if (event === "task-failed") {
185
+ const m = `dashscope asr: ${msg?.header?.error_message ?? "task failed"}`;
186
+ startedReject?.(new Error(m)); startedReject = null;
187
+ opts.onError(m);
188
+ return;
189
+ }
190
+ if (event === "result-generated") {
191
+ const sent = msg?.payload?.output?.sentence;
192
+ const text = String(sent?.text ?? msg?.payload?.output?.text ?? "");
193
+ if (!text) return;
194
+ if (sent?.sentence_end) opts.onFinal(text);
195
+ else opts.onPartial(text);
196
+ return;
197
+ }
198
+ if (event === "task-finished") {
199
+ // the API's own end-of-task event: report it now rather than waiting for the socket
200
+ startedReject?.(new Error("task finished before start"));
201
+ startedReject = null;
202
+ notifyDone();
203
+ return;
204
+ }
205
+ });
206
+ socket.on("error", (e: Error) => {
207
+ startedReject?.(e); startedReject = null;
208
+ opts.onError(`dashscope asr socket: ${e.message}`);
209
+ });
210
+ socket.on("close", notifyDone);
211
+
212
+ const timeout = setTimeout(() => {
213
+ startedReject?.(new Error("dashscope asr: task-started timed out"));
214
+ startedReject = null;
215
+ }, 10_000);
216
+ started.catch(() => { /* surfaced via onError/stop below */ }).finally(() => void 0);
217
+ // clear the timer once started succeeds; reject path already rejected the promise
218
+ void started.then(() => clearTimeout(timeout), () => { clearTimeout(timeout); try { socket.close(); } catch { /* gone */ } });
219
+
220
+ return {
221
+ sendAudio(chunk: Buffer): void {
222
+ if (socket.readyState === socket.OPEN) socket.send(chunk, { binary: true });
223
+ },
224
+ stop(): void {
225
+ closing = true;
226
+ try {
227
+ if (socket.readyState === socket.OPEN) {
228
+ socket.send(JSON.stringify({ header: { action: "finish-task", task_id: taskId, streaming: "duplex" }, payload: { input: {} } }));
229
+ setTimeout(() => { try { socket.close(); } catch { /* already */ } }, 2000);
230
+ }
231
+ } catch { /* already closed */ }
232
+ },
233
+ started,
234
+ };
235
+ }
@@ -0,0 +1,379 @@
1
+ // Directory browsing for the new-slot workspace picker.
2
+ //
3
+ // The operator already has the right to point an agent at any directory (that is the
4
+ // product), so this adds no new power — it just makes the choice discoverable instead
5
+ // of demanding a path typed from memory. Listing is deliberately narrow: directories
6
+ // only, one level at a time, capped, and never followed through symlinks we cannot stat.
7
+ import crypto from "node:crypto";
8
+ import fs from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import path from "node:path";
11
+
12
+ export interface DirEntry {
13
+ name: string;
14
+ path: string;
15
+ /** "dir" for directories, "file" for everything else the panel can show */
16
+ kind: "dir" | "file";
17
+ /** bytes, files only (the panel prints a human size) */
18
+ size?: number;
19
+ }
20
+
21
+ export interface DirListing {
22
+ /** the directory that was listed (absolute, symlink-resolved) */
23
+ path: string;
24
+ /** parent directory, or null at the root */
25
+ parent: string | null;
26
+ home: string;
27
+ entries: DirEntry[];
28
+ /** hit the entry cap — the UI says so instead of pretending the list is complete */
29
+ truncated: boolean;
30
+ }
31
+
32
+ const MAX_ENTRIES = 300;
33
+
34
+ /** `~`, `~/x`, relative and absolute all land on an absolute path. */
35
+ export function expandPath(input: string | null | undefined): string {
36
+ const raw = String(input ?? "").trim();
37
+ if (!raw) return homedir();
38
+ if (raw === "~") return homedir();
39
+ if (raw.startsWith("~/")) return path.resolve(path.join(homedir(), raw.slice(2)));
40
+ return path.resolve(raw);
41
+ }
42
+
43
+ export class FsError extends Error {
44
+ constructor(message: string, readonly code: "not_found" | "not_a_directory" | "not_a_file" | "permission_denied" | "bad_path") {
45
+ super(message);
46
+ }
47
+ }
48
+
49
+ export function listDirs(input: string | null | undefined, opts: { includeFiles?: boolean } = {}): DirListing {
50
+ const target = expandPath(input);
51
+ let st: fs.Stats;
52
+ try {
53
+ st = fs.statSync(target); // stat (not lstat): a symlinked workspace should work
54
+ } catch (e) {
55
+ const code = (e as NodeJS.ErrnoException).code;
56
+ if (code === "ENOENT") throw new FsError(`no such directory: ${target}`, "not_found");
57
+ if (code === "EACCES") throw new FsError(`permission denied: ${target}`, "permission_denied");
58
+ throw new FsError(`cannot read ${target}: ${code ?? String(e)}`, "bad_path");
59
+ }
60
+ if (!st.isDirectory()) throw new FsError(`not a directory: ${target}`, "not_a_directory");
61
+
62
+ let names: fs.Dirent[] = [];
63
+ try {
64
+ names = fs.readdirSync(target, { withFileTypes: true });
65
+ } catch (e) {
66
+ const code = (e as NodeJS.ErrnoException).code;
67
+ if (code === "EACCES") throw new FsError(`permission denied: ${target}`, "permission_denied");
68
+ throw new FsError(`cannot list ${target}: ${code ?? String(e)}`, "bad_path");
69
+ }
70
+
71
+ const entries: DirEntry[] = [];
72
+ for (const d of names) {
73
+ // Hidden directories are the bulk of a home directory and almost never the answer;
74
+ // the path input still accepts them when someone really wants one.
75
+ if (d.name.startsWith(".")) continue;
76
+ const full = path.join(target, d.name);
77
+ let isDir = d.isDirectory();
78
+ // a symlink to a directory is a normal way to organise work: stat it
79
+ if (!isDir && d.isSymbolicLink()) {
80
+ try {
81
+ isDir = fs.statSync(full).isDirectory();
82
+ } catch {
83
+ continue; // dangling symlink or unreadable target: skip
84
+ }
85
+ }
86
+ if (isDir) {
87
+ entries.push({ name: d.name, path: full, kind: "dir" });
88
+ continue;
89
+ }
90
+ // The file panel (head ▤) wants files too; the picker (new slot) must not
91
+ // offer one as a working directory, so it stays opt-in.
92
+ if (opts.includeFiles && !d.isSymbolicLink()) {
93
+ let size: number | undefined;
94
+ try {
95
+ size = fs.statSync(full).size;
96
+ } catch {
97
+ /* unreadable: still list it, size unknown */
98
+ }
99
+ entries.push({ name: d.name, path: full, kind: "file", size });
100
+ }
101
+ }
102
+ entries.sort((a, b) => (
103
+ a.kind === b.kind
104
+ ? a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" })
105
+ : a.kind === "dir" ? -1 : 1 // directories first, like every file manager
106
+ ));
107
+
108
+ const truncated = entries.length > MAX_ENTRIES;
109
+ const trimmed = truncated ? entries.slice(0, MAX_ENTRIES) : entries;
110
+ const parent = path.dirname(target);
111
+ return {
112
+ path: target,
113
+ parent: parent === target ? null : parent,
114
+ home: homedir(),
115
+ entries: trimmed,
116
+ truncated,
117
+ };
118
+ }
119
+
120
+ export interface FilePreview {
121
+ path: string;
122
+ name: string;
123
+ size: number;
124
+ /** decoded utf-8 text, cut at maxBytes (the tail is reported, never faked) */
125
+ content: string;
126
+ truncated: boolean;
127
+ /** looks like binary: the panel shows a "cannot preview" note instead of mojibake */
128
+ binary: boolean;
129
+ /** how the panel should draw it (see FileKind) */
130
+ kind: FileKind;
131
+ contentType: string;
132
+ mtimeMs: number;
133
+ /** sha1 of the bytes on disk: the editor hands this back on save so a rewrite the agent made
134
+ * while the operator was typing is refused instead of silently overwritten */
135
+ hash: string;
136
+ editable: boolean;
137
+ }
138
+
139
+ /** What the panel should DRAW for a file. Decided here, once, so the browser does not carry a
140
+ * second extension table that can drift from this one. */
141
+ export type FileKind =
142
+ | "markdown" | "image" | "pdf" | "html" | "csv" | "code" | "text"
143
+ | "audio" | "video" | "office" | "archive" | "binary";
144
+
145
+ const KIND_BY_EXT: Record<string, FileKind> = {
146
+ md: "markdown", markdown: "markdown", mdx: "markdown",
147
+ png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image", avif: "image",
148
+ bmp: "image", ico: "image", svg: "image", tiff: "image", heic: "image",
149
+ pdf: "pdf",
150
+ html: "html", htm: "html",
151
+ csv: "csv", tsv: "csv",
152
+ // languages the panel can syntax-highlight (the web side has the hljs set; anything not
153
+ // listed still renders as text, which is the honest outcome)
154
+ js: "code", mjs: "code", cjs: "code", jsx: "code", ts: "code", tsx: "code", json: "code",
155
+ py: "code", rb: "code", go: "code", rs: "code", java: "code", c: "code", h: "code",
156
+ cpp: "code", cc: "code", hpp: "code", cs: "code", php: "code", swift: "code", kt: "code",
157
+ sh: "code", bash: "code", zsh: "code", fish: "code", ps1: "code",
158
+ css: "code", scss: "code", less: "code", sql: "code", yml: "code", yaml: "code",
159
+ toml: "code", ini: "code", conf: "code", env: "code", xml: "code", plist: "code",
160
+ dockerfile: "code", gitignore: "code", patch: "code", diff: "code",
161
+ txt: "text", log: "text", text: "text", lock: "text",
162
+ mp3: "audio", wav: "audio", ogg: "audio", m4a: "audio", flac: "audio", aac: "audio", opus: "audio",
163
+ mp4: "video", webm: "video", mov: "video", mkv: "video", m4v: "video",
164
+ docx: "office", doc: "office", xlsx: "office", xls: "office", pptx: "office", ppt: "office",
165
+ odt: "office", ods: "office", odp: "office", rtf: "office",
166
+ zip: "archive", tar: "archive", gz: "archive", bz2: "archive", xz: "archive", "7z": "archive",
167
+ };
168
+
169
+ const MIME_BY_EXT: Record<string, string> = {
170
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
171
+ avif: "image/avif", bmp: "image/bmp", ico: "image/x-icon", svg: "image/svg+xml",
172
+ tiff: "image/tiff", heic: "image/heic",
173
+ pdf: "application/pdf",
174
+ html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8",
175
+ css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8",
176
+ mjs: "text/javascript; charset=utf-8", json: "application/json; charset=utf-8",
177
+ xml: "application/xml; charset=utf-8", svgz: "image/svg+xml",
178
+ mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", m4a: "audio/mp4", flac: "audio/flac",
179
+ aac: "audio/aac", opus: "audio/opus",
180
+ mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", mkv: "video/x-matroska", m4v: "video/mp4",
181
+ txt: "text/plain; charset=utf-8", md: "text/markdown; charset=utf-8",
182
+ csv: "text/csv; charset=utf-8", tsv: "text/tab-separated-values; charset=utf-8",
183
+ zip: "application/zip", gz: "application/gzip", tar: "application/x-tar",
184
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
185
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
186
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
187
+ };
188
+
189
+ /** Extension of a path, lowercased, without the dot. Dotfiles like `.env` report `env`. */
190
+ function extOf(target: string): string {
191
+ return path.extname(target).replace(/^\./, "").toLowerCase() || path.basename(target).replace(/^\./, "").toLowerCase();
192
+ }
193
+
194
+ export function fileKind(target: string): FileKind {
195
+ return KIND_BY_EXT[extOf(target)] ?? "text"; // unknown extensions are shown as text, never refused
196
+ }
197
+
198
+ /** Only ever used for `inline` preview; unknown types fall back to a neutral binary type
199
+ * rather than a wrong one, so the browser does not try to parse e.g. a .dmg as HTML. */
200
+ export function contentTypeFor(target: string): string {
201
+ return MIME_BY_EXT[extOf(target)] ?? "application/octet-stream";
202
+ }
203
+
204
+ export interface FileStatInfo {
205
+ path: string;
206
+ name: string;
207
+ /** what the panel should draw for this file (one table, see KIND_BY_EXT) */
208
+ kind: FileKind;
209
+ contentType: string;
210
+ size: number;
211
+ mtimeMs: number;
212
+ hash: string;
213
+ /** the file kind is editable in the panel (text-ish only; binaries are not) */
214
+ editable: boolean;
215
+ }
216
+
217
+ const EDITABLE: ReadonlySet<FileKind> = new Set<FileKind>(["markdown", "text", "code", "csv", "html"]);
218
+
219
+ function statOrThrow(target: string): fs.Stats {
220
+ try {
221
+ return fs.statSync(target);
222
+ } catch (e) {
223
+ const code = (e as NodeJS.ErrnoException).code;
224
+ if (code === "ENOENT") throw new FsError(`no such file: ${target}`, "not_found");
225
+ if (code === "EACCES") throw new FsError(`permission denied: ${target}`, "permission_denied");
226
+ throw new FsError(`cannot read ${target}: ${code ?? String(e)}`, "bad_path");
227
+ }
228
+ }
229
+
230
+ /** sha1 of the file's bytes. Doubles as the "did the file change under the editor?" check, so
231
+ * a save can refuse to clobber a rewrite the agent made while the operator was typing. */
232
+ export function fileHash(target: string): string {
233
+ const h = crypto.createHash("sha1");
234
+ const fd = fs.openSync(target, "r");
235
+ try {
236
+ const buf = Buffer.alloc(64 * 1024);
237
+ for (;;) {
238
+ const n = fs.readSync(fd, buf, 0, buf.length, null);
239
+ if (!n) break;
240
+ h.update(buf.subarray(0, n));
241
+ }
242
+ } finally {
243
+ fs.closeSync(fd);
244
+ }
245
+ return h.digest("hex");
246
+ }
247
+
248
+ /** Everything the panel needs to decide HOW to draw a file, without reading its bytes. */
249
+ export function statFile(input: string | null | undefined): FileStatInfo {
250
+ const target = expandPath(input);
251
+ const st = statOrThrow(target);
252
+ if (st.isDirectory()) throw new FsError(`not a file: ${target}`, "not_a_file");
253
+ const kind = fileKind(target);
254
+ return {
255
+ path: target,
256
+ name: path.basename(target),
257
+ kind,
258
+ contentType: contentTypeFor(target),
259
+ size: st.size,
260
+ mtimeMs: st.mtimeMs,
261
+ hash: fileHash(target),
262
+ editable: EDITABLE.has(kind),
263
+ };
264
+ }
265
+
266
+ export interface WriteResult {
267
+ path: string;
268
+ size: number;
269
+ mtimeMs: number;
270
+ hash: string;
271
+ }
272
+
273
+ const MAX_WRITE_BYTES = 8 * 1024 * 1024;
274
+
275
+ /** Save a text file the operator edited in the panel.
276
+ *
277
+ * Deliberate limits: the file must ALREADY exist (this is an editor for files the agent and
278
+ * the operator are looking at, not a way to create them — a chat window should not be able to
279
+ * drop new files anywhere the process can write), it must be a text-ish kind, and it must fit
280
+ * in memory. The write goes through a temp file + rename so a crash mid-save cannot leave a
281
+ * half-written file where an agent might read it; mode is carried over from the original.
282
+ *
283
+ * `ifHash` is the hash the editor loaded. When it no longer matches, the agent changed the
284
+ * file while the operator was typing: a 409 with the current bytes beats silently losing
285
+ * either side's work. */
286
+ export function writeTextFile(
287
+ input: string | null | undefined,
288
+ content: string,
289
+ opts: { ifHash?: string } = {},
290
+ ): WriteResult {
291
+ const target = expandPath(input);
292
+ const st = statOrThrow(target);
293
+ if (st.isDirectory()) throw new FsError(`not a file: ${target}`, "not_a_file");
294
+ const kind = fileKind(target);
295
+ if (!EDITABLE.has(kind)) {
296
+ throw new FsError(`not an editable file: ${path.basename(target)} (${kind})`, "not_a_file");
297
+ }
298
+ const bytes = Buffer.from(content, "utf8");
299
+ if (bytes.length > MAX_WRITE_BYTES) {
300
+ throw new FsError(`content too large (${bytes.length} > ${MAX_WRITE_BYTES})`, "bad_path");
301
+ }
302
+ const before = opts.ifHash ? fileHash(target) : "";
303
+ if (opts.ifHash && opts.ifHash !== before) {
304
+ const cur = readTextFile(target);
305
+ throw new ChangedOnDiskError(target, before, cur.content, cur.truncated);
306
+ }
307
+ const tmp = `${target}.agentus-${process.pid}.tmp`;
308
+ try {
309
+ fs.writeFileSync(tmp, bytes, { mode: st.mode & 0o7777 });
310
+ fs.renameSync(tmp, target);
311
+ } catch (e) {
312
+ try { fs.rmSync(tmp, { force: true }); } catch { /* the temp file may never have existed */ }
313
+ throw new FsError(`cannot write ${target}: ${String((e as NodeJS.ErrnoException).code ?? e)}`, "bad_path");
314
+ }
315
+ const after = fs.statSync(target);
316
+ return { path: target, size: after.size, mtimeMs: after.mtimeMs, hash: fileHash(target) };
317
+ }
318
+
319
+ /** The operator's buffer is based on bytes that no longer exist on disk. Carries the CURRENT
320
+ * content so the panel can offer "reload theirs" without a second round trip. */
321
+ export class ChangedOnDiskError extends FsError {
322
+ constructor(
323
+ readonly target: string,
324
+ readonly currentHash: string,
325
+ readonly currentContent: string,
326
+ readonly truncated: boolean,
327
+ ) {
328
+ super("changed_on_disk", "bad_path");
329
+ }
330
+ }
331
+
332
+ const DEFAULT_PREVIEW_BYTES = 256 * 1024;
333
+
334
+ /** Read a file for the workspace panel.
335
+ *
336
+ * This used to be read-only on the reasoning that "writing files is the agent's job, not the
337
+ * cockpit's". The operator overruled that (2026-10-09: 「可编辑一起搞吧」) and it is his call — a
338
+ * cockpit where he can fix a line of markdown without leaving for an editor is the point. So
339
+ * the read now also reports what the panel needs to EDIT safely (`hash`, `editable`), and
340
+ * `writeTextFile` below does the saving. Reading is still capped and still refuses to guess at
341
+ * binaries. */
342
+ export function readTextFile(input: string, opts: { maxBytes?: number } = {}): FilePreview {
343
+ const target = expandPath(input);
344
+ const maxBytes = Math.max(1024, opts.maxBytes ?? DEFAULT_PREVIEW_BYTES);
345
+ let st: fs.Stats;
346
+ try {
347
+ st = fs.statSync(target);
348
+ } catch (e) {
349
+ const code = (e as NodeJS.ErrnoException).code;
350
+ if (code === "ENOENT") throw new FsError(`no such file: ${target}`, "not_found");
351
+ if (code === "EACCES") throw new FsError(`permission denied: ${target}`, "permission_denied");
352
+ throw new FsError(`cannot read ${target}: ${code ?? String(e)}`, "bad_path");
353
+ }
354
+ if (st.isDirectory()) throw new FsError(`not a file: ${target}`, "not_a_file");
355
+ const fd = fs.openSync(target, "r");
356
+ try {
357
+ const len = Math.min(st.size, maxBytes);
358
+ const buf = Buffer.alloc(len);
359
+ fs.readSync(fd, buf, 0, len, 0);
360
+ // A NUL byte in the first 8KB is the classic binary tell.
361
+ const binary = buf.subarray(0, Math.min(buf.length, 8192)).includes(0);
362
+ const kind = fileKind(target);
363
+ return {
364
+ path: target,
365
+ name: path.basename(target),
366
+ size: st.size,
367
+ content: binary ? "" : buf.toString("utf8"),
368
+ truncated: st.size > len,
369
+ binary,
370
+ kind,
371
+ contentType: contentTypeFor(target),
372
+ mtimeMs: st.mtimeMs,
373
+ hash: fileHash(target),
374
+ editable: EDITABLE.has(kind),
375
+ };
376
+ } finally {
377
+ fs.closeSync(fd);
378
+ }
379
+ }