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.
@@ -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 ChildProcessWithoutNullStreams } from "node:child_process";
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: ChildProcessWithoutNullStreams;
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
- /** How to start the shell. Default: pipes (no native deps). PTY on request. */
41
- function shellCommand(shell: string): { cmd: string; args: string[] } {
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 === "1") {
48
- // python3's stdlib pty module is the one pty allocation available without a
49
- // native build step. Missing python3 fails loudly in the panel (the child
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
- stdio: ["pipe", "pipe", "pipe"],
85
- // own process group: `script` wraps the shell, so only a group kill reliably
86
- // takes both down (see kill()).
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 ChildProcessWithoutNullStreams;
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.setEncoding("utf8");
110
- child.stderr.setEncoding("utf8");
111
- // stderr is part of the terminal picture (a missing command prints there)
112
- child.stdout.on("data", (d: string) => push(d));
113
- child.stderr.on("data", (d: string) => push(d));
114
- child.on("exit", (code, signal) => {
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