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
|
@@ -75,6 +75,9 @@ export interface SessionRow {
|
|
|
75
75
|
pid: number | null;
|
|
76
76
|
createdAt: number;
|
|
77
77
|
closedAt: number | null;
|
|
78
|
+
/** When the OPERATOR put this session away (已归档). Null = it belongs in 工作空间. A storage
|
|
79
|
+
* state: it says nothing about whether a process is running (see SessionInfo.archivedAt). */
|
|
80
|
+
archivedAt: number | null;
|
|
78
81
|
/** JSON round-trip of the ACP mode state + config options, so a resumed
|
|
79
82
|
* session (M4-lite) comes back with the operator's mode/effort intact. */
|
|
80
83
|
modes?: unknown;
|
|
@@ -105,7 +108,7 @@ export interface SessionRow {
|
|
|
105
108
|
interface RawSessionRow {
|
|
106
109
|
id: string; backend: BackendId; acp_session_id: string | null; cwd: string;
|
|
107
110
|
title: string; auto_title?: string | null; status: SessionStatus; pid: number | null;
|
|
108
|
-
created_at: number; closed_at: number | null;
|
|
111
|
+
created_at: number; closed_at: number | null; archived_at?: number | null;
|
|
109
112
|
modes?: string | null; config_options?: string | null;
|
|
110
113
|
usage?: string | null; commands?: string | null;
|
|
111
114
|
workspace?: string | null;
|
|
@@ -169,7 +172,7 @@ function rowToSession(r: RawSessionRow): SessionRow {
|
|
|
169
172
|
return {
|
|
170
173
|
id: r.id, backend: r.backend, acpSessionId: r.acp_session_id, cwd: r.cwd,
|
|
171
174
|
title: r.title, autoTitle: r.auto_title ?? null, status: r.status, pid: r.pid,
|
|
172
|
-
createdAt: r.created_at, closedAt: r.closed_at,
|
|
175
|
+
createdAt: r.created_at, closedAt: r.closed_at, archivedAt: r.archived_at ?? null,
|
|
173
176
|
modes: parseJson(r.modes), configOptions: parseJson(r.config_options) ?? [],
|
|
174
177
|
usage: parseJson(r.usage), commands: parseJson(r.commands) ?? [],
|
|
175
178
|
workspace: r.workspace ?? null,
|
|
@@ -182,6 +185,7 @@ function rowToSession(r: RawSessionRow): SessionRow {
|
|
|
182
185
|
interface RawBackendRow {
|
|
183
186
|
id: string; label: string; kind: string; cmd: string; args: string; env: string;
|
|
184
187
|
home: string | null; profile: string | null; cwd: string | null; notes: string;
|
|
188
|
+
default_mode: string | null;
|
|
185
189
|
native_plan_source: string | null;
|
|
186
190
|
builtin: number; created_at: number; updated_at: number;
|
|
187
191
|
// health snapshot (M6.1) — system-written, see registry.BackendHealth
|
|
@@ -212,6 +216,9 @@ function rowToBackend(r: RawBackendRow): BackendRow {
|
|
|
212
216
|
home: r.home ?? null,
|
|
213
217
|
profile: r.profile ?? null,
|
|
214
218
|
cwd: r.cwd ?? null,
|
|
219
|
+
// A row predating the column (or a seeded one) says nothing: send no mode at all, so the agent
|
|
220
|
+
// keeps its own default. No mode name is invented here — "缺省" must mean "不上手".
|
|
221
|
+
defaultMode: (r.default_mode ?? "").trim() || null,
|
|
215
222
|
notes: r.notes ?? "",
|
|
216
223
|
builtin: r.builtin === 1,
|
|
217
224
|
health: {
|
|
@@ -304,6 +311,8 @@ export class Store {
|
|
|
304
311
|
id text primary key, label text not null, kind text not null,
|
|
305
312
|
cmd text not null, args text not null default '[]', env text not null default '{}',
|
|
306
313
|
home text, profile text, cwd text, notes text not null default '',
|
|
314
|
+
-- the ACP mode a NEW session from this row starts in (null = the agent's own default)
|
|
315
|
+
default_mode text,
|
|
307
316
|
builtin integer not null default 0,
|
|
308
317
|
created_at integer not null, updated_at integer not null
|
|
309
318
|
);
|
|
@@ -334,6 +343,13 @@ export class Store {
|
|
|
334
343
|
this.#db.exec("update sessions set auto_title = title where auto_title is null or auto_title = ''");
|
|
335
344
|
// integer column, so it gets its own migration (the loop above assumes text)
|
|
336
345
|
if (!cols.has("context_limit")) this.#db.exec("alter table sessions add column context_limit integer");
|
|
346
|
+
// WHEN THE OPERATOR PUT THE SESSION AWAY (已归档 — a STORAGE state). Before this column the rail
|
|
347
|
+
// inferred "archived" from "has no process", so a slot that merely crashed (or was killed by an
|
|
348
|
+
// idle reap) filed itself under 已归档 next to the ones the operator really put away, and
|
|
349
|
+
// 「我在归档一栏里看到的」 stopped meaning anything. NULL is the honest default for every existing
|
|
350
|
+
// row: nobody ever archived them, they were just dead. The 已归档 section starts empty and fills
|
|
351
|
+
// with what the operator actually archives.
|
|
352
|
+
if (!cols.has("archived_at")) this.#db.exec("alter table sessions add column archived_at integer");
|
|
337
353
|
// The session's own ACTIVITY clock — when the operator or the agent last really said something
|
|
338
354
|
// in it. A COLUMN, not a derived `max(messages.created_at)` (AionUi's model: `modified_at` on
|
|
339
355
|
// the conversation row, indexed, which is what its sidebar sorts by): every ingestion path
|
|
@@ -362,6 +378,9 @@ export class Store {
|
|
|
362
378
|
// P1 (plan service): which channel carries this backend's plan, `acp` | `none`. Nullable on
|
|
363
379
|
// purpose — a row that predates it follows the same default as a seeded one (see rowToBackend).
|
|
364
380
|
"native_plan_source",
|
|
381
|
+
// The permission mode a new session starts in. Nullable like the rest: a row that predates the
|
|
382
|
+
// column means "send nothing" (the agent's own default), not "some mode we invented".
|
|
383
|
+
"default_mode",
|
|
365
384
|
]) {
|
|
366
385
|
if (!beCols.has(col)) this.#db.exec(`alter table backends add column ${col} text`);
|
|
367
386
|
}
|
|
@@ -693,6 +712,16 @@ export class Store {
|
|
|
693
712
|
}
|
|
694
713
|
}
|
|
695
714
|
|
|
715
|
+
/** Put this session away — or take it back out (已归档). Its OWN statement, like `home`: the
|
|
716
|
+
* archive flag is the one thing the operator sets directly, and a routine row upsert (a status
|
|
717
|
+
* change, a rename, an idle reap) must not be able to clear it. Returns whether a row matched. */
|
|
718
|
+
setArchived(sessionId: string, at: number | null): boolean {
|
|
719
|
+
const info = this.#db
|
|
720
|
+
.prepare("update sessions set archived_at = ? where id = ?")
|
|
721
|
+
.run(at, sessionId);
|
|
722
|
+
return Number(info.changes ?? 0) > 0;
|
|
723
|
+
}
|
|
724
|
+
|
|
696
725
|
/** Record (or adopt) the home a session was created under. Used when a legacy row — created
|
|
697
726
|
* before the column existed — is resumed successfully: the successful load is itself the
|
|
698
727
|
* proof of which home holds its state, so the row stops being ambiguous. */
|
|
@@ -1153,16 +1182,18 @@ export class Store {
|
|
|
1153
1182
|
this.#db
|
|
1154
1183
|
.prepare(
|
|
1155
1184
|
`insert into backends (id, label, kind, cmd, args, env, home, profile, cwd, notes,
|
|
1185
|
+
default_mode,
|
|
1156
1186
|
native_plan_source,
|
|
1157
1187
|
builtin, created_at, updated_at,
|
|
1158
1188
|
last_check_status, last_check_kind, last_check_error_code,
|
|
1159
1189
|
last_check_error_message, last_check_guidance, last_check_latency_ms,
|
|
1160
1190
|
last_check_at, last_success_at, last_failure_at, handshake, handshake_at)
|
|
1161
|
-
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1191
|
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1162
1192
|
on conflict(id) do update set
|
|
1163
1193
|
label = excluded.label, kind = excluded.kind, cmd = excluded.cmd, args = excluded.args,
|
|
1164
1194
|
env = excluded.env, home = excluded.home, profile = excluded.profile, cwd = excluded.cwd,
|
|
1165
1195
|
notes = excluded.notes,
|
|
1196
|
+
default_mode = excluded.default_mode,
|
|
1166
1197
|
native_plan_source = excluded.native_plan_source,
|
|
1167
1198
|
updated_at = excluded.updated_at,
|
|
1168
1199
|
-- the evidence travels with the row: a spawn-relevant edit replaces it with a cleared
|
|
@@ -1179,6 +1210,7 @@ export class Store {
|
|
|
1179
1210
|
.run(
|
|
1180
1211
|
row.id, row.label, row.kind, row.cmd, JSON.stringify(row.args), JSON.stringify(row.env ?? {}),
|
|
1181
1212
|
row.home, row.profile, row.cwd, row.notes ?? "",
|
|
1213
|
+
row.defaultMode ?? null,
|
|
1182
1214
|
row.nativePlanSource ?? null,
|
|
1183
1215
|
row.builtin ? 1 : 0, row.createdAt, row.updatedAt,
|
|
1184
1216
|
row.health?.status ?? null, row.health?.kind ?? null, row.health?.errorCode ?? null,
|
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
// because that is the directory the operator pointed this slot at. It is not
|
|
20
20
|
// a sandbox and does not pretend to be: the person running the cockpit already
|
|
21
21
|
// owns the machine (same trust model as the agent processes).
|
|
22
|
-
import { spawn, type
|
|
22
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
23
23
|
import { randomUUID } from "node:crypto";
|
|
24
24
|
import fs from "node:fs";
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
26
27
|
|
|
27
28
|
export interface TermSession {
|
|
28
29
|
id: string;
|
|
@@ -30,31 +31,53 @@ export interface TermSession {
|
|
|
30
31
|
pid: number | null;
|
|
31
32
|
/** bytes of output kept for a late joiner / a reconnect */
|
|
32
33
|
buffer: string;
|
|
33
|
-
child:
|
|
34
|
+
child: ChildProcess;
|
|
34
35
|
subscribers: Set<(chunk: string) => void>;
|
|
35
36
|
exited: { code: number | null; signal: string | null } | null;
|
|
37
|
+
/** true when the child has a real tty (see shellCommand). The panel says which it got, because
|
|
38
|
+
* a pipe shell and a tty look nothing alike and the operator should not have to guess. */
|
|
39
|
+
tty: boolean;
|
|
40
|
+
/** Resize the tty (no-op on a pipe shell). The kernel then SIGWINCHes the child's foreground
|
|
41
|
+
* process group, which is what makes a full-screen program redraw at the new width. */
|
|
42
|
+
resize: (cols: number, rows: number) => void;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
const MAX_BUFFER = 256 * 1024;
|
|
39
46
|
|
|
40
|
-
/**
|
|
41
|
-
function
|
|
47
|
+
/** The PTY helper that ships with the server. */
|
|
48
|
+
function ptyHelperPath(): string | null {
|
|
49
|
+
for (const rel of ["./pty-helper.py", "../src/pty-helper.py"]) {
|
|
50
|
+
try {
|
|
51
|
+
const p = fileURLToPath(new URL(rel, import.meta.url));
|
|
52
|
+
if (fs.existsSync(p)) return p;
|
|
53
|
+
} catch {
|
|
54
|
+
/* not this layout */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** How to start the shell.
|
|
61
|
+
*
|
|
62
|
+
* Default is a REAL pty via `pty-helper.py` (python3 stdlib `forkpty`): the shell gets a controlling
|
|
63
|
+
* terminal, so prompts, echo, colours, job control and full-screen programs behave. That is the
|
|
64
|
+
* whole point of the panel — a terminal emulator in front of a pipe shell is a lie (no prompt lands
|
|
65
|
+
* on screen, keystrokes are never echoed, resize means nothing).
|
|
66
|
+
*
|
|
67
|
+
* Escapes: `AGENTUS_TERM_CMD` runs an arbitrary command line instead (pipes), `AGENTUS_TERM_PTY=0`
|
|
68
|
+
* forces the old pipe shell, and a machine without python3 degrades to it automatically. Whoever
|
|
69
|
+
* gets a pipe shell is told so in the panel rather than being shown a fake terminal. */
|
|
70
|
+
function shellCommand(shell: string): { cmd: string; args: string[]; tty: boolean } {
|
|
42
71
|
const override = (process.env.AGENTUS_TERM_CMD || "").trim();
|
|
43
72
|
if (override) {
|
|
44
73
|
const parts = override.split(/\s+/);
|
|
45
|
-
return { cmd: parts[0], args: parts.slice(1) };
|
|
74
|
+
return { cmd: parts[0], args: parts.slice(1), tty: false };
|
|
46
75
|
}
|
|
47
|
-
if (process.env.AGENTUS_TERM_PTY
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// prints the interpreter error into the terminal), which is better than
|
|
51
|
-
// silently degrading to a pipe shell that claims to be a tty.
|
|
52
|
-
return {
|
|
53
|
-
cmd: "python3",
|
|
54
|
-
args: ["-u", "-c", `import pty,sys; pty.spawn(${JSON.stringify([shell, "-i"])})`],
|
|
55
|
-
};
|
|
76
|
+
if (process.env.AGENTUS_TERM_PTY !== "0") {
|
|
77
|
+
const helper = ptyHelperPath();
|
|
78
|
+
if (helper) return { cmd: "python3", args: [helper, shell, "-i"], tty: true };
|
|
56
79
|
}
|
|
57
|
-
return { cmd: shell, args: ["-i"] };
|
|
80
|
+
return { cmd: shell, args: ["-i"], tty: false };
|
|
58
81
|
}
|
|
59
82
|
|
|
60
83
|
function pickShell(): string {
|
|
@@ -69,9 +92,11 @@ function pickShell(): string {
|
|
|
69
92
|
export class TermService {
|
|
70
93
|
#sessions = new Map<string, TermSession>();
|
|
71
94
|
|
|
72
|
-
start(cwd: string): TermSession {
|
|
95
|
+
start(cwd: string, size?: { cols: number; rows: number }): TermSession {
|
|
73
96
|
const shell = pickShell();
|
|
74
|
-
const { cmd, args } = shellCommand(shell);
|
|
97
|
+
const { cmd, args, tty } = shellCommand(shell);
|
|
98
|
+
const cols = Math.max(20, Math.min(size?.cols ?? 80, 1000));
|
|
99
|
+
const rows = Math.max(5, Math.min(size?.rows ?? 24, 1000));
|
|
75
100
|
const child = spawn(cmd, args, {
|
|
76
101
|
cwd: fs.existsSync(cwd) ? cwd : homedir(),
|
|
77
102
|
env: {
|
|
@@ -80,13 +105,19 @@ export class TermService {
|
|
|
80
105
|
PYTHONUNBUFFERED: "1",
|
|
81
106
|
// Let the shell know a machine is driving, so prompts stay plain.
|
|
82
107
|
AGENTUS_TERMINAL: "1",
|
|
108
|
+
// The pty is sized at start because the shell reads its size when it draws the first
|
|
109
|
+
// prompt — resizing afterwards still works, but a 80x24 flash on every open is not free.
|
|
110
|
+
AGENTUS_PTY_COLS: String(cols),
|
|
111
|
+
AGENTUS_PTY_ROWS: String(rows),
|
|
83
112
|
},
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
//
|
|
113
|
+
// fd 3 is the PTY helper's control channel (resize). A pipe shell has no use for it.
|
|
114
|
+
stdio: tty ? ["pipe", "pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
|
|
115
|
+
// own process group: the shell (or the pty helper wrapping it) is killed as a group, never
|
|
116
|
+
// one process at a time (see kill()).
|
|
87
117
|
detached: true,
|
|
88
|
-
}) as
|
|
118
|
+
}) as ChildProcess;
|
|
89
119
|
|
|
120
|
+
const ctl = tty ? ((child.stdio[3] ?? null) as NodeJS.WritableStream | null) : null;
|
|
90
121
|
const session: TermSession = {
|
|
91
122
|
id: randomUUID(),
|
|
92
123
|
cwd,
|
|
@@ -95,6 +126,15 @@ export class TermService {
|
|
|
95
126
|
child,
|
|
96
127
|
subscribers: new Set(),
|
|
97
128
|
exited: null,
|
|
129
|
+
tty,
|
|
130
|
+
resize: (c: number, r: number): void => {
|
|
131
|
+
if (!ctl) return;
|
|
132
|
+
try {
|
|
133
|
+
ctl.write(`resize ${Math.max(1, Math.min(c, 1000))} ${Math.max(1, Math.min(r, 1000))}\n`);
|
|
134
|
+
} catch {
|
|
135
|
+
/* the helper is gone; the exit event says so */
|
|
136
|
+
}
|
|
137
|
+
},
|
|
98
138
|
};
|
|
99
139
|
const push = (chunk: string): void => {
|
|
100
140
|
session.buffer = (session.buffer + chunk).slice(-MAX_BUFFER);
|
|
@@ -106,17 +146,18 @@ export class TermService {
|
|
|
106
146
|
}
|
|
107
147
|
}
|
|
108
148
|
};
|
|
109
|
-
child.stdout
|
|
110
|
-
child.stderr
|
|
111
|
-
// stderr is part of the terminal picture (a missing command prints there)
|
|
112
|
-
|
|
113
|
-
child.
|
|
114
|
-
child.on("
|
|
149
|
+
child.stdout?.setEncoding("utf8");
|
|
150
|
+
child.stderr?.setEncoding("utf8");
|
|
151
|
+
// stderr is part of the terminal picture (a missing command prints there). With a pty both
|
|
152
|
+
// streams are already merged into the pty master, so only stdout carries them — harmless.
|
|
153
|
+
child.stdout?.on("data", (d: string) => push(d));
|
|
154
|
+
child.stderr?.on("data", (d: string) => push(d));
|
|
155
|
+
child.on("exit", (code: number | null, signal: string | null) => {
|
|
115
156
|
session.exited = { code, signal };
|
|
116
157
|
push(`\r\n[process exited${code != null ? ` with code ${code}` : ""}${signal ? ` (${signal})` : ""}]\r\n`);
|
|
117
158
|
this.#sessions.delete(session.id);
|
|
118
159
|
});
|
|
119
|
-
child.on("error", (err) => push(`\r\n[cannot start shell: ${err.message}]\r\n`));
|
|
160
|
+
child.on("error", (err: Error) => push(`\r\n[cannot start shell: ${err.message}]\r\n`));
|
|
120
161
|
this.#sessions.set(session.id, session);
|
|
121
162
|
return session;
|
|
122
163
|
}
|
|
@@ -22,6 +22,7 @@ export type SessionStatus =
|
|
|
22
22
|
| "starting" // subprocess spawned, initialize/newSession in flight
|
|
23
23
|
| "ready" // accepts prompts
|
|
24
24
|
| "running" // a prompt turn is in flight
|
|
25
|
+
| "reaped" // no process: it was idle past the operator's threshold (settings.agent.idleKillMin)
|
|
25
26
|
| "error" // subprocess died or handshake failed
|
|
26
27
|
| "closed";
|
|
27
28
|
|
|
@@ -74,6 +75,15 @@ export interface SessionInfo {
|
|
|
74
75
|
* This — not `createdAt`, and not `lastSeq` (a per-session counter) — is what the rail
|
|
75
76
|
* orders by: "which of these did I last talk to". */
|
|
76
77
|
lastAt?: number;
|
|
78
|
+
/** The operator PUT THIS SESSION AWAY: it belongs in the rail's 已归档 section, whatever its
|
|
79
|
+
* process is doing. This is a STORAGE state, not a liveness one — the rail used to infer
|
|
80
|
+
* `archived` from `has no process`, which filed a crashed slot and a deliberately archived
|
|
81
|
+
* one in the same place (the operator's complaint: 「我设想的点击归档是一种存储态」). */
|
|
82
|
+
archivedAt?: number | null;
|
|
83
|
+
/** No agent process right now — it was closed, it crashed, or it was REAPED for being idle.
|
|
84
|
+
* Derived server-side and sent with the row, so the client never infers liveness from which
|
|
85
|
+
* list a row arrived in (that inference is what merged 已归档 with 死了). */
|
|
86
|
+
cold?: boolean;
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
export interface AvailableCommandView {
|
|
@@ -148,7 +158,7 @@ export interface PermissionDiff {
|
|
|
148
158
|
// ---- WS envelope (server -> browser) ----
|
|
149
159
|
export type ServerEvent =
|
|
150
160
|
| { t: "hello"; clientId: string; resumed: boolean }
|
|
151
|
-
| { t: "sessions"; sessions: SessionInfo[]; /** requests already waiting for an answer, so a page that connects or refreshes learns about them (they are STATE; the events happened before it existed) */ pending?: PermissionRequestView[] }
|
|
161
|
+
| { t: "sessions"; sessions: SessionInfo[]; /** rows on disk with NO process and no archive flag — they belong in 工作空间 beside the live ones (a crashed slot must not look archived, and must not vanish from the rail either) */ cold?: SessionInfo[]; /** rows the operator explicitly archived (archivedAt set) — the 已归档 section, whatever their process state */ archived?: SessionInfo[]; /** requests already waiting for an answer, so a page that connects or refreshes learns about them (they are STATE; the events happened before it existed) */ pending?: PermissionRequestView[] }
|
|
152
162
|
| { t: "session"; session: SessionInfo }
|
|
153
163
|
| { t: "messages"; sessionId: string; messages: StoredMessage[]; hasMore: boolean; partial?: boolean }
|
|
154
164
|
| { t: "message"; message: StoredMessage; /** text to APPEND for a streamed block that grew (absent
|