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.
- package/README.md +35 -0
- package/README.zh-CN.md +31 -0
- package/bin/agentus.mjs +27 -0
- package/bin/cli.mjs +339 -0
- package/docs/installation.md +1 -0
- package/package.json +3 -1
- package/packages/server/mcp/plan-server.mjs +18 -3
- package/packages/server/mock/agent.mjs +19 -0
- package/packages/server/src/acp/backends.ts +25 -0
- package/packages/server/src/acp/registry.ts +6 -0
- package/packages/server/src/acp/session-manager.ts +269 -25
- package/packages/server/src/cockpit/api.ts +402 -0
- package/packages/server/src/index.ts +293 -7
- package/packages/server/src/notify/center.ts +55 -4
- package/packages/server/src/notify/types.ts +6 -1
- package/packages/server/src/observer/hub.ts +327 -0
- package/packages/server/src/plan/plan.ts +16 -0
- package/packages/server/src/settings.ts +166 -8
- package/packages/server/src/skills/install.ts +102 -0
- package/packages/server/src/store/store.ts +51 -13
- package/packages/shared/src/index.ts +34 -1
- package/packages/web/dist/assets/index-CRo7LVdP.css +32 -0
- package/packages/web/dist/assets/index-q4g0lPdX.js +123 -0
- package/packages/web/dist/index.html +2 -2
- package/skills/agentus-cockpit/SKILL.md +183 -0
- package/packages/web/dist/assets/index-CMRMZCT8.js +0 -122
- package/packages/web/dist/assets/index-LqLBh4zI.css +0 -32
|
@@ -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)
|
|
171
|
-
* regeneration is
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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: {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|