agentus 0.1.0 → 0.3.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.
@@ -209,10 +209,17 @@ export class SessionManager {
209
209
  // one you come back to, even when it is the oldest row in the list.
210
210
  const lastAt = this.#store.lastActivityAt();
211
211
  return [...this.#sessions.values()]
212
+ // An ARCHIVED session is not 工作空间 material, whatever its process is doing (resume leaves the
213
+ // flag alone, so a restored-then-archived slot can be live). It is listed by `archived()`
214
+ // instead — one row in one bucket, or the rail shows the same slot twice.
215
+ .filter((s) => this.#store.getSession(s.info.id)?.archivedAt == null)
212
216
  .map((s) => ({
213
217
  ...s.info,
214
218
  lastSeq: this.#store.maxSeq(s.info.id),
215
219
  lastAt: lastAt.get(s.info.id) ?? s.info.createdAt,
220
+ archivedAt: null,
221
+ // a row in the manager's map has a process, by definition
222
+ cold: false,
216
223
  }))
217
224
  .sort((a, b) => (b.lastAt ?? 0) - (a.lastAt ?? 0) || b.createdAt - a.createdAt);
218
225
  }
@@ -416,6 +423,11 @@ export class SessionManager {
416
423
  live.info.models = readModels(res);
417
424
  // the row just proved itself: remember that, plus what the agent said it can do
418
425
  this.#noteBackendCheck(String(backend), { ok: true, latencyMs: Date.now() - spawnStarted }, handshakeFrom(init, res));
426
+ // The row's DEFAULT permission mode, applied HERE: on a brand-new session, before the first
427
+ // prompt. Hermes reads its mode through a live getter (acp_adapter/server.py `policy_getter`),
428
+ // so the very first turn is already governed by it. A later `resume()` never passes through
429
+ // this method — that is exactly what keeps a per-session pick from being silently undone.
430
+ await this.#applyDefaultMode(live);
419
431
  this.#updateSession(live);
420
432
  return live.info;
421
433
  } catch (err) {
@@ -699,33 +711,74 @@ export class SessionManager {
699
711
  });
700
712
  }
701
713
 
702
- /** Sessions we know about on disk but have no process for (rail's cold slots). */
703
- archived(limit = 20): SessionInfo[] {
714
+ /** Rows the OPERATOR put away — 已归档. Chosen by the archive FLAG, not by liveness: a session
715
+ * the operator archived belongs there whatever its process is doing, and a session that merely
716
+ * lost its process (crashed, was closed, or was reaped for being idle) does NOT — that was the
717
+ * old rule, and it turned 已归档 into a drawer where the operator's own intent was
718
+ * indistinguishable from debris.
719
+ *
720
+ * No cap: this list is curated by hand, so hiding its tail would hide the operator's own work.
721
+ * Real removal is the ✕ purge button (DELETE on a cold row). */
722
+ archived(): SessionInfo[] {
723
+ const lastAtAll = this.#store.lastActivityAt();
704
724
  return this.#store
705
- // include closed rows: "close" means "kill the process, keep the transcript as a cold
706
- // slot" (that is what the UI's close prompt promises) — filtering status!='closed' made
707
- // the slot vanish from the rail entirely, leaving the transcript unreachable even though
708
- // it was still on disk. Real deletion is the separate ✕ purge button.
709
725
  .listSessions(true)
710
- .filter((r) => !this.#sessions.has(r.id))
711
- .map((r) => ({
712
- id: r.id, backend: r.backend, acpSessionId: r.acpSessionId, cwd: r.cwd,
713
- workspace: r.workspace ?? null,
714
- models: (r.models ?? null) as SessionInfo["models"],
715
- contextLimit: r.contextLimit ?? null,
716
- title: r.title, autoTitle: r.autoTitle ?? null, status: r.status, pid: null, createdAt: r.createdAt,
717
- modes: (r.modes ?? null) as SessionModeState | null,
718
- configOptions: (r.configOptions ?? []) as ConfigOptionView[],
719
- usage: (r.usage ?? null) as SessionInfo["usage"],
720
- commands: normCommands(r.commands),
721
- lastSeq: this.#store.maxSeq(r.id),
722
- lastAt: this.#store.lastActivityAt().get(r.id) ?? r.createdAt,
723
- }))
726
+ .filter((r) => r.archivedAt != null)
727
+ .map((r) => {
728
+ const live = this.#sessions.get(r.id);
729
+ // An archived row can still have a process — resume does not touch the flag — and then it is
730
+ // NOT cold: the rail must draw a live row (and clicking it hands over the floor rather than
731
+ // waking anything), or the operator is told a slot is asleep while it is running.
732
+ return live
733
+ ? {
734
+ ...live.info,
735
+ lastSeq: this.#store.maxSeq(r.id),
736
+ lastAt: lastAtAll.get(r.id) ?? r.createdAt,
737
+ archivedAt: r.archivedAt ?? null,
738
+ cold: false,
739
+ }
740
+ : this.#infoFromRow(r);
741
+ })
742
+ .sort((a, b) => (b.archivedAt ?? 0) - (a.archivedAt ?? 0) || (b.lastAt ?? 0) - (a.lastAt ?? 0));
743
+ }
744
+
745
+ /** Cold rows that are NOT archived: on disk, no process, still part of 工作空间. A crashed slot,
746
+ * one the operator closed, or one THIS cockpit reaped for being idle — all of them must stay
747
+ * reachable in the rail (the old rule showed them under 已归档; the older one dropped them out of
748
+ * the list entirely, which is how a transcript became unreachable while still on disk).
749
+ *
750
+ * Capped, unlike `archived()`: crash debris accumulates without anyone choosing it, and the rail
751
+ * has its own recent-N rule anyway. */
752
+ cold(limit = 20): SessionInfo[] {
753
+ return this.#store
754
+ .listSessions(true)
755
+ .filter((r) => r.archivedAt == null && !this.#sessions.has(r.id))
756
+ .map((r) => this.#infoFromRow(r))
724
757
  // cold rows by the same rule as live ones (recent chat first), THEN take the page
725
758
  .sort((a, b) => (b.lastAt ?? 0) - (a.lastAt ?? 0) || b.createdAt - a.createdAt)
726
759
  .slice(0, limit);
727
760
  }
728
761
 
762
+ /** One row in the shape list()/cold()/archived() produce, from a stored row. */
763
+ #infoFromRow(r: SessionRow): SessionInfo {
764
+ return {
765
+ id: r.id, backend: r.backend, acpSessionId: r.acpSessionId, cwd: r.cwd,
766
+ workspace: r.workspace ?? null,
767
+ models: (r.models ?? null) as SessionInfo["models"],
768
+ contextLimit: r.contextLimit ?? null,
769
+ title: r.title, autoTitle: r.autoTitle ?? null, status: r.status, pid: null, createdAt: r.createdAt,
770
+ modes: (r.modes ?? null) as SessionModeState | null,
771
+ configOptions: (r.configOptions ?? []) as ConfigOptionView[],
772
+ usage: (r.usage ?? null) as SessionInfo["usage"],
773
+ commands: normCommands(r.commands),
774
+ lastSeq: this.#store.maxSeq(r.id),
775
+ lastAt: this.#store.lastActivityAt().get(r.id) ?? r.createdAt,
776
+ archivedAt: r.archivedAt ?? null,
777
+ // no process by construction: this path only ever runs for a row that is not in the map
778
+ cold: true,
779
+ };
780
+ }
781
+
729
782
  async prompt(
730
783
  sessionId: string,
731
784
  text: string,
@@ -827,6 +880,47 @@ export class SessionManager {
827
880
  await s.conn.cancel({ sessionId: s.info.acpSessionId }).catch(() => {});
828
881
  }
829
882
 
883
+ /**
884
+ * Put a NEW session into its backend row's default permission mode (BackendRow.defaultMode).
885
+ * Called from `create()` only — see the call site for why a resume must never do this.
886
+ *
887
+ * Three refusals are deliberate:
888
+ * · a row with no default sends NOTHING (the agent keeps its own default) — so a registry the
889
+ * operator never touched behaves exactly as it did before this existed;
890
+ * · a mode the agent did not advertise is not sent either: it would come back as a refusal nobody
891
+ * sees, and the settings row already wears a warning saying the pick does not match this
892
+ * agent's handshake (registry.rowWarnings);
893
+ * · a failure NEVER fails the slot. The session is usable, just in the agent's own mode — and the
894
+ * log line is the only evidence of which of those two happened, so it is not optional.
895
+ */
896
+ async #applyDefaultMode(live: LiveSession): Promise<void> {
897
+ const wanted = (this.#store.getBackend(String(live.info.backend))?.defaultMode ?? "").trim();
898
+ if (!wanted) return;
899
+ const modes = live.info.modes;
900
+ const acpSessionId = live.info.acpSessionId;
901
+ if (!modes || !live.conn || !acpSessionId) return;
902
+ const ids = (modes.availableModes ?? []).map((m) => m.id);
903
+ if (!ids.includes(wanted)) {
904
+ console.log(
905
+ `[agentus] session ${live.info.id}: default permission mode "${wanted}" is not advertised by ` +
906
+ `${String(live.info.backend)} (${ids.join(", ") || "no modes"}) — sending nothing`,
907
+ );
908
+ return;
909
+ }
910
+ if (modes.currentModeId === wanted) return;
911
+ try {
912
+ await live.conn.setSessionMode({ sessionId: acpSessionId, modeId: wanted });
913
+ live.info.modes = { ...modes, currentModeId: wanted };
914
+ console.log(`[agentus] session ${live.info.id}: default permission mode -> ${wanted}`);
915
+ } catch (e) {
916
+ // qodercli advertises modes but answered -32601 in the design probe, so "advertised but not
917
+ // implemented" is a real case — same tolerance as setMode(), and never a failed spawn.
918
+ console.log(
919
+ `[agentus] session ${live.info.id}: default permission mode "${wanted}" not applied: ${errMessage(e)}`,
920
+ );
921
+ }
922
+ }
923
+
830
924
  async setMode(sessionId: string, modeId: string): Promise<void> {
831
925
  const s = this.#need(sessionId);
832
926
  if (!s.conn || !s.info.acpSessionId) throw new Error("session not ready");
@@ -1107,17 +1201,87 @@ export class SessionManager {
1107
1201
 
1108
1202
  async closeSession(sessionId: string): Promise<void> {
1109
1203
  const s = this.#need(sessionId);
1110
- for (const { resolve, timer } of s.pendingPermissions.values()) {
1204
+ this.#stop(s, "closed");
1205
+ this.#emitSessions();
1206
+ }
1207
+
1208
+ /** 归档 / 取消归档 — the operator's own STORAGE decision, taken independently of the process.
1209
+ *
1210
+ * Archiving a LIVE session stops its process too: 「我把它收起来了」 and 「它还在后台吃内存」
1211
+ * contradict each other, and demanding a close first would make the archive a two-step ritual that
1212
+ * says nothing about intent. Un-archiving only moves the flag — the row lands back in 工作空间 as
1213
+ * a cold slot, and the rail's 「取消归档并恢复」 wakes it with a separate resume (so a restore that
1214
+ * fails to load leaves a visible cold slot rather than an error nobody asked for). */
1215
+ async setArchived(id: string, on: boolean): Promise<SessionInfo> {
1216
+ const live = this.#sessions.get(id);
1217
+ const row = this.#store.getSession(id);
1218
+ if (!row) throw new Error(`no such session: ${id}`);
1219
+ if (on && live) this.#stop(live, "archived");
1220
+ this.#store.setArchived(id, on ? Date.now() : null);
1221
+ this.#emitSessions();
1222
+ return this.#sessions.get(id)?.info ?? this.#coldInfo(id);
1223
+ }
1224
+
1225
+ /** Stop a slot's agent process and record WHY it went away. The row always survives with its
1226
+ * transcript: the transcript is the operator's, the process is ours to reclaim.
1227
+ *
1228
+ * `reaped` is its own status on purpose — 「它怎么变冷了」 has three different answers (I closed
1229
+ * it / it crashed / the cockpit reclaimed it for being idle) and a rail that shows one word for
1230
+ * all three teaches the operator to distrust the column. */
1231
+ #stop(live: LiveSession, why: "closed" | "archived" | "reaped"): void {
1232
+ for (const { resolve, timer } of live.pendingPermissions.values()) {
1111
1233
  clearTimeout(timer);
1112
1234
  resolve({ outcome: { outcome: "cancelled" } });
1113
1235
  }
1114
- s.pendingPermissions.clear();
1115
- s.child?.kill("SIGTERM");
1116
- s.info.status = "closed";
1117
- const row = this.#store.getSession(sessionId);
1118
- if (row) this.#store.upsertSession({ ...row, status: "closed", closedAt: Date.now(), pid: null });
1119
- this.#sessions.delete(sessionId);
1120
- this.#emitSessions();
1236
+ live.pendingPermissions.clear();
1237
+ live.child?.kill("SIGTERM");
1238
+ live.info.status = why === "reaped" ? "reaped" : "closed";
1239
+ live.info.pid = null;
1240
+ const row = this.#store.getSession(live.info.id);
1241
+ if (row) {
1242
+ this.#store.upsertSession({
1243
+ ...row, status: live.info.status, closedAt: Date.now(), pid: null,
1244
+ });
1245
+ }
1246
+ this.#sessions.delete(live.info.id);
1247
+ }
1248
+
1249
+ /** Reclaim idle slots: stop the agent process of a session nobody has touched for the operator's
1250
+ * threshold. Deliberately NOT an archive — the row stays in 工作空间 wearing `reaped` and a click
1251
+ * brings it back. AionUi ships exactly this switch (设置 → 系统 → 「Agent 空闲超时(分钟)」,
1252
+ * default 5, 1–60) and the guards below are what its changelog had to add after the first version
1253
+ * killed agents that were still working: never touch a slot mid-turn, one waiting on a human, or
1254
+ * one the operator is WATCHING right now (a reply that lands while he reads it must not be killed
1255
+ * under him).
1256
+ *
1257
+ * `idleMs()` is read on every tick, so a settings change applies with no restart; 0 = off. */
1258
+ startIdleReaper(opts: { idleMs: () => number; watched?: (id: string) => boolean; intervalMs?: number }): () => void {
1259
+ const interval = opts.intervalMs ?? 30_000;
1260
+ const tick = (): void => {
1261
+ const idleMs = opts.idleMs();
1262
+ if (!(idleMs > 0)) return;
1263
+ const now = Date.now();
1264
+ let reaped = 0;
1265
+ for (const s of [...this.#sessions.values()]) {
1266
+ if (s.busy || s.pendingPermissions.size) continue;
1267
+ if (s.info.status === "starting" || s.info.status === "running") continue;
1268
+ if (opts.watched?.(s.info.id)) continue;
1269
+ const lastAt = s.info.lastAt ?? s.info.createdAt;
1270
+ if (now - lastAt < idleMs) continue;
1271
+ console.log(
1272
+ `[agentus] idle reap: ${s.info.id} (${Math.round((now - lastAt) / 60_000)} min idle, limit ${Math.round(idleMs / 60_000)} min)`,
1273
+ );
1274
+ this.#stop(s, "reaped");
1275
+ reaped += 1;
1276
+ }
1277
+ // Only speak when something changed: a frame every tick would be noise on the socket (and on
1278
+ // the phone's notification path, which observes this same stream).
1279
+ if (reaped) this.#emitSessions();
1280
+ };
1281
+ const timer = setInterval(tick, interval);
1282
+ // A server that will not exit because of the reclaim timer is a worse bug than the memory it saves.
1283
+ timer.unref?.();
1284
+ return () => clearInterval(timer);
1121
1285
  }
1122
1286
 
1123
1287
  /** The list every client needs to be current: the rows PLUS what is already waiting for an
@@ -1689,22 +1853,14 @@ export class SessionManager {
1689
1853
  this.#store.touchActivity(live.info.id, at);
1690
1854
  }
1691
1855
 
1692
- /** One cold row, in the same shape list()/archived() produce. */
1856
+ /** One cold row, in the same shape list()/cold()/archived() produce. */
1693
1857
  #coldInfo(id: string): SessionInfo {
1694
1858
  const r = this.#store.getSession(id);
1695
1859
  if (!r) throw new Error(`no such session: ${id}`);
1696
1860
  return {
1697
- id: r.id, backend: r.backend, acpSessionId: r.acpSessionId, cwd: r.cwd,
1698
- workspace: r.workspace ?? null, contextLimit: r.contextLimit ?? null,
1861
+ ...this.#infoFromRow(r),
1862
+ // the one difference from the list path: the model's own window, which the resume card prints
1699
1863
  modelContextLimit: this.#store.getModelLimit(((r.models ?? null) as SessionInfo["models"])?.currentModelId ?? null),
1700
- models: (r.models ?? null) as SessionInfo["models"],
1701
- title: r.title, autoTitle: r.autoTitle ?? null, status: r.status, pid: null, createdAt: r.createdAt,
1702
- modes: (r.modes ?? null) as SessionModeState | null,
1703
- configOptions: (r.configOptions ?? []) as ConfigOptionView[],
1704
- usage: (r.usage ?? null) as SessionInfo["usage"],
1705
- commands: normCommands(r.commands),
1706
- lastSeq: this.#store.maxSeq(id),
1707
- lastAt: this.#store.lastActivityAt().get(id) ?? r.createdAt,
1708
1864
  };
1709
1865
  }
1710
1866
 
@@ -1748,6 +1904,8 @@ export class SessionManager {
1748
1904
  pid: null,
1749
1905
  createdAt: now,
1750
1906
  closedAt: null,
1907
+ // a fork is a NEW conversation: it is nobody's archive (the parent's flag is not inherited)
1908
+ archivedAt: null,
1751
1909
  // the agent just told us the fork's modes/options — keep them so the new slot shows
1752
1910
  // the right permission mode and thinking depth before it is even resumed
1753
1911
  modes: res?.modes ?? source.info.modes ?? null,
@@ -1798,6 +1956,9 @@ function sessionRow(i: SessionInfo, home: string | null = null) {
1798
1956
  return {
1799
1957
  id: i.id, backend: i.backend, acpSessionId: i.acpSessionId, cwd: i.cwd,
1800
1958
  title: i.title, status: i.status, pid: i.pid, createdAt: i.createdAt, closedAt: null,
1959
+ // never written by this upsert (the archive flag has its own statement — see setArchived):
1960
+ // carried here only so the row type stays honest.
1961
+ archivedAt: i.archivedAt ?? null,
1801
1962
  modes: i.modes ?? null, configOptions: i.configOptions ?? [],
1802
1963
  usage: i.usage ?? null, commands: i.commands ?? [],
1803
1964
  models: i.models ?? null,
@@ -4,6 +4,7 @@
4
4
  // product), so this adds no new power — it just makes the choice discoverable instead
5
5
  // of demanding a path typed from memory. Listing is deliberately narrow: directories
6
6
  // only, one level at a time, capped, and never followed through symlinks we cannot stat.
7
+ import crypto from "node:crypto";
7
8
  import fs from "node:fs";
8
9
  import { homedir } from "node:os";
9
10
  import path from "node:path";
@@ -125,12 +126,219 @@ export interface FilePreview {
125
126
  truncated: boolean;
126
127
  /** looks like binary: the panel shows a "cannot preview" note instead of mojibake */
127
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
+ }
128
330
  }
129
331
 
130
332
  const DEFAULT_PREVIEW_BYTES = 256 * 1024;
131
333
 
132
- /** Read a file for the workspace panel. Read-only on purpose: this panel is a
133
- * browser, not an editor — writing files is the agent's job, not the cockpit's. */
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. */
134
342
  export function readTextFile(input: string, opts: { maxBytes?: number } = {}): FilePreview {
135
343
  const target = expandPath(input);
136
344
  const maxBytes = Math.max(1024, opts.maxBytes ?? DEFAULT_PREVIEW_BYTES);
@@ -151,6 +359,7 @@ export function readTextFile(input: string, opts: { maxBytes?: number } = {}): F
151
359
  fs.readSync(fd, buf, 0, len, 0);
152
360
  // A NUL byte in the first 8KB is the classic binary tell.
153
361
  const binary = buf.subarray(0, Math.min(buf.length, 8192)).includes(0);
362
+ const kind = fileKind(target);
154
363
  return {
155
364
  path: target,
156
365
  name: path.basename(target),
@@ -158,6 +367,11 @@ export function readTextFile(input: string, opts: { maxBytes?: number } = {}): F
158
367
  content: binary ? "" : buf.toString("utf8"),
159
368
  truncated: st.size > len,
160
369
  binary,
370
+ kind,
371
+ contentType: contentTypeFor(target),
372
+ mtimeMs: st.mtimeMs,
373
+ hash: fileHash(target),
374
+ editable: EDITABLE.has(kind),
161
375
  };
162
376
  } finally {
163
377
  fs.closeSync(fd);