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.
@@ -14,6 +14,7 @@ import type {
14
14
  RequestPermissionResponse,
15
15
  } from "@agentclientprotocol/sdk";
16
16
  import { BACKENDS, buildSpawnEnv, nativePlanSourceOf, type BackendSpec } from "./backends.js";
17
+ import { SKILLS_ROOT, installSkills, resolveSkillsDir } from "../skills/install.js";
17
18
  import { classifyError, checkedHealth, effectiveArgs, handshakeFrom, rowToSpec } from "./registry.js";
18
19
  import type { BackendHandshake, CheckErrorCode } from "./registry.js";
19
20
  import {
@@ -35,10 +36,19 @@ import type {
35
36
  SessionModeState,
36
37
  TurnTrace,
37
38
  } from "../../../shared/src/index.js";
38
- import { acceptsToolWrite, acceptsWriteFrom, clampExplanation, demoteInProgress, foldPlanUpdate, hasUnfinished, normalizeItems, v2PlanEnabled } from "../plan/plan.js";
39
+ import { acceptsToolWrite, acceptsWriteFrom, clampExplanation, clampTitle, demoteInProgress, foldPlanUpdate, hasUnfinished, normalizeItems, v2PlanEnabled } from "../plan/plan.js";
39
40
  import { PLAN_GUIDANCE_ACTIVE, PLAN_GUIDANCE_INTRO, PLAN_GUIDANCE_NATIVE } from "../../../shared/src/index.js";
40
41
  import { PREFS_DEFAULT, type PlanHintSettings } from "../settings.js";
41
42
 
43
+ // The cockpit CLI an agent is handed (issue #15). Resolved from THIS module's location, so it points
44
+ // at the same tree the server runs from — a checkout and an installed package alike (`bin/` is in the
45
+ // package's `files`).
46
+ // The path we hand agents as `AGENTUS_COCKPIT_BIN` is the `agentus` launcher itself — there is no
47
+ // second command to install, and `agentus cockpit <command>` is the only spelling anywhere. (The
48
+ // implementation is `bin/cockpit.mjs`, but that file is the subcommand, not a product name: agents
49
+ // never see it, so it must not become a second thing users have to learn.)
50
+ const COCKPIT_BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../../bin/agentus.mjs");
51
+
42
52
  // Permission prompts must not hang a session forever (design.md §8-4).
43
53
  // Env-tunable so QA can exercise the timeout path in seconds instead of minutes.
44
54
  const PERMISSION_TIMEOUT_MS = Number(process.env.AGENTUS_PERM_TIMEOUT_MS || 5 * 60_000);
@@ -167,9 +177,10 @@ interface LiveSession {
167
177
  }>;
168
178
  alwaysAllow: Set<string>; // "allow_always" remembered per live session only (AionUi F-PERM-05)
169
179
  stderrBuf: string[];
170
- /** What the agent says it can do (initialize.agentCapabilities), kept because the title
171
- * regeneration is only possible when it advertises `session/fork`. */
172
- caps: { fork: boolean; load: boolean };
180
+ /** What the agent says it can do (initialize.agentCapabilities). `fork` gates title
181
+ * regeneration, `load` gates resume; `image` is the one a TURN can be refused on — see
182
+ * `IMAGE_UNSUPPORTED` in `prompt()`. */
183
+ caps: { fork: boolean; load: boolean; image: boolean };
173
184
  /** Incremented when a turn STARTS. An interrupt waits for the specific turn it cancelled,
174
185
  * so a second interrupting utterance that already started its own turn is not mistaken for
175
186
  * "the one I cancelled is still running". */
@@ -212,6 +223,11 @@ interface LiveSession {
212
223
  * what keeps the tool's writes scoped to this one session even if the spawn command is copied
213
224
  * out of `ps`. Lives and dies with the process that received it. */
214
225
  planToken: string;
226
+ /** Minted per agent process and handed to the CHILD's ENVIRONMENT (issue #15): the cockpit CLI
227
+ * (`$AGENTUS_COCKPIT_BIN`) reads it, so an agent can look at the rest of the cockpit without ever
228
+ * touching a port or a token itself. Scoped to this one session, like `planToken` — and dies with
229
+ * the process that received it, so a copied `ps` line is worth nothing later. */
230
+ cockpitToken: string;
215
231
  /** ACP v2 lets a session carry several plans (`planId`); the card renders one. Whichever planId
216
232
  * shows up first is adopted and the rest are left alone — see the `plan_update` case. */
217
233
  planId?: string;
@@ -252,6 +268,11 @@ export class SessionManager {
252
268
  // flag alone, so a restored-then-archived slot can be live). It is listed by `archived()`
253
269
  // instead — one row in one bucket, or the rail shows the same slot twice.
254
270
  .filter((s) => this.#store.getSession(s.info.id)?.archivedAt == null)
271
+ // A SYSTEM session (the 工作播报 observer, issue #16) is the cockpit's own machinery: it has a
272
+ // transcript and a process like any other slot, but it is not WORK IN FLIGHT. Filtering here —
273
+ // the one gate the rail, the cockpit CLI and the warm-slot ranking all read through — is what
274
+ // keeps an observer from showing up as a ghost row beside the session it watches.
275
+ .filter((s) => !s.info.system)
255
276
  .map((s) => ({
256
277
  ...s.info,
257
278
  lastSeq: this.#store.maxSeq(s.info.id),
@@ -263,6 +284,33 @@ export class SessionManager {
263
284
  .sort((a, b) => (b.lastAt ?? 0) - (a.lastAt ?? 0) || b.createdAt - a.createdAt);
264
285
  }
265
286
 
287
+ /** Wait until a slot can accept a prompt (its handshake finished), or explain why it never will.
288
+ * `create()` deliberately returns BEFORE the handshake, so a caller that spawns and prompts in the
289
+ * same breath (the 工作播报 observer, issue #16) has to wait for the one thing only the agent can
290
+ * tell us. Throws on a slot that died while we waited — silence would leave the observer looking
291
+ * alive forever. */
292
+ async awaitReady(id: string, timeoutMs = 30_000): Promise<SessionInfo> {
293
+ const deadline = Date.now() + timeoutMs;
294
+ for (;;) {
295
+ const s = this.#sessions.get(id);
296
+ if (!s) throw new Error(`no such session: ${id}`);
297
+ if (s.conn && s.info.acpSessionId) return s.info;
298
+ if (s.info.status === "error" || s.info.status === "closed") {
299
+ throw new Error(`session ${id} is ${s.info.status}${s.info.lastError ? `: ${s.info.lastError}` : ""}`);
300
+ }
301
+ if (Date.now() > deadline) {
302
+ throw new Error(`session ${id} did not become ready within ${Math.round(timeoutMs / 1000)}s`);
303
+ }
304
+ await new Promise((r) => setTimeout(r, 100));
305
+ }
306
+ }
307
+
308
+ /** The ACP mode state of a live slot, for callers that must not go through the rail's lists
309
+ * (issue #16's observer picks a read-only mode as soon as the handshake announces one). */
310
+ modesOf(id: string): SessionModeState | null {
311
+ return this.#sessions.get(id)?.info.modes ?? null;
312
+ }
313
+
266
314
  isBusy(id: string): boolean {
267
315
  return this.#sessions.get(id)?.busy ?? false;
268
316
  }
@@ -377,7 +425,10 @@ export class SessionManager {
377
425
  } catch { /* the row is evidence, not control flow */ }
378
426
  }
379
427
 
380
- async create(backend: BackendId, cwd: string, title?: string): Promise<SessionInfo> {
428
+ /** `opts.system` marks a session the COCKPIT is spawning for itself (issue #16's observer). It
429
+ * is a real ACP session and behaves like one everywhere below (close, idle-reap, resume), but it
430
+ * is born out of the rail's scope and stays out of it. */
431
+ async create(backend: BackendId, cwd: string, title?: string, opts: { system?: boolean } = {}): Promise<SessionInfo> {
381
432
  const shape = this.#spawnShape(backend);
382
433
  if (!shape) throw new Error(`unknown backend: ${backend}`);
383
434
  const { spec, args } = shape;
@@ -386,6 +437,13 @@ export class SessionManager {
386
437
 
387
438
  // isolation: never let a child inherit its way back into the live runtime home
388
439
  const plan = buildSpawnEnv(spec);
440
+ // before the handshake, because the agent resolves its skill roots when the session starts —
441
+ // not later
442
+ plan.env = this.#skillsEnv(spec, plan.env);
443
+ // the cockpit CLI's identity for THIS process (issue #15) — minted before the spawn because it
444
+ // travels in the child's own environment
445
+ const cockpitToken = randomUUID();
446
+ plan.env = this.#cockpitEnv(plan.env, id, cockpitToken);
389
447
  // measured once per spawn: how long the row took to reach `ready` (or to die trying)
390
448
  const spawnStarted = Date.now();
391
449
 
@@ -420,11 +478,13 @@ export class SessionManager {
420
478
  pid: child.pid ?? null,
421
479
  title: title || `${spec.label} @ ${shortCwd(cwd)}`,
422
480
  autoTitle: title || `${spec.label} @ ${shortCwd(cwd)}`,
481
+ ...(opts.system ? { system: true } : {}),
423
482
  createdAt: now, modes: null, configOptions: [], commands: [],
424
483
  },
425
484
  child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(), stderrBuf: [],
426
- caps: { fork: false, load: false }, turnSeq: 0, collectors: new Map(), planReminderPending: true,
485
+ caps: { fork: false, load: false, image: false }, turnSeq: 0, collectors: new Map(), planReminderPending: true,
427
486
  planToken: randomUUID(),
487
+ cockpitToken,
428
488
  // A brand-new process is, by definition, freshly used: the click that spawned it is the
429
489
  // activity. Without this the slot inherits the store's message clock — yesterday's — and the
430
490
  // reaper's first tick after the spawn kills the process the operator just asked for.
@@ -494,14 +554,7 @@ export class SessionManager {
494
554
  protocolVersion: 1,
495
555
  clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
496
556
  });
497
- const caps = (init?.agentCapabilities ?? {}) as {
498
- loadSession?: boolean;
499
- sessionCapabilities?: { fork?: unknown };
500
- };
501
- live.caps = {
502
- fork: Boolean(caps.sessionCapabilities?.fork),
503
- load: Boolean(caps.loadSession),
504
- };
557
+ live.caps = readCaps(init?.agentCapabilities);
505
558
  const res = await conn.newSession({ cwd, mcpServers: this.#planMcpServers(spec, live) });
506
559
  live.info.acpSessionId = res.sessionId;
507
560
  live.info.status = "ready";
@@ -556,6 +609,13 @@ export class SessionManager {
556
609
  // point of the workspace being a separate field (see shared/index.ts).
557
610
  const resumeCwd = row.workspace || row.cwd;
558
611
  const plan = buildSpawnEnv(spec);
612
+ // A resumed slot is a fresh agent process and re-resolves its skill roots, so a release that
613
+ // changed the skill lands here too — not only on first create.
614
+ plan.env = this.#skillsEnv(spec, plan.env);
615
+ // a resumed slot is a NEW process: its cockpit identity is minted here, like create()'s. The old
616
+ // child's token died with the old child.
617
+ const cockpitToken = randomUUID();
618
+ plan.env = this.#cockpitEnv(plan.env, id, cockpitToken);
559
619
  // ── Can this slot come back AT ALL? ──────────────────────────────────────────────────────
560
620
  // A session's agent-side state lives in the home it was CREATED under; a resume hands the
561
621
  // agent whatever home the row names TODAY. If those differ, the agent is being asked about a
@@ -594,6 +654,9 @@ export class SessionManager {
594
654
  workspace: row.workspace ?? null,
595
655
  contextLimit: row.contextLimit ?? null,
596
656
  status: "starting", pid: child.pid ?? null, title: row.title,
657
+ // a resumed system session is still a system session (issue #16): the observer is reaped and
658
+ // resumed like any slot, and must come back out of the rail's scope, not into it
659
+ ...(row.system ? { system: true } : {}),
597
660
  createdAt: row.createdAt,
598
661
  modes: (row.modes ?? null) as SessionModeState | null,
599
662
  configOptions: (row.configOptions ?? []) as ConfigOptionView[],
@@ -603,8 +666,9 @@ export class SessionManager {
603
666
  commands: normCommands(row.commands),
604
667
  },
605
668
  child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(), stderrBuf: [],
606
- caps: { fork: false, load: false }, turnSeq: 0, collectors: new Map(), planReminderPending: true,
669
+ caps: { fork: false, load: false, image: false }, turnSeq: 0, collectors: new Map(), planReminderPending: true,
607
670
  planToken: randomUUID(),
671
+ cockpitToken,
608
672
  // A brand-new process is, by definition, freshly used: the click that spawned it is the
609
673
  // activity. Without this the slot inherits the store's message clock — yesterday's — and the
610
674
  // reaper's first tick after the spawn kills the process the operator just asked for.
@@ -644,6 +708,7 @@ export class SessionManager {
644
708
  protocolVersion: 1,
645
709
  clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
646
710
  });
711
+ live.caps = readCaps(reloadInit?.agentCapabilities);
647
712
  let loaded = (await conn.loadSession({
648
713
  sessionId: row.acpSessionId, cwd: row.cwd, mcpServers: this.#planMcpServers(spec, live),
649
714
  })) as { modes?: SessionModeState | null; configOptions?: ConfigOptionView[]; sessionId?: string } | undefined;
@@ -813,7 +878,7 @@ export class SessionManager {
813
878
  const lastAtAll = this.#store.lastActivityAt();
814
879
  return this.#store
815
880
  .listSessions(true)
816
- .filter((r) => r.archivedAt != null)
881
+ .filter((r) => r.archivedAt != null && !r.system)
817
882
  .map((r) => {
818
883
  const live = this.#sessions.get(r.id);
819
884
  // An archived row can still have a process — resume does not touch the flag — and then it is
@@ -842,7 +907,8 @@ export class SessionManager {
842
907
  cold(limit = 20): SessionInfo[] {
843
908
  return this.#store
844
909
  .listSessions(true)
845
- .filter((r) => r.archivedAt == null && !this.#sessions.has(r.id))
910
+ // system rows are the cockpit's own (see list()): never offered as a slot to wake
911
+ .filter((r) => r.archivedAt == null && !r.system && !this.#sessions.has(r.id))
846
912
  .map((r) => this.#infoFromRow(r))
847
913
  // cold rows by the same rule as live ones (recent chat first), THEN take the page
848
914
  .sort((a, b) => (b.lastAt ?? 0) - (a.lastAt ?? 0) || b.createdAt - a.createdAt)
@@ -856,7 +922,8 @@ export class SessionManager {
856
922
  workspace: r.workspace ?? null,
857
923
  models: (r.models ?? null) as SessionInfo["models"],
858
924
  contextLimit: r.contextLimit ?? null,
859
- title: r.title, autoTitle: r.autoTitle ?? null, status: r.status, pid: null, createdAt: r.createdAt,
925
+ title: r.title, autoTitle: r.autoTitle ?? null, titleBy: r.titleBy ?? null, status: r.status, pid: null,
926
+ createdAt: r.createdAt,
860
927
  modes: (r.modes ?? null) as SessionModeState | null,
861
928
  configOptions: (r.configOptions ?? []) as ConfigOptionView[],
862
929
  usage: (r.usage ?? null) as SessionInfo["usage"],
@@ -864,6 +931,7 @@ export class SessionManager {
864
931
  lastSeq: this.#store.maxSeq(r.id),
865
932
  lastAt: this.#store.lastActivityAt().get(r.id) ?? r.createdAt,
866
933
  archivedAt: r.archivedAt ?? null,
934
+ ...(r.system ? { system: true } : {}),
867
935
  // no process by construction: this path only ever runs for a row that is not in the map
868
936
  cold: true,
869
937
  };
@@ -873,10 +941,27 @@ export class SessionManager {
873
941
  sessionId: string,
874
942
  text: string,
875
943
  attachments: PromptAttachment[] = [],
876
- opts: { interrupt?: boolean } = {},
944
+ opts: {
945
+ interrupt?: boolean;
946
+ /**
947
+ * Who SAID this, when it was not the operator at the keyboard — today only the cockpit's
948
+ * cross-session `send`. It rides the stored payload so the page can label the bubble instead of
949
+ * presenting another agent's words as the operator's own (which is what it did before: a user
950
+ * message, no attribution, indistinguishable from something they typed).
951
+ */
952
+ origin?: { sessionId: string; title?: string | null };
953
+ } = {},
877
954
  ): Promise<void> {
878
955
  const s = this.#need(sessionId);
879
956
  if (!s.conn || !s.info.acpSessionId) throw new Error("session not ready");
957
+ // An image the agent cannot take is refused HERE, above the floor-taking logic (issue #17):
958
+ // · below it, a busy agent answers "turn already running" and the operator never learns that the
959
+ // picture is the problem — they retry, the turn ends, and it fails the same way for a new reason;
960
+ // · through it, one of two silent failures: the agent errors on a content block, or drops it and
961
+ // answers as if nothing was attached.
962
+ // Neither is something the cockpit could explain afterwards, so it is said now, and the running
963
+ // turn is left alone (a refused prompt must never cancel one).
964
+ if (attachments.some((a) => a.kind === "image") && !s.caps.image) throw new Error(IMAGE_UNSUPPORTED);
880
965
  if (s.busy) {
881
966
  // Someone is talking over the agent. Plain prompts are refused (the operator's send
882
967
  // button is a stop button), but a call utterance carries `interrupt: true`: give the
@@ -933,7 +1018,14 @@ export class SessionManager {
933
1018
  const turnStartedAt = Date.now();
934
1019
  this.#emit({ t: "turn-start", sessionId, trace: this.#turnTrace(s) });
935
1020
  const msg = this.#store.appendMessage({
936
- sessionId, kind: "user", payload: { text, attachments: summarize(attachments) }, createdAt: Date.now(),
1021
+ sessionId,
1022
+ kind: "user",
1023
+ payload: {
1024
+ text,
1025
+ attachments: summarize(attachments),
1026
+ ...(opts.origin ? { origin: { sessionId: opts.origin.sessionId, title: opts.origin.title ?? null } } : {}),
1027
+ },
1028
+ createdAt: Date.now(),
937
1029
  });
938
1030
  this.#touch(s, msg.createdAt);
939
1031
  this.#emit({ t: "message", message: msg });
@@ -1103,6 +1195,68 @@ export class SessionManager {
1103
1195
  * is driving the session and vanishes for an agent started from the CLI, cron or Studio — which
1104
1196
  * is the whole difference from a `config.yaml` entry like the `ekko_studio_*` ones (§4).
1105
1197
  * The token in `env` is minted for THIS session, so a copied-out command writes only its own. */
1198
+ /** The cockpit CLI's identity, in the CHILD's environment (issue #15).
1199
+ *
1200
+ * Handed over the same way the plan token is — per process, per session, nowhere else — so the
1201
+ * agent never handles a port or a secret: it runs `$AGENTUS_COCKPIT_BIN context` and the CLI reads
1202
+ * these three. Rendering it into a prompt, an `echo`, or a `ps` line is the one thing the CLI's own
1203
+ * `capabilities` forbids, and the token is worth nothing once this process is gone. */
1204
+ #cockpitEnv(env: NodeJS.ProcessEnv, sessionId: string, token: string): NodeJS.ProcessEnv {
1205
+ return {
1206
+ ...env,
1207
+ AGENTUS_COCKPIT_ENDPOINT: `http://127.0.0.1:${process.env.AGENTUS_PORT ?? 8788}`,
1208
+ AGENTUS_COCKPIT_TOKEN: token,
1209
+ AGENTUS_COCKPIT_SESSION: sessionId,
1210
+ AGENTUS_COCKPIT_BIN: COCKPIT_BIN,
1211
+ };
1212
+ }
1213
+
1214
+ /** Hand this package's skills to the agent for THIS process only, when its backend has a channel.
1215
+ *
1216
+ * `skillsEnv` (Hermes: `HERMES_EXTERNAL_SKILLS_DIRS`, the per-process half of `skills.external_dirs`)
1217
+ * is a root the agent reads and we never write: `skill_view` resolves our corpus, and not one byte
1218
+ * lands in the operator's home — the same `~/.hermes` that Studio, cron and every terminal `hermes`
1219
+ * share, where a copied skill would surface in sessions that have nothing to do with the cockpit.
1220
+ * A backend without such a variable falls through to `#installSkills`, the only channel it has.
1221
+ */
1222
+ #skillsEnv(spec: BackendSpec, env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
1223
+ if (!spec.skillsEnv) return env;
1224
+ return { ...env, [spec.skillsEnv]: SKILLS_ROOT };
1225
+ }
1226
+
1227
+ /**
1228
+ * FALLBACK (vendors with no extra-roots channel): copy this package's skills into the agent's own
1229
+ * skills directory. Every backend we ship today takes `skillsEnv` instead, so this only runs for a
1230
+ * row whose spec names `skillsDir` without one.
1231
+ *
1232
+ * Failures are WARNINGS, never session failures: a slot whose skill could not be written still runs
1233
+ * — it just learns about the CLI from the one-line reminder and `agentus cockpit skill` instead of
1234
+ * native discovery. Turning a read-only-to-us directory (a home owned by root, a read-only mount)
1235
+ * into a dead slot would be a much worse trade than a missing manual.
1236
+ */
1237
+ #installSkills(spec: BackendSpec, home: string | null): void {
1238
+ if (!spec.skillsDir || !home) return;
1239
+ try {
1240
+ const r = installSkills(resolveSkillsDir(spec.skillsDir, home));
1241
+ if (r.installed.length || r.updated.length) {
1242
+ console.log(
1243
+ `[agentus] skills → ${r.dir}: ` +
1244
+ [...r.installed.map((n) => `${n} (new)`), ...r.updated.map((n) => `${n} (updated)`)].join(", "),
1245
+ );
1246
+ }
1247
+ } catch (err) {
1248
+ console.warn(`[agentus] could not install skills into ${spec.skillsDir} (${home}): ${errMessage(err)}`);
1249
+ }
1250
+ }
1251
+
1252
+ /** Resolve a cockpit-channel token to the session it was minted for (issue #15). One token, one
1253
+ * session: a copied env var reads that session's scope and nothing wider. */
1254
+ cockpitCaller(token: string): string | null {
1255
+ if (!token) return null;
1256
+ for (const s of this.#sessions.values()) if (s.cockpitToken === token) return s.info.id;
1257
+ return null;
1258
+ }
1259
+
1106
1260
  #planMcpServers(spec: BackendSpec, live: LiveSession): McpServer[] {
1107
1261
  if (nativePlanSourceOf(spec) !== "none") return [];
1108
1262
  return [
@@ -1219,7 +1373,7 @@ export class SessionManager {
1219
1373
  * truth (it does not own this list) or the model will keep believing it does. On an MCP-driven row
1220
1374
  * the tool is the session's ONLY writer, so it takes the card over even when an older build had
1221
1375
  * written it from frames (`acceptsToolWrite`). */
1222
- writePlanFromMcp(sessionId: string, token: string, input: { items: unknown; explanation?: unknown }):
1376
+ writePlanFromMcp(sessionId: string, token: string, input: { items: unknown; title?: unknown; explanation?: unknown }):
1223
1377
  | { ok: true; accepted: boolean; reason?: string; plan: PlanSnapshot | null }
1224
1378
  | { ok: false; error: string } {
1225
1379
  const live = this.#sessions.get(sessionId);
@@ -1243,6 +1397,10 @@ export class SessionManager {
1243
1397
  }
1244
1398
  const plan = this.#store.upsertPlan(sessionId, {
1245
1399
  items,
1400
+ // `undefined` = keep whatever title is on the card (same rule as the remark): most writes are
1401
+ // a status flip and carry no title, and those must not erase the phrase the completion card
1402
+ // reads. `null`/empty clears it (clampTitle answers null).
1403
+ ...(input.title !== undefined ? { title: clampTitle(input.title) } : {}),
1246
1404
  ...(input.explanation !== undefined ? { explanation: clampExplanation(input.explanation) } : {}),
1247
1405
  source: "mcp",
1248
1406
  // A write means work is happening again: the previous turn's terminal stamp no longer
@@ -1312,6 +1470,14 @@ export class SessionManager {
1312
1470
  return null;
1313
1471
  }
1314
1472
 
1473
+ // The cockpit channel (issue #15) rides the same block: ONE line, because its whole job is to say
1474
+ // that a readable panel exists. The contract itself lives on the other end (`capabilities` is
1475
+ // self-describing), so nothing here needs to be kept in sync with the CLI.
1476
+ body.push(
1477
+ "",
1478
+ "本驾驶舱可用 `$AGENTUS_COCKPIT_BIN session list`(其它会话状态/消息/计划;先 capabilities,全文 skill)。",
1479
+ );
1480
+
1315
1481
  return {
1316
1482
  type: "resource",
1317
1483
  resource: {
@@ -1604,6 +1770,16 @@ export class SessionManager {
1604
1770
  if (live.replaying && (isTextFrame || isToolFrame) && this.#store.maxSeq(live.info.id) > 0) {
1605
1771
  return;
1606
1772
  }
1773
+ // A status line from the agent (issue #18). Hermes sends these on ACP as an EMPTY thought chunk
1774
+ // whose `_meta.hermes` carries the sentence — the protocol has no status object, and `_meta` is the
1775
+ // extension door it does leave open. Honoured here rather than in the web, because the decision that
1776
+ // matters is 「do not persist」: a recall count belongs to the turn that is running, and writing it
1777
+ // into the transcript would put 「recalled 8 memories」 above a conversation from last month.
1778
+ const statusText = statusLineOf(u);
1779
+ if (statusText) {
1780
+ this.#emit({ t: "status", sessionId: live.info.id, text: statusText, statusKind: statusKindOf(u) });
1781
+ return;
1782
+ }
1607
1783
  let msg: Parameters<Store["appendMessage"]>[0] | null = null;
1608
1784
  switch (st) {
1609
1785
  case "agent_message_chunk":
@@ -1802,7 +1978,10 @@ export class SessionManager {
1802
1978
  // The remembered window follows the model, so it has to be refreshed whenever the
1803
1979
  // model (or anything else) changes — this is the one place every mutation passes.
1804
1980
  s.info.modelContextLimit = this.#store.getModelLimit(s.info.models?.currentModelId ?? null);
1805
- this.#emit({ t: "session", session: s.info });
1981
+ // A system session never appears in a `session` frame: the rail would add it to a bucket that
1982
+ // list()/cold() deliberately do not contain, and #13 is precisely the bug where the two
1983
+ // disagree. The page learns about observers through `speak` lines, not through the rail.
1984
+ if (!s.info.system) this.#emit({ t: "session", session: s.info });
1806
1985
  const row = this.#store.getSession(s.info.id);
1807
1986
  if (row) {
1808
1987
  // Persist the LIVE metadata too, not just status/pid: without modes/configOptions/
@@ -1833,6 +2012,22 @@ export class SessionManager {
1833
2012
  return this.#store.messagesAfter(sessionId, afterSeq, limit);
1834
2013
  }
1835
2014
 
2015
+ /** The cockpit CLI's read view (issue #15): the sessions the OPERATOR can see, in one list —
2016
+ * live and cold, never 已归档 (that is the operator's shelf, not work in flight).
2017
+ *
2018
+ * Cold rows are capped by `cold()`'s own page size on purpose: an agent asking "what is going on"
2019
+ * gets the recent cockpit, not a scrollback of every session this install ever made. */
2020
+ cockpitRows(): SessionInfo[] {
2021
+ return [...this.list(), ...this.cold(50)];
2022
+ }
2023
+
2024
+ /** Approvals a session is WAITING on right now — the one fact worth waking another agent for. */
2025
+ pendingFor(sessionId: string): PermissionRequestView[] {
2026
+ // The map holds `{view, resolve, timer}`; only the VIEW is safe to hand out (the other two are
2027
+ // the waiter itself).
2028
+ return [...(this.#sessions.get(sessionId)?.pendingPermissions.values() ?? [])].map((p) => p.view);
2029
+ }
2030
+
1836
2031
  /** Re-point a slot's workspace. Live slot: panels + the *next* resume move; the
1837
2032
  * running child keeps the cwd it was spawned with (ACP fixes cwd at newSession).
1838
2033
  * Cold slot: the resume will come up in the new directory. */
@@ -1861,14 +2056,18 @@ export class SessionManager {
1861
2056
  * renamable without waking it up (the agent never hears about this). `null`/blank
1862
2057
  * clears back to the generated title; control characters fold to spaces and the
1863
2058
  * result is capped, because this string is rendered in a single-line row. */
1864
- rename(id: string, title: string | null): SessionInfo {
2059
+ rename(id: string, title: string | null, by: string | null = null): SessionInfo {
1865
2060
  const clean = title == null ? null : title.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim().slice(0, 120).trim();
1866
- if (!this.#store.renameSession(id, clean && clean.length ? clean : null)) {
2061
+ // A cleared name has no author: after `--auto` the session carries the GENERATED title again, so
2062
+ // keeping "an agent named this" would describe a name that no longer exists.
2063
+ const attributed = clean && clean.length && by ? by : null;
2064
+ if (!this.#store.renameSession(id, clean && clean.length ? clean : null, attributed)) {
1867
2065
  throw new Error(`no such session: ${id}`);
1868
2066
  }
1869
2067
  const live = this.#sessions.get(id);
1870
2068
  if (live) {
1871
2069
  live.info.title = this.#store.getSession(id)?.title ?? live.info.title;
2070
+ live.info.titleBy = attributed;
1872
2071
  this.#updateSession(live);
1873
2072
  this.#emit({ t: "session", session: live.info });
1874
2073
  return live.info;
@@ -1880,7 +2079,7 @@ export class SessionManager {
1880
2079
  workspace: row.workspace ?? null,
1881
2080
  models: (row.models ?? null) as SessionInfo["models"],
1882
2081
  contextLimit: row.contextLimit ?? null,
1883
- title: row.title, status: row.status, pid: null, createdAt: row.createdAt,
2082
+ title: row.title, titleBy: row.titleBy ?? null, status: row.status, pid: null, createdAt: row.createdAt,
1884
2083
  modes: (row.modes ?? null) as SessionModeState | null,
1885
2084
  configOptions: (row.configOptions ?? []) as ConfigOptionView[],
1886
2085
  usage: (row.usage ?? null) as SessionInfo["usage"],
@@ -2167,6 +2366,50 @@ export class SessionManager {
2167
2366
  /** ACP ContentBlock[] for a prompt: text first (the instruction), then media.
2168
2367
  * Text attachments are inlined with a filename header because that is what every
2169
2368
  * agent actually reads well; images go as protocol image blocks. */
2369
+ /** What a turn must say when the agent cannot take a picture (issue #17).
2370
+ *
2371
+ * Kept as a module constant because TWO places have to agree on it: `prompt()` throws it, and the
2372
+ * server turns it into "your sentence comes back" just like `turn already running`. A second
2373
+ * literal would drift the moment one side is reworded. */
2374
+ export const IMAGE_UNSUPPORTED =
2375
+ "这个后端不支持图片输入(agent 没在 initialize 里声明 promptCapabilities.image)—— 去掉图片再发,或换一个能看图的模型";
2376
+
2377
+ /** Read the three capabilities this cockpit acts on. Defensive on purpose: `promptCapabilities`
2378
+ * is absent on agents that predate it, and an absent field means "no", not "try anyway" — a
2379
+ * silently dropped image is the failure this whole read exists to prevent. */
2380
+ function readCaps(capabilities: unknown): { fork: boolean; load: boolean; image: boolean } {
2381
+ const c = (capabilities ?? {}) as {
2382
+ loadSession?: boolean;
2383
+ sessionCapabilities?: { fork?: unknown };
2384
+ promptCapabilities?: { image?: unknown };
2385
+ };
2386
+ return {
2387
+ fork: Boolean(c.sessionCapabilities?.fork),
2388
+ load: Boolean(c.loadSession),
2389
+ image: c.promptCapabilities?.image === true,
2390
+ };
2391
+ }
2392
+
2393
+ /** The sentence inside a status carrier, or null when the frame is an ordinary thought chunk.
2394
+ *
2395
+ * Shape (hermes `acp_adapter/events.py:make_status_cb`): `agent_thought_chunk` with an empty content
2396
+ * block and `_meta.hermes = { kind: "status", statusKind, text }`. Read defensively — `_meta` is the
2397
+ * one part of an ACP frame a client may see from agents we have never run.
2398
+ */
2399
+ function statusLineOf(u: Record<string, unknown>): string | null {
2400
+ const meta = u?._meta as { hermes?: { kind?: unknown; text?: unknown } } | undefined;
2401
+ const h = meta?.hermes;
2402
+ if (!h || h.kind !== "status") return null;
2403
+ const text = typeof h.text === "string" ? h.text.trim() : "";
2404
+ return text || null;
2405
+ }
2406
+
2407
+ function statusKindOf(u: Record<string, unknown>): string | undefined {
2408
+ const meta = u?._meta as { hermes?: { statusKind?: unknown } } | undefined;
2409
+ const kind = meta?.hermes?.statusKind;
2410
+ return typeof kind === "string" && kind ? kind : undefined;
2411
+ }
2412
+
2170
2413
  function buildPromptBlocks(text: string, attachments: PromptAttachment[]): unknown[] {
2171
2414
  const blocks: unknown[] = [];
2172
2415
  const body = text.trim();
@@ -2202,6 +2445,7 @@ function sessionRow(i: SessionInfo, home: string | null = null) {
2202
2445
  modes: i.modes ?? null, configOptions: i.configOptions ?? [],
2203
2446
  usage: i.usage ?? null, commands: i.commands ?? [],
2204
2447
  models: i.models ?? null,
2448
+ system: Boolean(i.system),
2205
2449
  // only ever written by the insert (creation); see Store.upsertSession
2206
2450
  home,
2207
2451
  };