agentus 0.3.9 → 0.4.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.
@@ -0,0 +1,402 @@
1
+ /**
2
+ * The cockpit CLI's data plane, as pure functions.
3
+ *
4
+ * WHAT THIS IS FOR
5
+ * An agent inside the cockpit is blind: the whole instrument panel (what the other sessions are
6
+ * doing, what they were told, which one is waiting for an approval) lives in this server's store and
7
+ * is visible only to the operator. This module is the READ side of the one channel that lets an agent
8
+ * see it — plus the shape of the controlled `send` — kept as pure functions so the CLI contract can
9
+ * be unit-tested without a server or a socket.
10
+ *
11
+ * WHAT IT DELIBERATELY IS NOT
12
+ * Intelligence. It selects, truncates and labels; it never summarises, never decides what an agent
13
+ * "should" know, and never rewrites a message. The server's red line (the UI holds no intelligence)
14
+ * applies to the agent-facing data plane for the same reason: a machine that paraphrases the cockpit
15
+ * is a machine that can be wrong about the cockpit.
16
+ *
17
+ * The CLI is `bin/agentus-cockpit.mjs`; the routes are in `index.ts` (`/api/cockpit/*`). Both are
18
+ * thin: every decision about WHAT may be seen lives here, so there is one answer, not three.
19
+ */
20
+ import type { PlanSnapshot, SessionInfo, StoredMessage } from "../../../shared/src/index.js";
21
+
22
+ /** Version of the shapes this file produces. Bumped when a field changes MEANING; additive fields
23
+ * do not bump it (a reader that does not know a field must not break — same rule the ACP plan v2
24
+ * adoption used). */
25
+ export const COCKPIT_SCHEMA_VERSION = 1;
26
+
27
+ /** How many messages `messages --tail` may return at most. The tail is for "what is it doing NOW",
28
+ * not for mirroring a transcript into another agent's context — an agent that pastes 400 messages
29
+ * into its own prompt has spent the operator's tokens on history nobody asked for. */
30
+ export const MESSAGES_TAIL_MAX = 40;
31
+ export const MESSAGES_TAIL_DEFAULT = 10;
32
+
33
+ /** How many characters of ONE message are returned. Long replies (a full file dump, a 2000-line
34
+ * stack trace) are exactly what a reader wants a glimpse of, not a copy of. */
35
+ export const MESSAGE_CHARS = 600;
36
+
37
+ /** Room name of the send gate in the operator's settings (`agent.cockpitSend`). Named in one place
38
+ * because the CLI text, the settings page and the server gate all have to agree. */
39
+ export const SEND_SETTING = "agent.cockpitSend";
40
+
41
+ export type CockpitSessionRow = {
42
+ id: string;
43
+ name: string;
44
+ backend: string;
45
+ status: string;
46
+ /** the directory the agent process was spawned in */
47
+ cwd: string;
48
+ /** the WORKSPACE the rail groups by (`workspace || cwd` on the session) — what an operator means
49
+ * when they say 「这个项目里的会话」. Equal to `cwd` unless the operator re-pointed the panels. */
50
+ workspace: string;
51
+ /** when the session was created — the other end of the clock from `lastAt` */
52
+ createdAt: number;
53
+ /** who named it: absent/null = the operator, a session id = that session's agent renamed it */
54
+ titleBy?: string | null;
55
+ /** this row is the CALLER — reported in the list, but never a send target */
56
+ self?: boolean;
57
+ /** no agent process: the message would have to wake it first */
58
+ cold: boolean;
59
+ /** on the operator's 已归档 shelf — OUT of scope for the cockpit channel */
60
+ archived: boolean;
61
+ /** last talked to (wall clock) */
62
+ lastAt: number;
63
+ /** plan progress as the card shows it (0/0 = no plan) */
64
+ plan: { done: number; total: number; title: string | null };
65
+ /** approvals this session is WAITING on right now — the one thing worth waking someone for */
66
+ permissions: number;
67
+ /** the operator is looking at this session (it is the active tab) */
68
+ focused?: boolean;
69
+ };
70
+
71
+ export type CockpitMessageRow = {
72
+ seq: number;
73
+ kind: string;
74
+ at: number;
75
+ /** TRUNCATED: see `truncation` in `capabilities`. A cut message ends with `…`. */
76
+ text: string;
77
+ /** the text was longer than the budget */
78
+ truncated: boolean;
79
+ };
80
+
81
+ /** What `cockpit capabilities` prints. Self-description is the reason the CLI can stay thin: an
82
+ * agent that reads this once knows every command, every field and every truncation rule without a
83
+ * doc that drifts. */
84
+ export function capabilities(sendEnabled: boolean): Record<string, unknown> {
85
+ return {
86
+ schema_version: COCKPIT_SCHEMA_VERSION,
87
+ bin: "agentus <noun> <verb> — sessions are the noun; capabilities/skill sit at the top level",
88
+ commands: [
89
+ { name: "capabilities", args: [], returns: "this object — the contract, without needing a doc" },
90
+ { name: "skill", args: ["--path"], returns: "the CLI's own skill document (skills/agentus-cockpit/SKILL.md), served from the package with no auth" },
91
+ {
92
+ name: "session list",
93
+ args: ["--here", "--workspace <path>", "--status <s>", "--waiting", "--since <when>", "--created-since <when>", "--grep <text>", "--sort last|created|name", "--limit N", "--all", "--include-self", "--by workspace", "--table"],
94
+ returns: "who you are + the sessions in scope that match (id/name/workspace/status/cold/createdAt/lastAt/plan/permissions/self/titleBy); --by workspace adds `groups` to data, --table prints a human table INSTEAD of JSON",
95
+ },
96
+ { name: "session rename", args: ["<id>", "<title…>", "--auto"], returns: "name a session (--auto clears back to the generated name)" },
97
+ { name: "session show", args: ["<id>"], returns: "one session in detail, including any approval it is waiting on" },
98
+ { name: "session messages", args: ["<id>", "--tail N"], returns: `the last N messages (default ${MESSAGES_TAIL_DEFAULT}, max ${MESSAGES_TAIL_MAX}), each cut to ${MESSAGE_CHARS} chars` },
99
+ { name: "session plan", args: ["<id>"], returns: "the plan object the operator's card renders (title/items/explanation/terminal)" },
100
+ { name: "session send", args: ["<id>", "<text…>"], returns: "deliver a message into another session (text after the id, or on stdin)" },
101
+ ],
102
+ truncation: {
103
+ messages: `each message's text is cut to ${MESSAGE_CHARS} characters; a cut one ends with … and reports truncated:true`,
104
+ tail: `at most ${MESSAGES_TAIL_MAX} messages per call`,
105
+ plan_items: "not truncated — a plan is short by construction (it is a step list)",
106
+ },
107
+ auth: {
108
+ how: "environment only: AGENTUS_COCKPIT_ENDPOINT / AGENTUS_COCKPIT_TOKEN / AGENTUS_COCKPIT_SESSION are set by the cockpit when it spawns you; AGENTUS_COCKPIT_BIN is the agentus launcher itself, so every call is \"$AGENTUS_COCKPIT_BIN\" session <verb>",
109
+ operator: "outside a session the CLI also works for the operator: it reads <DATA_DIR>/auth.token (the machine token scripts already use) and talks to AGENTUS_ENDPOINT or 127.0.0.1:AGENTUS_PORT. In that mode there is no `self`, and a rename records no author (NULL title_by = the operator).",
110
+ rule: "never print, echo, export or pass those variables anywhere — they identify your session",
111
+ },
112
+ session_list: {
113
+ default: `live first, then most recently active, ${LIST_LIMIT_DEFAULT} rows (max ${LIST_LIMIT_MAX}); 已归档 excluded; your OWN row excluded unless you pass --include-self (it then appears marked self:true, and is never a send target)`,
114
+ clocks: "lastAt = when someone last SAID something; createdAt = when the slot was made. --since filters ACTIVITY, --created-since filters CREATION, --sort only orders. Both stamps are on every row.",
115
+ here: "--here is usually what you want: it narrows to YOUR workspace (the project you are working in), which is the same grouping the operator's rail uses",
116
+ waiting: "--waiting is the operator's queue: sessions with an approval outstanding. Do NOT use --since 5m for this — a session waiting on a human has no new messages, so its lastAt goes stale.",
117
+ filter_refusals: "an unparsable --since is an error (bad_request), never a silent 'everything'",
118
+ },
119
+ scope: {
120
+ read: "every session the operator can see: live and cold, never 已归档",
121
+ send: sendEnabled
122
+ ? "ENABLED: you may deliver a message into another session in scope"
123
+ : "DISABLED: the operator has not turned 驾驶舱互发消息 on; send answers code:sends_disabled",
124
+ send_semantics: "delivered means the target agent RECEIVED the message and started a turn — never that it read it or agreed. Nothing is claimed about a reply.",
125
+ },
126
+ discipline: [
127
+ "read before you assert: call `context` instead of trusting your memory of another session",
128
+ "queued/busy is not read: a target that is mid-turn answers code:target_busy and saw nothing",
129
+ "rate_limited means STOP and tell the operator — two agents can otherwise spin forever",
130
+ "a rename is a claim about what a session IS, and it is attributed: the row records titleBy, so the operator can see an agent named their conversation",
131
+ ],
132
+ };
133
+ }
134
+
135
+ /** One row of the scope list. `self` is reported separately so a caller cannot mistake its own row
136
+ * for one it may talk to. */
137
+ export function sessionRow(
138
+ info: SessionInfo,
139
+ opts: { plan?: PlanSnapshot | null; permissions?: number; focused?: boolean; self?: boolean } = {},
140
+ ): CockpitSessionRow {
141
+ const items = opts.plan?.items ?? [];
142
+ return {
143
+ id: info.id,
144
+ name: info.title,
145
+ backend: info.backend,
146
+ status: info.status,
147
+ cwd: info.cwd,
148
+ workspace: info.workspace || info.cwd,
149
+ createdAt: info.createdAt,...(info.titleBy ? { titleBy: info.titleBy } : {}),
150
+ cold: Boolean(info.cold),
151
+ archived: Boolean(info.archivedAt),
152
+ lastAt: info.lastAt ?? info.createdAt,
153
+ plan: {
154
+ done: items.filter((i) => i.status === "completed").length,
155
+ total: items.length,
156
+ title: opts.plan?.title?.trim() || null,
157
+ },
158
+ permissions: opts.permissions ?? 0,
159
+ ...(opts.focused ? { focused: true } : {}),
160
+ ...(opts.self ? { self: true } : {}),
161
+ };
162
+ }
163
+
164
+ /** How many rows `session list` returns unless told otherwise, and the hard ceiling. The point of a
165
+ * DEFAULT here is the same as the tail's: an agent that lists the whole cockpit burns the operator's
166
+ * context on a workplace it is not working in. */
167
+ export const LIST_LIMIT_DEFAULT = 20;
168
+ export const LIST_LIMIT_MAX = 200;
169
+
170
+ /** What `session list` may be asked to do. Every field is optional; the empty query is the default
171
+ * view (live first, then most recently active, 20 rows, archived excluded, self included but marked).
172
+ *
173
+ * ORDER OF THE THREE CLOCKS, because they are easy to confuse and this is the only place that
174
+ * decides: `lastAt` = when someone last SAID something (activity), `createdAt` = when the slot was
175
+ * made. `--since` filters on activity, `--created-since` on creation; `--sort` only orders. */
176
+ export interface ListQuery {
177
+ /** exact workspace path (already resolved by the caller — see `here` in the route) */
178
+ workspace?: string;
179
+ /** a status word, or the pseudo-status `cold` */
180
+ status?: string;
181
+ /** only sessions with an approval waiting — the operator's real to-do queue */
182
+ waiting?: boolean;
183
+ /** `30m` / `2h` / `1d` / `2w` / `2026-10-17` / `2026-10-17T14:30` — measured against ACTIVITY */
184
+ since?: string;
185
+ /** same grammar, measured against CREATION */
186
+ createdSince?: string;
187
+ /** case-insensitive substring of the TITLE (never the transcript — see the skill) */
188
+ grep?: string;
189
+ sort?: "last" | "created" | "name";
190
+ limit?: number;
191
+ /** include 已归档 rows. They are out of scope by default, and that is a product rule, not a default */
192
+ all?: boolean;
193
+ /** include the caller's own row (marked `self`) — off shows only sessions it could talk to */
194
+ includeSelf?: boolean;
195
+ /** bucket the page instead of returning it flat. `workspace` is the only dimension today because it
196
+ * is the one the operator's rail groups by — anything else would be an invented taxonomy. */
197
+ by?: "workspace";
198
+ /** fixed clock for tests; never set by a caller over the wire */
199
+ now?: number;
200
+ }
201
+
202
+ export interface ListResult {
203
+ rows: CockpitSessionRow[];
204
+ /** present only when `by` was asked for: the SAME rows, bucketed, in the same order. A caller that
205
+ * does not want buckets keeps reading `rows`; nothing is duplicated in the JSON. */
206
+ groups?: { key: string; label: string; sessions: CockpitSessionRow[] }[];
207
+ meta: {
208
+ total: number;
209
+ returned: number;
210
+ capped: boolean;
211
+ /** the filters that were actually applied, echoed back so a caller cannot misread an empty list
212
+ * as "there is nothing" when what happened is "your filter matched nothing" */
213
+ applied: Record<string, string | number | boolean>;
214
+ };
215
+ }
216
+
217
+ const SINCE_UNITS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
218
+
219
+ /** Parse a `--since` value.
220
+ *
221
+ * Relative windows out of `s/m/h/d/w`, or an ISO date (`2026-10-17` = local midnight that day) or an
222
+ * ISO date-time without a zone (`2026-10-17T14:30`, LOCAL — the operator's clock is the one they
223
+ * mean; a Z or offset, if sent, is honoured by `Date.parse`).
224
+ *
225
+ * Deliberately NOT accepted: `yesterday`, `last week`, `1M` (month vs minute is a trap), and empty
226
+ * strings. A wrong window silently returning everything would be worse than a refusal. */
227
+ export function parseSince(raw: string, now = Date.now()): { ok: true; at: number } | { ok: false; why: string } {
228
+ const v = raw.trim();
229
+ const rel = /^(\d+)([smhdw])$/.exec(v);
230
+ if (rel) {
231
+ const at = now - Number(rel[1]) * SINCE_UNITS[rel[2]];
232
+ return { ok: true, at };
233
+ }
234
+ if (/^\d{4}-\d{2}-\d{2}$/.test(v)) {
235
+ const at = new Date(`${v}T00:00:00`).getTime();
236
+ return Number.isFinite(at) ? { ok: true, at } : { ok: false, why: `not a date: ${raw}` };
237
+ }
238
+ if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?/.test(v)) {
239
+ const at = new Date(v.replace(" ", "T")).getTime();
240
+ return Number.isFinite(at) ? { ok: true, at } : { ok: false, why: `not a date-time: ${raw}` };
241
+ }
242
+ return { ok: false, why: `--since wants 30m/2h/1d/2w or 2026-10-17[T14:30], got "${raw}"` };
243
+ }
244
+
245
+ /** Filter + order + cap the scope list. Pure, so the CLI contract can be tested without a server.
246
+ *
247
+ * Reads nothing it was not given: the caller decides WHICH rows exist (the cockpit's own scope rule
248
+ * lives in `cockpitRows`), this decides which of those answer the question that was asked. */
249
+ export function listSessions(rows: CockpitSessionRow[], q: ListQuery = {}): ListResult {
250
+ const now = q.now ?? Date.now();
251
+ const applied: Record<string, string | number | boolean> = {};
252
+ let out = rows;
253
+
254
+ if (q.since) {
255
+ const p = parseSince(q.since, now);
256
+ if (p.ok) {
257
+ applied.since = new Date(p.at).toISOString();
258
+ out = out.filter((r) => r.lastAt >= p.at);
259
+ }
260
+ // a bad value is reported by the ROUTE as bad_request; the pure function must not pretend it filtered
261
+ }
262
+ if (q.createdSince) {
263
+ const p = parseSince(q.createdSince, now);
264
+ if (p.ok) {
265
+ applied.created_since = new Date(p.at).toISOString();
266
+ out = out.filter((r) => r.createdAt >= p.at);
267
+ }
268
+ }
269
+ if (q.workspace) {
270
+ const want = normPath(q.workspace);
271
+ applied.workspace = q.workspace;
272
+ out = out.filter((r) => normPath(r.workspace) === want);
273
+ }
274
+ if (q.status) {
275
+ applied.status = q.status;
276
+ out = q.status === "cold" ? out.filter((r) => r.cold) : out.filter((r) => r.status === q.status);
277
+ }
278
+ if (q.waiting) {
279
+ applied.waiting = true;
280
+ out = out.filter((r) => r.permissions > 0);
281
+ }
282
+ if (q.grep) {
283
+ const needle = q.grep.toLowerCase();
284
+ applied.grep = q.grep;
285
+ out = out.filter((r) => r.name.toLowerCase().includes(needle));
286
+ }
287
+ if (!q.all) {
288
+ applied.archived = false;
289
+ out = out.filter((r) => !r.archived);
290
+ }
291
+ if (!q.includeSelf) {
292
+ applied.self = false;
293
+ out = out.filter((r) => !r.self);
294
+ }
295
+
296
+ const sort = q.sort ?? "last";
297
+ applied.sort = sort;
298
+ const byName = new Intl.Collator("zh").compare;
299
+ out = [...out].sort((a, b) => {
300
+ // live before cold, always: a session with no process cannot be talked to, and burying it is how
301
+ // an agent ends up "sending" to something the cockpit would only answer target_cold about
302
+ if (a.cold !== b.cold) return a.cold ? 1 : -1;
303
+ if (sort === "name") return byName(a.name, b.name);
304
+ if (sort === "created") return b.createdAt - a.createdAt;
305
+ return b.lastAt - a.lastAt || b.createdAt - a.createdAt;
306
+ });
307
+
308
+ const limit = Math.max(1, Math.min(LIST_LIMIT_MAX, Math.round(q.limit ?? LIST_LIMIT_DEFAULT)));
309
+ applied.limit = limit;
310
+ const total = out.length;
311
+ const capped = total > limit;
312
+ const page = out.slice(0, limit);
313
+
314
+ let groups: ListResult["groups"];
315
+ if (q.by === "workspace") {
316
+ applied.by = "workspace";
317
+ // Buckets keep the order the rows already have, so the first group is the one that was active
318
+ // most recently — the rail's own reading. Empty/unset workspace is its own bucket rather than
319
+ // being folded into a neighbour: "somewhere I cannot name" is information.
320
+ const byKey = new Map<string, CockpitSessionRow[]>();
321
+ for (const row of page) {
322
+ const key = normPath(row.workspace || "");
323
+ const bucket = byKey.get(key);
324
+ if (bucket) bucket.push(row);
325
+ else byKey.set(key, [row]);
326
+ }
327
+ groups = [...byKey.entries()].map(([key, sessions]) => ({
328
+ key: key || "(no workspace)",
329
+ label: key ? (key.split("/").pop() || key) : "(no workspace)",
330
+ sessions,
331
+ }));
332
+ }
333
+
334
+ return { rows: page, ...(groups ? { groups } : {}), meta: { total, returned: Math.min(total, limit), capped, applied } };
335
+ }
336
+
337
+ function normPath(p: string): string {
338
+ return p.replace(/\/+$/, "") || "/";
339
+ }
340
+
341
+ /** A transcript row in the CLI's vocabulary: the payload is normalised to TEXT and cut, because an
342
+ * agent's reader is a prompt, not a renderer. Unknown payload shapes are stringified rather than
343
+ * dropped — "something happened here that I cannot read" is better than a silent gap. */
344
+ export function messageRow(m: StoredMessage): CockpitMessageRow {
345
+ const raw = textOf(m);
346
+ const cut = raw.length > MESSAGE_CHARS;
347
+ return {
348
+ seq: m.seq,
349
+ kind: m.kind,
350
+ at: m.createdAt,
351
+ text: cut ? `${raw.slice(0, MESSAGE_CHARS - 1)}…` : raw,
352
+ truncated: cut,
353
+ };
354
+ }
355
+
356
+ function textOf(m: StoredMessage): string {
357
+ const p = (m.payload ?? null) as
358
+ | { content?: { text?: unknown }; text?: unknown; entries?: { content?: unknown }[]; _meta?: unknown }
359
+ | null;
360
+ if (!p || typeof p !== "object") return "";
361
+ const direct = p.content?.text;
362
+ if (typeof direct === "string") return direct;
363
+ if (typeof p.text === "string") return p.text;
364
+ // plan frames and tool rows: the text a reader wants is the steps / the title
365
+ if (Array.isArray(p.entries)) return p.entries.map((e) => `- ${String(e.content ?? "")}`).join("\n");
366
+ return JSON.stringify(p).slice(0, MESSAGE_CHARS * 2);
367
+ }
368
+
369
+ /** The tail of a transcript. `tail` is clamped rather than refused: a caller asking for 500 gets the
370
+ * documented maximum, and `meta.truncated` says so — a silent short answer reads as "there was
371
+ * less". */
372
+ export function tailMessages(rows: readonly StoredMessage[], tail: number): { rows: CockpitMessageRow[]; meta: Record<string, unknown> } {
373
+ const want = Number.isFinite(tail) ? Math.max(1, Math.floor(tail)) : MESSAGES_TAIL_DEFAULT;
374
+ const capped = Math.min(MESSAGES_TAIL_MAX, want);
375
+ const slice = rows.slice(-capped);
376
+ return {
377
+ rows: slice.map(messageRow),
378
+ meta: { asked: want, returned: slice.length, available: rows.length, capped: want > capped },
379
+ };
380
+ }
381
+
382
+ /** The `send` gate, in ONE expression so the route and the CLI cannot disagree about it.
383
+ *
384
+ * `read` is on by default (the operator can see everything; an agent reading it cannot damage
385
+ * anything, and it is what a 工作播报 observer lives on). `send` is OFF until the operator turns it
386
+ * on: an agent that can write into another agent's turn without being asked is a loop generator,
387
+ * and the operator is the one who pays for the tokens. */
388
+ export function sendAllowed(setting: unknown): boolean {
389
+ return setting === true;
390
+ }
391
+
392
+ /** Refusal codes the CLI turns into stderr lines. Stable strings, because a model reads them. */
393
+ export const COCKPIT_ERRORS = {
394
+ badToken: "bad_token",
395
+ noSuchSession: "no_such_session",
396
+ outOfScope: "out_of_scope",
397
+ sendsDisabled: "sends_disabled",
398
+ targetBusy: "target_busy",
399
+ targetCold: "target_cold",
400
+ rateLimited: "rate_limited",
401
+ badRequest: "bad_request",
402
+ } as const;