agentus 0.3.9 → 0.4.1
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 +39 -3
- package/README.zh-CN.md +34 -2
- package/bin/agentus.mjs +27 -0
- package/bin/cli.mjs +339 -0
- package/docs/installation.md +7 -3
- package/docs/installation.zh-CN.md +5 -3
- 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
|
@@ -10,7 +10,7 @@ import fs from "node:fs";
|
|
|
10
10
|
import { WebSocketServer, WebSocket } from "ws";
|
|
11
11
|
import { Store } from "./store/store.js";
|
|
12
12
|
import { exportFilename, renderJson, renderMarkdown, type ExportSessionHeader } from "./store/export.js";
|
|
13
|
-
import { SessionManager, SessionUnavailable } from "./acp/session-manager.js";
|
|
13
|
+
import { IMAGE_UNSUPPORTED, SessionManager, SessionUnavailable } from "./acp/session-manager.js";
|
|
14
14
|
import { describeAcpError } from "./acp/errors.js";
|
|
15
15
|
import { BACKENDS } from "./acp/backends.js";
|
|
16
16
|
import { classifyError, checkedHealth, coerceRow, inspectRow, planFor, rowWarnings, seedRows, startupCheck } from "./acp/registry.js";
|
|
@@ -19,12 +19,27 @@ import { terms } from "./term.js";
|
|
|
19
19
|
import { VoiceError, listVoiceModels, setHotwordSource, synthesize, transcribe, voiceCapabilities } from "./voice.js";
|
|
20
20
|
import { hotwordsFor, vocabularyOf } from "./hotwords.js";
|
|
21
21
|
import { openDashscopeStream } from "./dashscope.js";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
agentSettings, getSettings, initSettings, observerSettings, planHintSettings, publicSettings,
|
|
24
|
+
saveAgent, saveCall, saveObserver, savePrefs, saveSettings, saveTheme,
|
|
25
|
+
} from "./settings.js";
|
|
26
|
+
import { ObserverHub } from "./observer/hub.js";
|
|
23
27
|
import * as auth from "./auth.js";
|
|
24
28
|
import { NotifyCenter } from "./notify/center.js";
|
|
25
|
-
import type { BackendId, ClientCommand, PermissionDecision, PromptAttachment, ServerEvent } from "../../shared/src/index.js";
|
|
29
|
+
import type { BackendId, ClientCommand, PermissionDecision, PromptAttachment, ServerEvent, SessionInfo } from "../../shared/src/index.js";
|
|
30
|
+
import {
|
|
31
|
+
COCKPIT_ERRORS, COCKPIT_SCHEMA_VERSION, MESSAGES_TAIL_DEFAULT, capabilities, listSessions, parseSince,
|
|
32
|
+
sendAllowed, sessionRow, tailMessages, type ListQuery,
|
|
33
|
+
} from "./cockpit/api.js";
|
|
26
34
|
|
|
27
35
|
const PORT = Number(process.env.AGENTUS_PORT ?? 8788);
|
|
36
|
+
|
|
37
|
+
// The INSTANCE's own name (issue #19). One person runs several cockpits — live, a pre-release box,
|
|
38
|
+
// the one at home — and every tab plus every home-screen icon said "Agentus", so the only way to
|
|
39
|
+
// tell them apart was the domain. The name is not a build input: the HTML is already served
|
|
40
|
+
// no-store and names the hashed bundle, so renaming it here costs nothing and needs no rebuild.
|
|
41
|
+
// Unset keeps "Agentus" byte for byte, so an install that never set it is unchanged.
|
|
42
|
+
const TITLE = (process.env.AGENTUS_TITLE ?? "").trim() || "Agentus";
|
|
28
43
|
// Data + web build resolve against THIS FILE, not the shell's cwd: `scripts/start.sh`
|
|
29
44
|
// launches from the repo root while the dev server runs from packages/server, and a
|
|
30
45
|
// cwd-relative path would silently open a second database (the operator's slots would
|
|
@@ -79,6 +94,7 @@ if (process.env.AGENTUS_PRINT_PATHS) {
|
|
|
79
94
|
console.log(`[agentus] web bundle ${WEB_DIST} (${has(path.join(WEB_DIST, "index.html"))})`);
|
|
80
95
|
console.log(`[agentus] companion apk ${APK_FILE} (${has(APK_FILE)})`);
|
|
81
96
|
console.log(`[agentus] http port ${PORT}`);
|
|
97
|
+
console.log(`[agentus] instance name ${TITLE}`);
|
|
82
98
|
process.exit(0);
|
|
83
99
|
}
|
|
84
100
|
|
|
@@ -140,6 +156,9 @@ function sessionRoot(sessionId: string): { path: string; workspace: string | nul
|
|
|
140
156
|
}
|
|
141
157
|
|
|
142
158
|
function emit(evt: ServerEvent): void {
|
|
159
|
+
// Older builds below get the plain fan-out. The hub reads the SAME frames the page reads, and may
|
|
160
|
+
// emit a `speak` of its own — which lands back here one level deep and is then broadcast normally.
|
|
161
|
+
if (evt.t !== "speak") hub?.onEvent(evt);
|
|
143
162
|
const wire = JSON.stringify(evt);
|
|
144
163
|
for (const { ws } of clients.values()) {
|
|
145
164
|
if (ws.readyState === WebSocket.OPEN) ws.send(wire);
|
|
@@ -152,6 +171,11 @@ function emit(evt: ServerEvent): void {
|
|
|
152
171
|
|
|
153
172
|
const mgr = new SessionManager(store, emit);
|
|
154
173
|
|
|
174
|
+
/** 工作播报 (issue #16). Constructed AFTER the manager so it can spawn observers through it, and
|
|
175
|
+
* wired into `emit` above through this mutable handle — the hub itself is not in the broadcast
|
|
176
|
+
* path, it only OBSERVES the same event stream the browser sees (one source of truth). */
|
|
177
|
+
let hub: ObserverHub | null = null;
|
|
178
|
+
|
|
155
179
|
/** The session list every client stays current with: the rows PLUS what is already waiting for
|
|
156
180
|
* an answer. Pending approvals are STATE, not just an event — a page that connects or refreshes
|
|
157
181
|
* after the request was raised still has to be able to show it.
|
|
@@ -169,6 +193,43 @@ const sessionsEvent = (): ServerEvent => ({
|
|
|
169
193
|
pending: mgr.pendingPermissions(),
|
|
170
194
|
});
|
|
171
195
|
|
|
196
|
+
// 工作播报 (issue #16): one long-lived observer per watched session, in its own scratch home under
|
|
197
|
+
// `~/.hermes/agentus/observers/<sid>` — never the observed session's cwd (an observer that can write
|
|
198
|
+
// into the project is an observer that changes what it is watching, and the rail groups by cwd: the
|
|
199
|
+
// ghost row next to the real one is issue #13 all over again).
|
|
200
|
+
hub = new ObserverHub({
|
|
201
|
+
home: path.join(homedir(), ".hermes", "agentus"),
|
|
202
|
+
settings: observerSettings,
|
|
203
|
+
// 工作播报 is the operator's third speaker position; in the other two the cockpit says nothing, so
|
|
204
|
+
// no observer is spawned and no tokens are spent.
|
|
205
|
+
speakEnabled: () => getSettings().prefs.speakMode === "brief",
|
|
206
|
+
createSession: async (backend, cwd, title) => (await mgr.create(backend, cwd, title, { system: true })).id,
|
|
207
|
+
prompt: (id, text) => mgr.prompt(id, text, []),
|
|
208
|
+
waitReady: (id, ms) => mgr.awaitReady(id, ms).then(() => undefined),
|
|
209
|
+
titleOf: (id) => store.getSession(id)?.title ?? null,
|
|
210
|
+
backendOf: (id) => store.getSession(id)?.backend ?? null,
|
|
211
|
+
planTitleOf: (id) => store.getPlan(id)?.title?.trim() || null,
|
|
212
|
+
// Read-only is a SECOND line of defence behind the scratch directory: a runtime that offers a
|
|
213
|
+
// read-only (or plan) mode should not be able to accept a write from an observer at all. Modes
|
|
214
|
+
// arrive with the handshake, so this is a bounded poll; if the backend offers nothing matching,
|
|
215
|
+
// the observer simply runs in its default mode — inside a directory that holds nothing.
|
|
216
|
+
preferReadonly: (id) => {
|
|
217
|
+
const started = Date.now();
|
|
218
|
+
const pick = (): void => {
|
|
219
|
+
const modes = mgr.modesOf(id);
|
|
220
|
+
const options = modes?.availableModes ?? [];
|
|
221
|
+
if (!options.length && Date.now() - started < 20_000) {
|
|
222
|
+
setTimeout(pick, 500).unref?.();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const wanted = options.find((m) => /read[-_ ]?only|readonly|plan/i.test(m.id) || /只读|规划/.test(m.name));
|
|
226
|
+
if (wanted) void mgr.setMode(id, wanted.id).catch(() => {});
|
|
227
|
+
};
|
|
228
|
+
pick();
|
|
229
|
+
},
|
|
230
|
+
emit,
|
|
231
|
+
});
|
|
232
|
+
|
|
172
233
|
// ---- notify channel (docs/android-notify-contract.md) ----------------------------
|
|
173
234
|
// Owns pairing, the device websocket, the activity lifecycle and the button callbacks.
|
|
174
235
|
// It holds no agent logic: what an event MEANS is decided in notify/center.ts §ACP mapping,
|
|
@@ -177,6 +238,9 @@ const notify = new NotifyCenter({
|
|
|
177
238
|
dataDir: DATA_DIR,
|
|
178
239
|
log: (line: string) => console.log(line),
|
|
179
240
|
sessionTitle: (id: string) => mgr.list().find((s) => s.id === id)?.title ?? store.getSession(id)?.title ?? null,
|
|
241
|
+
// The completion card's task line (#11). Read straight from the plan OBJECT — the same row the
|
|
242
|
+
// card renders — so the notification and the screen can never disagree about what finished.
|
|
243
|
+
sessionPlan: (id: string) => store.getPlan(id),
|
|
180
244
|
// Operator credential = whatever already unlocks the cockpit (session cookie or machine token).
|
|
181
245
|
operator: (req, url) => Boolean(auth.authenticate(req.headers, url)),
|
|
182
246
|
// The companion artifact, so the phone panel in 设置 can show its size without a second request.
|
|
@@ -339,10 +403,23 @@ function runPrompt(
|
|
|
339
403
|
const msg = describeAcpError(e);
|
|
340
404
|
const tail = mgr.stderrTail(sessionId);
|
|
341
405
|
console.error(`[agentus] prompt failed for ${sessionId}: ${msg}${tail ? `\n agent stderr tail:\n ${tail}` : ""}`);
|
|
406
|
+
// `rejectedPrompt` is the operator's own sentence, handed back with the refusal so the client can
|
|
407
|
+
// put it back in line instead of losing it with the red bubble (see state.ts, case "message").
|
|
408
|
+
// Only for the refusals that mean 「你的话我还没收到,不是它失败了」: a busy agent, and an agent that
|
|
409
|
+
// cannot take the picture the operator just attached (issue #17) — there the sentence is still
|
|
410
|
+
// worth keeping, it is the image that has to go.
|
|
411
|
+
const lost = msg === "turn already running" || msg === IMAGE_UNSUPPORTED;
|
|
342
412
|
emit({ t: "message", message: {
|
|
343
|
-
seq: -1, sessionId, kind: "meta",
|
|
413
|
+
seq: -1, sessionId, kind: "meta",
|
|
414
|
+
payload: { text: `turn failed: ${msg}`, ...(lost ? { rejectedPrompt: text } : {}) },
|
|
415
|
+
createdAt: Date.now(),
|
|
344
416
|
} });
|
|
345
|
-
|
|
417
|
+
// `turn-end` is the frame that says A TURN IS OVER. For the two "nothing left the client" refusals
|
|
418
|
+
// there was no turn to end: the busy case still has the operator's ORIGINAL turn running (its own
|
|
419
|
+
// turn-end will come), and a refused image never started one. Emitting one anyway flipped the page
|
|
420
|
+
// to idle mid-turn — the stop button vanished while the agent was still working (measured 2026-10-17
|
|
421
|
+
// in `attachment-capability`, on the busy branch).
|
|
422
|
+
if (!lost) emit({ t: "turn-end", sessionId, error: msg });
|
|
346
423
|
});
|
|
347
424
|
}
|
|
348
425
|
|
|
@@ -391,6 +468,33 @@ function send(res: ServerResponse, code: number, body: unknown): void {
|
|
|
391
468
|
res.end(JSON.stringify(body));
|
|
392
469
|
}
|
|
393
470
|
|
|
471
|
+
/** Escape the one instance name that reaches HTML — an operator's `AGENTUS_TITLE` is not markup. */
|
|
472
|
+
function htmlEscape(s: string): string {
|
|
473
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Put the instance name in the tab title. A body without a `<title>` is left alone rather than
|
|
477
|
+
* guessed at: a release that changes index.html will fail the sweep, not silently ship a title. */
|
|
478
|
+
function withTitle(html: string): string {
|
|
479
|
+
if (TITLE === "Agentus") return html; // never set → the built file is the answer, byte for byte
|
|
480
|
+
return html.replace(/<title>[^<]*<\/title>/i, `<title>${htmlEscape(TITLE)}</title>`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Put the instance name on the home screen. `name` is the install dialog and the launcher label in
|
|
484
|
+
* most launchers; `short_name` is what actually fits under the icon, and Android truncates it
|
|
485
|
+
* itself — inventing our own abbreviation produced names nobody chose. */
|
|
486
|
+
function withManifestName(text: string): string {
|
|
487
|
+
if (TITLE === "Agentus") return text;
|
|
488
|
+
try {
|
|
489
|
+
const j = JSON.parse(text) as Record<string, unknown>;
|
|
490
|
+
j.name = TITLE;
|
|
491
|
+
j.short_name = TITLE;
|
|
492
|
+
return JSON.stringify(j, null, 2);
|
|
493
|
+
} catch {
|
|
494
|
+
return text; // an unparseable manifest is not worth failing a page load over
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
394
498
|
function serveStatic(req: IncomingMessage, res: ServerResponse): void {
|
|
395
499
|
// SPA fallback for web build; only GET
|
|
396
500
|
if (!fs.existsSync(WEB_DIST)) {
|
|
@@ -408,6 +512,29 @@ function serveStatic(req: IncomingMessage, res: ServerResponse): void {
|
|
|
408
512
|
".svg": "image/svg+xml", ".png": "image/png", ".webmanifest": "application/manifest+json",
|
|
409
513
|
".json": "application/json", ".ico": "image/x-icon",
|
|
410
514
|
};
|
|
515
|
+
// The two bodies this server EDITS on the way out are the two that carry the instance's name
|
|
516
|
+
// (issue #19): the tab title lives in index.html, the home-screen label in the manifest. Every
|
|
517
|
+
// other file is streamed untouched — the hashed assets are immutable and must stay byte-exact.
|
|
518
|
+
if (ext === ".html" || ext === ".webmanifest") {
|
|
519
|
+
let body: string;
|
|
520
|
+
try {
|
|
521
|
+
body = fs.readFileSync(file, "utf8");
|
|
522
|
+
} catch {
|
|
523
|
+
res.writeHead(500); res.end("web build unreadable"); return;
|
|
524
|
+
}
|
|
525
|
+
if (ext === ".html") body = withTitle(body);
|
|
526
|
+
else body = withManifestName(body);
|
|
527
|
+
res.writeHead(200, {
|
|
528
|
+
"content-type": types[ext] ?? "text/plain",
|
|
529
|
+
// Same rule as below: the HTML names the hashed bundle, so it must never be cached. The
|
|
530
|
+
// manifest carries the instance name, which an operator changes by restarting with a new
|
|
531
|
+
// AGENTUS_TITLE — a cached copy would keep the old label on the home screen for a year.
|
|
532
|
+
"cache-control": "no-cache, must-revalidate",
|
|
533
|
+
"content-length": String(Buffer.byteLength(body)),
|
|
534
|
+
});
|
|
535
|
+
res.end(body);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
411
538
|
res.writeHead(200, {
|
|
412
539
|
"content-type": types[ext] ?? "application/octet-stream",
|
|
413
540
|
// The HTML must never be cached: it names the hashed bundle, so a cached index.html keeps serving
|
|
@@ -437,6 +564,162 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
|
|
437
564
|
// minted per agent process and delivered through the ACP handshake (§4) — plus a loopback-only
|
|
438
565
|
// rule, so the token never crosses a network. The blast radius of a leaked token is one session's
|
|
439
566
|
// plan, which is the object the operator is already looking at.
|
|
567
|
+
// The cockpit CLI's data plane (issue #15). AHEAD of the operator gate for the same reason the
|
|
568
|
+
// plan tool is: its caller is an AGENT CHILD process with no browser session, and handing it
|
|
569
|
+
// operator credentials would put them in every agent's environment. It carries a token minted per
|
|
570
|
+
// agent process (env, never argv) plus the same loopback rule, so the token cannot cross a network.
|
|
571
|
+
if (url.pathname.startsWith("/api/cockpit/")) {
|
|
572
|
+
const ip = clientIp(req);
|
|
573
|
+
if (!LOOPBACK_IPS.has(ip)) return send(res, 403, { error: "the cockpit channel is loopback-only" });
|
|
574
|
+
const caller = mgr.cockpitCaller(String(req.headers["x-agentus-cockpit-token"] ?? ""));
|
|
575
|
+
// Operator mode: the machine token in <DATA>/auth.token is already the credential that lets
|
|
576
|
+
// scripts act as the operator, so a human at a shell can use the same CLI. `caller` stays the
|
|
577
|
+
// empty string — there is no agent to attribute to, so `self` is null and a rename this way
|
|
578
|
+
// records no author (NULL title_by = the operator), which is exactly right.
|
|
579
|
+
const isOperator = !caller && auth.verifyMachineToken(/^Bearer\s+(.+)$/i.exec(String(req.headers.authorization ?? ""))?.[1] ?? undefined);
|
|
580
|
+
if (!caller && !isOperator) return send(res, 403, { error: COCKPIT_ERRORS.badToken });
|
|
581
|
+
// Scope: what the operator can see. 已归档 is out, on purpose — see `cockpitRows`.
|
|
582
|
+
const rows = mgr.cockpitRows();
|
|
583
|
+
const rowOf = (id: string): SessionInfo | null => rows.find((s) => s.id === id) ?? null;
|
|
584
|
+
const summarize = (s: SessionInfo) => sessionRow(s, {
|
|
585
|
+
plan: store.getPlan(s.id),
|
|
586
|
+
permissions: mgr.pendingFor(s.id).length,
|
|
587
|
+
});
|
|
588
|
+
const sendOk = sendAllowed(agentSettings().cockpitSend);
|
|
589
|
+
|
|
590
|
+
if (url.pathname === "/api/cockpit/capabilities" && req.method === "GET") {
|
|
591
|
+
return send(res, 200, capabilities(sendOk));
|
|
592
|
+
}
|
|
593
|
+
if (url.pathname === "/api/cockpit/context" && req.method === "GET") {
|
|
594
|
+
const sp = url.searchParams;
|
|
595
|
+
const self = caller ? rowOf(caller) : null; // operator mode: no session, so no `self`
|
|
596
|
+
// `--all` is the ONE way archived rows enter this channel, and it is opt-in per call: the
|
|
597
|
+
// product rule (an archive is a statement that the work is closed) still holds by default.
|
|
598
|
+
const pool = sp.get("all") === "1" || sp.get("all") === "true"
|
|
599
|
+
? [...rows, ...mgr.archived()]
|
|
600
|
+
: rows;
|
|
601
|
+
// `--here`: the CALLER's own workspace, resolved here because only the server knows the
|
|
602
|
+
// canonical path the rail groups by.
|
|
603
|
+
const here = sp.get("here") === "1" || sp.get("here") === "true";
|
|
604
|
+
const by = sp.get("by") ?? undefined;
|
|
605
|
+
if (by && by !== "workspace") {
|
|
606
|
+
return send(res, 400, { error: COCKPIT_ERRORS.badRequest, message: `--by wants workspace, got "${by}"` });
|
|
607
|
+
}
|
|
608
|
+
const query: ListQuery = {
|
|
609
|
+
workspace: here ? (self ? self.workspace || self.cwd : undefined) : sp.get("workspace") ?? undefined,
|
|
610
|
+
status: sp.get("status") ?? undefined,
|
|
611
|
+
waiting: sp.get("waiting") === "1" || sp.get("waiting") === "true",
|
|
612
|
+
since: sp.get("since") ?? undefined,
|
|
613
|
+
createdSince: sp.get("created_since") ?? undefined,
|
|
614
|
+
grep: sp.get("grep") ?? undefined,
|
|
615
|
+
sort: (sp.get("sort") as ListQuery["sort"] | null) ?? undefined,
|
|
616
|
+
limit: sp.get("limit") ? Number(sp.get("limit")) : undefined,
|
|
617
|
+
all: sp.get("all") === "1" || sp.get("all") === "true",
|
|
618
|
+
includeSelf: sp.get("include_self") === "1" || sp.get("include_self") === "true",
|
|
619
|
+
by: by as ListQuery["by"],
|
|
620
|
+
};
|
|
621
|
+
if (query.sort && !["last", "created", "name"].includes(query.sort)) {
|
|
622
|
+
return send(res, 400, { error: COCKPIT_ERRORS.badRequest, message: `--sort wants last|created|name, got "${query.sort}"` });
|
|
623
|
+
}
|
|
624
|
+
// A window that cannot be parsed is REFUSED. Silently ignoring it would answer a different
|
|
625
|
+
// question than the one asked, and the caller would believe the empty list.
|
|
626
|
+
for (const [field, raw] of [["since", query.since], ["created_since", query.createdSince]] as const) {
|
|
627
|
+
if (!raw) continue;
|
|
628
|
+
const parsed = parseSince(raw);
|
|
629
|
+
if (!parsed.ok) return send(res, 400, { error: COCKPIT_ERRORS.badRequest, message: parsed.why });
|
|
630
|
+
}
|
|
631
|
+
// Mark the caller's own row: it stays visible (you are part of the cockpit) but it is never a
|
|
632
|
+
// send target, and saying so in the data is cheaper than making every reader remember.
|
|
633
|
+
const marked = pool.map((s) => ({ ...summarize(s), ...(s.id === caller ? { self: true } : {}) }));
|
|
634
|
+
const listed = listSessions(marked, query);
|
|
635
|
+
return send(res, 200, {
|
|
636
|
+
schema_version: COCKPIT_SCHEMA_VERSION,
|
|
637
|
+
self: self ? summarize(self) : null,
|
|
638
|
+
sessions: listed.rows,
|
|
639
|
+
...(listed.groups ? { groups: listed.groups } : {}),
|
|
640
|
+
meta: {
|
|
641
|
+
...listed.meta,
|
|
642
|
+
scope: query.all ? "live+cold+archived (--all)" : "live+cold, archived excluded",
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
if (url.pathname === "/api/cockpit/rename" && req.method === "POST") {
|
|
647
|
+
const body = await readJson(req);
|
|
648
|
+
const id = String(body.id ?? "");
|
|
649
|
+
if (!id) return send(res, 400, { error: COCKPIT_ERRORS.badRequest });
|
|
650
|
+
if (!rowOf(id)) return send(res, 404, { error: COCKPIT_ERRORS.outOfScope });
|
|
651
|
+
// Recorded as THIS session's doing (titleBy), so the operator can tell an agent-named
|
|
652
|
+
// conversation from one they named. `auto` clears back to the generated title.
|
|
653
|
+
const title = body.auto === true ? null : String(body.title ?? "").trim();
|
|
654
|
+
if (body.auto !== true && !title) return send(res, 400, { error: COCKPIT_ERRORS.badRequest });
|
|
655
|
+
try {
|
|
656
|
+
const info = mgr.rename(id, title, caller);
|
|
657
|
+
return send(res, 200, {
|
|
658
|
+
schema_version: COCKPIT_SCHEMA_VERSION,
|
|
659
|
+
renamed: true,
|
|
660
|
+
id,
|
|
661
|
+
name: info.title,
|
|
662
|
+
titleBy: info.titleBy ?? null,
|
|
663
|
+
});
|
|
664
|
+
} catch (e) {
|
|
665
|
+
return send(res, 404, { error: COCKPIT_ERRORS.outOfScope, message: String((e as Error)?.message ?? e) });
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (url.pathname === "/api/cockpit/session" && req.method === "GET") {
|
|
669
|
+
const id = url.searchParams.get("id") ?? "";
|
|
670
|
+
const info = rowOf(id);
|
|
671
|
+
if (!info) return send(res, 404, { error: COCKPIT_ERRORS.noSuchSession });
|
|
672
|
+
const waiting = mgr.pendingFor(id).map((p) => ({
|
|
673
|
+
requestId: p.requestId, title: p.toolCallTitle ?? p.kind ?? "请求",
|
|
674
|
+
options: (p.options ?? []).map((o) => ({ id: o.optionId, name: o.name, kind: o.kind })),
|
|
675
|
+
}));
|
|
676
|
+
return send(res, 200, {
|
|
677
|
+
schema_version: COCKPIT_SCHEMA_VERSION,
|
|
678
|
+
session: summarize(info),
|
|
679
|
+
plan: store.getPlan(id),
|
|
680
|
+
pending: waiting,
|
|
681
|
+
messages: tailMessages(mgr.messages(id, -1, 500), 0).meta,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
if (url.pathname === "/api/cockpit/messages" && req.method === "GET") {
|
|
685
|
+
const id = url.searchParams.get("id") ?? "";
|
|
686
|
+
if (!rowOf(id)) return send(res, 404, { error: COCKPIT_ERRORS.noSuchSession });
|
|
687
|
+
const tail = Number(url.searchParams.get("tail") ?? MESSAGES_TAIL_DEFAULT);
|
|
688
|
+
const out = tailMessages(mgr.messages(id, -1, 2000), tail);
|
|
689
|
+
return send(res, 200, { schema_version: COCKPIT_SCHEMA_VERSION, messages: out.rows, meta: out.meta });
|
|
690
|
+
}
|
|
691
|
+
if (url.pathname === "/api/cockpit/plan" && req.method === "GET") {
|
|
692
|
+
const id = url.searchParams.get("id") ?? "";
|
|
693
|
+
if (!rowOf(id)) return send(res, 404, { error: COCKPIT_ERRORS.noSuchSession });
|
|
694
|
+
return send(res, 200, { schema_version: COCKPIT_SCHEMA_VERSION, plan: store.getPlan(id) });
|
|
695
|
+
}
|
|
696
|
+
if (url.pathname === "/api/cockpit/send" && req.method === "POST") {
|
|
697
|
+
const body = await readJson(req);
|
|
698
|
+
const to = String(body.to ?? "");
|
|
699
|
+
const message = String(body.message ?? "").trim();
|
|
700
|
+
if (!to || !message) return send(res, 400, { error: COCKPIT_ERRORS.badRequest });
|
|
701
|
+
if (!sendOk) return send(res, 403, { error: COCKPIT_ERRORS.sendsDisabled });
|
|
702
|
+
const target = rowOf(to);
|
|
703
|
+
if (!target) return send(res, 404, { error: COCKPIT_ERRORS.outOfScope });
|
|
704
|
+
if (target.cold) return send(res, 409, { error: COCKPIT_ERRORS.targetCold });
|
|
705
|
+
// Deliberately NOT `interrupt`: a cross-session message must never take the floor from a turn
|
|
706
|
+
// the OPERATOR started. A busy target answers `target_busy` and the caller reports it as such.
|
|
707
|
+
try {
|
|
708
|
+
// `origin` is not decoration: without it the target's page shows this as a message the
|
|
709
|
+
// OPERATOR typed. With it the bubble says which session it came from.
|
|
710
|
+
await mgr.prompt(to, message, [], {
|
|
711
|
+
interrupt: false,
|
|
712
|
+
origin: caller ? { sessionId: caller, title: rows.find((s) => s.id === caller)?.title ?? null } : undefined,
|
|
713
|
+
});
|
|
714
|
+
} catch (e) {
|
|
715
|
+
const why = String((e as Error)?.message ?? e);
|
|
716
|
+
return send(res, 409, { error: why.includes("turn already running") ? COCKPIT_ERRORS.targetBusy : why });
|
|
717
|
+
}
|
|
718
|
+
return send(res, 200, { delivered: true, to, code: "delivered" });
|
|
719
|
+
}
|
|
720
|
+
return send(res, 404, { error: "unknown cockpit command" });
|
|
721
|
+
}
|
|
722
|
+
|
|
440
723
|
if (url.pathname === "/api/plan/mcp" && req.method === "POST") {
|
|
441
724
|
const ip = clientIp(req);
|
|
442
725
|
if (!LOOPBACK_IPS.has(ip)) return send(res, 403, { error: "the plan tool is loopback-only" });
|
|
@@ -448,7 +731,9 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
|
|
448
731
|
return out.ok ? send(res, 200, { plan: out.plan }) : send(res, 403, { error: out.error });
|
|
449
732
|
}
|
|
450
733
|
if (body.op === "update") {
|
|
451
|
-
const out = mgr.writePlanFromMcp(sessionId, token, {
|
|
734
|
+
const out = mgr.writePlanFromMcp(sessionId, token, {
|
|
735
|
+
items: body.items, title: body.title, explanation: body.explanation,
|
|
736
|
+
});
|
|
452
737
|
if (!out.ok) return send(res, 403, { error: out.error });
|
|
453
738
|
return send(res, 200, { accepted: out.accepted, reason: out.reason, plan: out.plan });
|
|
454
739
|
}
|
|
@@ -892,7 +1177,8 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
892
1177
|
if (body.call && typeof body.call === "object") saveCall(body.call as Record<string, unknown>);
|
|
893
1178
|
if (body.prefs && typeof body.prefs === "object") savePrefs(body.prefs as Record<string, unknown>);
|
|
894
1179
|
if (body.agent && typeof body.agent === "object") saveAgent(body.agent as Record<string, unknown>);
|
|
895
|
-
if (
|
|
1180
|
+
if (body.observer && typeof body.observer === "object") saveObserver(body.observer as Record<string, unknown>);
|
|
1181
|
+
if (!body.voice && !body.theme && !body.call && !body.prefs && !body.agent && !body.observer) saveSettings(body);
|
|
896
1182
|
return send(res, 200, publicSettings());
|
|
897
1183
|
} catch (e) {
|
|
898
1184
|
return send(res, 400, { error: String((e as Error)?.message ?? e) });
|
|
@@ -15,7 +15,7 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
|
15
15
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
16
16
|
import type { Duplex } from "node:stream";
|
|
17
17
|
import { WebSocketServer, WebSocket } from "ws";
|
|
18
|
-
import type { PermissionRequestView, ServerEvent } from "../../../shared/src/index.js";
|
|
18
|
+
import type { PermissionRequestView, PlanSnapshot, ServerEvent } from "../../../shared/src/index.js";
|
|
19
19
|
import { DEFAULT_NOTIFY_RULES } from "./types.js";
|
|
20
20
|
import type {
|
|
21
21
|
ActionCallback,
|
|
@@ -41,6 +41,9 @@ export interface NotifyCenterOptions {
|
|
|
41
41
|
credentials?: (username: string, password: string, ip: string) => { ok: boolean; reason?: string };
|
|
42
42
|
/** Session title lookup, for activity subtitles. */
|
|
43
43
|
sessionTitle?: (sessionId: string) => string | null;
|
|
44
|
+
/** The session's plan OBJECT (`Store.getPlan`), for the one line that says WHAT finished. Read-only
|
|
45
|
+
* and optional: a center built without it produces exactly the pre-#11 cards. */
|
|
46
|
+
sessionPlan?: (sessionId: string) => PlanSnapshot | null;
|
|
44
47
|
/** The companion APK, so the web panel can report its size (optional). */
|
|
45
48
|
apkPath?: string;
|
|
46
49
|
/** Called when the cockpit reports WHICH session it is showing (the presence route) — the one
|
|
@@ -121,6 +124,12 @@ function nameAndDetail(name: string, ...detail: Array<string | undefined | null>
|
|
|
121
124
|
return [name, ...detail.filter((d): d is string => Boolean(d && d.trim()))].join(" · ");
|
|
122
125
|
}
|
|
123
126
|
|
|
127
|
+
/** Trim a fallback line to the body's budget. The `…` is the honest part: it says the sentence was
|
|
128
|
+
* longer, where a silent cut reads as the writer having written exactly that. */
|
|
129
|
+
function clip(text: string, max: number): string {
|
|
130
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
131
|
+
}
|
|
132
|
+
|
|
124
133
|
/** The agent's own tool title, split at its first ": ".
|
|
125
134
|
*
|
|
126
135
|
* The head goes on the state line (short — the Xiaomi island clips a title at 20 chars), and the
|
|
@@ -141,6 +150,7 @@ export class NotifyCenter {
|
|
|
141
150
|
private readonly operator: ((req: IncomingMessage, url: URL) => boolean) | null;
|
|
142
151
|
private readonly credentials: ((username: string, password: string, ip: string) => { ok: boolean; reason?: string }) | null;
|
|
143
152
|
private readonly sessionTitle: ((sessionId: string) => string | null) | null;
|
|
153
|
+
private readonly sessionPlan: ((sessionId: string) => PlanSnapshot | null) | null;
|
|
144
154
|
/** The APK the phone installs (optional): the web panel reports its size from here. */
|
|
145
155
|
private readonly apkPath: string | null = null;
|
|
146
156
|
/** "The operator is on this session right now" — the idle reaper's use-activity clock. */
|
|
@@ -265,6 +275,7 @@ export class NotifyCenter {
|
|
|
265
275
|
this.operator = opts.operator ?? null;
|
|
266
276
|
this.credentials = opts.credentials ?? null;
|
|
267
277
|
this.sessionTitle = opts.sessionTitle ?? null;
|
|
278
|
+
this.sessionPlan = opts.sessionPlan ?? null;
|
|
268
279
|
this.apkPath = opts.apkPath ?? null;
|
|
269
280
|
this.onFocus = opts.onFocus ?? null;
|
|
270
281
|
this.state = this.loadState();
|
|
@@ -386,6 +397,12 @@ export class NotifyCenter {
|
|
|
386
397
|
const name = String(body.deviceName ?? "device").slice(0, 60);
|
|
387
398
|
const prior = this.state.devices.find((d) => d.name === name);
|
|
388
399
|
if (prior) this.revoke(prior.deviceId);
|
|
400
|
+
// A re-pair is the SAME phone logging in again, not a new device: it inherits the old row's read
|
|
401
|
+
// position. Starting at 0 made `attach` replay the whole stored window as brand-new notifications
|
|
402
|
+
// — a re-pair (or the app's own 401 self-heal) put on a stack of 「已完成」 banners with a sound
|
|
403
|
+
// each (issue #12). A genuinely new phone has no prior row and still starts at 0, so a reinstall
|
|
404
|
+
// keeps getting the full STATE rebuild it needs.
|
|
405
|
+
const inheritCursor = prior?.cursor ?? 0;
|
|
389
406
|
const device: NotifyDevice = {
|
|
390
407
|
deviceId: `dev_${randomBytes(4).toString("hex")}`,
|
|
391
408
|
token: randomBytes(24).toString("hex"),
|
|
@@ -397,7 +414,7 @@ export class NotifyCenter {
|
|
|
397
414
|
capabilities: Array.isArray(caps) ? caps.map(String) : [],
|
|
398
415
|
createdAt: Date.now(),
|
|
399
416
|
lastSeenAt: null,
|
|
400
|
-
cursor:
|
|
417
|
+
cursor: inheritCursor,
|
|
401
418
|
};
|
|
402
419
|
this.state.devices.push(device);
|
|
403
420
|
this.save();
|
|
@@ -797,8 +814,11 @@ export class NotifyCenter {
|
|
|
797
814
|
// passes since=0 and must get everything, one that already saw seq N passes N.
|
|
798
815
|
const cursor = since >= 0 ? Math.max(since, row.cursor) : row.cursor;
|
|
799
816
|
this.send(ws, { t: "hello", deviceId: row.deviceId, serverTime: Date.now(), cursor, live: since < 0 });
|
|
817
|
+
// `replayed: true` marks catch-up (issue #12): the phone applies the same activity object — it
|
|
818
|
+
// has to, or a turn that is still running and an approval that is still open would never reach
|
|
819
|
+
// the screen — but it knows not to announce a FINISHED turn as news again.
|
|
800
820
|
for (const rec of this.replaySince(cursor)) {
|
|
801
|
-
this.send(ws, { t: "activity", seq: rec.seq, activity: this.degrade(rec.activity, row) });
|
|
821
|
+
this.send(ws, { t: "activity", seq: rec.seq, activity: this.degrade(rec.activity, row), replayed: true });
|
|
802
822
|
}
|
|
803
823
|
this.log(`[notify] ${row.name} connected (cursor ${cursor} -> replay ${this.replaySince(cursor).length})`);
|
|
804
824
|
|
|
@@ -1037,7 +1057,8 @@ export class NotifyCenter {
|
|
|
1037
1057
|
kind: failed ? "error" : "agent_done", priority: "high", ongoing: false, promotable: false,
|
|
1038
1058
|
title: failed ? "执行失败" : "已完成",
|
|
1039
1059
|
body: nameAndDetail(title(evt.sessionId),
|
|
1040
|
-
failed ? String(evt.error).slice(0, 140) :
|
|
1060
|
+
failed ? String(evt.error).slice(0, 140) : this.taskLine(evt.sessionId),
|
|
1061
|
+
failed ? null : durationText(evt.durationMs)),
|
|
1041
1062
|
channel: {
|
|
1042
1063
|
id: failed ? "agent_error" : "agent_done",
|
|
1043
1064
|
name: failed ? "任务出错" : "任务完成",
|
|
@@ -1075,6 +1096,36 @@ export class NotifyCenter {
|
|
|
1075
1096
|
}
|
|
1076
1097
|
}
|
|
1077
1098
|
|
|
1099
|
+
/** WHAT this turn was about, for the completion card's body (issue #11).
|
|
1100
|
+
*
|
|
1101
|
+
* The card reads `会话名 · <这句> · 用时 1m12s`. The session name is a PLACE, not a task — on a
|
|
1102
|
+
* phone 「已完成 · hello」 says nothing about what finished, and the operator's own complaint was
|
|
1103
|
+
* exactly that. The plan already knows, in three descending levels of quality, and every one of
|
|
1104
|
+
* them is a string a WRITER put there (nothing is invented or summarised here — the server holds
|
|
1105
|
+
* no intelligence):
|
|
1106
|
+
*
|
|
1107
|
+
* 1. `title` — the action phrase the writer set for this job (≤40 chars, its own budget);
|
|
1108
|
+
* 2. the first sentence of `explanation` — the writer's remark about scope or why it changed;
|
|
1109
|
+
* 3. the last step this plan shows as completed — the thing it just got done.
|
|
1110
|
+
*
|
|
1111
|
+
* No plan, or nothing usable in it → null, and the card degrades to the pre-#11 shape
|
|
1112
|
+
* (`会话名 · 用时`). A one-step turn has no task line to give, and a fabricated one would be worse
|
|
1113
|
+
* than the silence. */
|
|
1114
|
+
private taskLine(sessionId: string): string | null {
|
|
1115
|
+
const plan = this.sessionPlan?.(sessionId) ?? null;
|
|
1116
|
+
const title = plan?.title?.trim();
|
|
1117
|
+
if (title) return title;
|
|
1118
|
+
const remark = plan?.explanation?.trim();
|
|
1119
|
+
if (remark) {
|
|
1120
|
+
// first sentence only: a remark is prose, and the body line has one line to spend
|
|
1121
|
+
const first = remark.split(/[。.!?!?;;\n]/).map((s) => s.trim()).find(Boolean);
|
|
1122
|
+
if (first) return clip(first, 24);
|
|
1123
|
+
}
|
|
1124
|
+
const closed = [...(plan?.items ?? [])].reverse().find((i) => i.status === "completed");
|
|
1125
|
+
const content = closed?.content?.trim();
|
|
1126
|
+
return content ? clip(content, 24) : null;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1078
1129
|
/** The approval card, built but not published: the phone's activity and the page's notice are two
|
|
1079
1130
|
* renderings of ONE object, so the wording lives here and nowhere else. */
|
|
1080
1131
|
private approvalActivity(request: PermissionRequestView, sessionTitle: string, link: string): {
|
|
@@ -174,7 +174,12 @@ export interface ActionCallback {
|
|
|
174
174
|
/** Server -> device WS frames. */
|
|
175
175
|
export type NotifyFrame =
|
|
176
176
|
| { t: "hello"; deviceId: string; serverTime: number; cursor: number; live: boolean }
|
|
177
|
-
|
|
177
|
+
/** `replayed: true` = this frame is CATCH-UP sent on attach (the device's cursor was behind), not
|
|
178
|
+
* news that just happened. The two need different treatment on the far side: a reconnecting phone
|
|
179
|
+
* must rebuild STATE (a turn still running, an approval still waiting) but must not fire a banner
|
|
180
|
+
* and a sound for every turn that finished while it was away (issue #12). The flag is additive and
|
|
181
|
+
* optional — an older app simply ignores it and behaves as it does today. */
|
|
182
|
+
| { t: "activity"; seq: number; activity: ActivityObject; replayed?: boolean }
|
|
178
183
|
| { t: "bye"; reason: string };
|
|
179
184
|
|
|
180
185
|
/** Server-side record: what was published + how to interpret its actions. */
|