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.
- package/README.md +19 -2
- package/README.zh-CN.md +396 -0
- package/docs/dev/01-dev-loop.md +186 -0
- package/docs/dev/02-verify.md +237 -0
- package/docs/dev/03-release.md +218 -0
- package/docs/dev/04-recover.md +199 -0
- package/docs/dev/05-agent-driven.md +114 -0
- package/docs/dev/README.md +65 -0
- package/docs/installation.md +366 -0
- package/docs/installation.zh-CN.md +350 -0
- package/package.json +5 -4
- package/packages/server/mock/agent.mjs +35 -0
- package/packages/server/src/acp/registry.ts +51 -2
- package/packages/server/src/acp/session-manager.ts +200 -39
- package/packages/server/src/fs.ts +216 -2
- package/packages/server/src/index.ts +172 -15
- package/packages/server/src/notify/center.ts +12 -1
- package/packages/server/src/pty-helper.py +135 -0
- package/packages/server/src/settings.ts +64 -1
- package/packages/server/src/store/store.ts +35 -3
- package/packages/server/src/term.ts +69 -28
- package/packages/shared/src/index.ts +11 -1
- package/packages/web/dist/assets/index-C5XlgEcj.js +283 -0
- package/packages/web/dist/assets/index-D667PX2c.css +32 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-B52l55_4.css +0 -1
- package/packages/web/dist/assets/index-BRgC7oYt.js +0 -246
|
@@ -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
|
-
/**
|
|
703
|
-
archived
|
|
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) =>
|
|
711
|
-
.map((r) =>
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
-
|
|
1698
|
-
|
|
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.
|
|
133
|
-
*
|
|
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);
|