agentus 0.3.3 → 0.3.5
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 +18 -50
- package/README.zh-CN.md +12 -42
- package/bin/agentus.mjs +4 -4
- package/docs/dev/01-dev-loop.md +1 -2
- package/docs/dev/02-verify.md +0 -1
- package/docs/dev/04-recover.md +6 -26
- package/docs/dev/05-agent-driven.md +1 -1
- package/docs/dev/README.md +4 -5
- package/docs/installation.md +76 -30
- package/docs/installation.zh-CN.md +68 -29
- package/package.json +8 -3
- package/packages/server/mock/agent.mjs +38 -0
- package/packages/server/src/acp/registry.ts +28 -1
- package/packages/server/src/acp/session-manager.ts +192 -60
- package/packages/server/src/index.ts +44 -64
- package/packages/server/src/notify/center.ts +118 -26
- package/packages/server/src/notify/types.ts +25 -0
- package/packages/server/src/settings.ts +46 -0
- package/packages/shared/src/index.ts +40 -0
- package/packages/web/dist/assets/index-DQdijjaT.css +32 -0
- package/packages/web/dist/assets/index-eHg6Cshr.js +119 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +39 -0
- package/packages/web/dist/assets/index-CCvfL56d.css +0 -32
- package/packages/web/dist/assets/index-DCBs8dsw.js +0 -119
- package/scripts/make-cert.sh +0 -101
|
@@ -36,6 +36,7 @@ import type {
|
|
|
36
36
|
TurnTrace,
|
|
37
37
|
} from "../../../shared/src/index.js";
|
|
38
38
|
import { acceptsToolWrite, acceptsWriteFrom, clampExplanation, demoteInProgress, foldPlanUpdate, hasUnfinished, normalizeItems, v2PlanEnabled } from "../plan/plan.js";
|
|
39
|
+
import type { PlanHintSettings } from "../settings.js";
|
|
39
40
|
|
|
40
41
|
// Permission prompts must not hang a session forever (design.md §8-4).
|
|
41
42
|
// Env-tunable so QA can exercise the timeout path in seconds instead of minutes.
|
|
@@ -108,6 +109,14 @@ const TITLE_FORK_TIMEOUT_MS = Number(process.env.AGENTUS_TITLE_TIMEOUT_MS || 90_
|
|
|
108
109
|
// (an agent that ignores session/cancel must not hang the operator's next sentence forever).
|
|
109
110
|
const TURN_SETTLE_TIMEOUT_MS = Number(process.env.AGENTUS_SETTLE_TIMEOUT_MS || 15_000);
|
|
110
111
|
|
|
112
|
+
/** How long a close/archive waits for a slot's in-flight handshake before killing it anyway.
|
|
113
|
+
*
|
|
114
|
+
* `#stop` waits because the agent-side session id only exists once the handshake lands, and a row
|
|
115
|
+
* closed without it can never be resumed (see `#stop`). The wait is bounded so a spawn that never
|
|
116
|
+
* answers cannot hold the operator's click open forever: past this, the slot closes with whatever it
|
|
117
|
+
* has — a broken agent is not made worse by a row we cannot wake. */
|
|
118
|
+
const CLOSE_STARTUP_WAIT_MS = Number(process.env.AGENTUS_CLOSE_WAIT_MS || 30_000);
|
|
119
|
+
|
|
111
120
|
/** A cold slot the agent cannot adopt, and why — the only two answers there are.
|
|
112
121
|
*
|
|
113
122
|
* Resuming has to *ask* the agent: `session/load` is "do you still have this session?", and the
|
|
@@ -159,6 +168,14 @@ interface LiveSession {
|
|
|
159
168
|
* so a second interrupting utterance that already started its own turn is not mistaken for
|
|
160
169
|
* "the one I cancelled is still running". */
|
|
161
170
|
turnSeq: number;
|
|
171
|
+
/** The spawn-time handshake, while it is still running.
|
|
172
|
+
*
|
|
173
|
+
* `create()` no longer waits for it (that wait is what made 「新建会话」 feel broken), so the slot
|
|
174
|
+
* exists — and can be closed, archived or reaped — before the agent has answered. This handle is
|
|
175
|
+
* how the teardown waits for the one thing only the handshake can tell us: the agent-side session
|
|
176
|
+
* id. Killing the child before it arrives leaves a row with `acpSessionId: null` that `resume()`
|
|
177
|
+
* refuses forever (「was never handed to an agent」) — see `#stop`. */
|
|
178
|
+
startup?: Promise<void>;
|
|
162
179
|
/** When the operator last USED this slot — he opened it, or his page reported it as the one on
|
|
163
180
|
* screen. In memory on purpose: this is the half of the idle rule that says "he was just here",
|
|
164
181
|
* and the store's message clock (which survives restarts) is the other half. Reading only the
|
|
@@ -203,12 +220,22 @@ export class SessionManager {
|
|
|
203
220
|
#sessions = new Map<string, LiveSession>();
|
|
204
221
|
#store: Store;
|
|
205
222
|
#emit: Emitter;
|
|
223
|
+
/** How the operator wants the plan-tool hint injected into a prompt — read on EVERY prompt, like
|
|
224
|
+
* the idle thresholds, because the requirement is that a change in 设置 applies to the very next
|
|
225
|
+
* turn with no restart and no session to hibernate. */
|
|
226
|
+
#planHint: () => PlanHintSettings = () => ({ on: true, everyTurn: false });
|
|
206
227
|
|
|
207
228
|
constructor(store: Store, emit: Emitter) {
|
|
208
229
|
this.#store = store;
|
|
209
230
|
this.#emit = emit;
|
|
210
231
|
}
|
|
211
232
|
|
|
233
|
+
/** Wire the cockpit's plan-hint setting in. Optional on purpose: with no source wired the
|
|
234
|
+
* manager behaves exactly as it did before the setting existed (on, 仅首条). */
|
|
235
|
+
setPlanHintSource(read: () => PlanHintSettings): void {
|
|
236
|
+
this.#planHint = read;
|
|
237
|
+
}
|
|
238
|
+
|
|
212
239
|
list(): SessionInfo[] {
|
|
213
240
|
// Most recently CHATTED first — the cockpit's left rail is a launch pad, not a log file, and
|
|
214
241
|
// creation order is the wrong key for that: the session you talked to a minute ago is the
|
|
@@ -268,14 +295,33 @@ export class SessionManager {
|
|
|
268
295
|
return s.stderrBuf.slice(-lines).join("\n ");
|
|
269
296
|
}
|
|
270
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Drop every pending approval of one slot, and TELL THE WIRE each one is gone.
|
|
300
|
+
*
|
|
301
|
+
* The client has exactly two ways to take an approval off the screen: the `permission-resolved`
|
|
302
|
+
* event, or a fresh `sessions` snapshot that no longer lists it. A bare `clear()` therefore
|
|
303
|
+
* leaves the popup, the transcript card, the ⚿ badge — and the phone's 待你确认 activity — for a
|
|
304
|
+
* request that does not exist any more; the operator clicks it, the server logs 「answer dropped」
|
|
305
|
+
* and answers `permission-expired`, and he reads his own cockpit as broken (measured 2026-10-11:
|
|
306
|
+
* `kill -9` the child with an approval up and the dialog is still there 4s later). So: every path
|
|
307
|
+
* that resolves these promises from the SERVER side goes through here.
|
|
308
|
+
*
|
|
309
|
+
* The decision is `cancelled` — what the agent would have received had nobody ever answered.
|
|
310
|
+
*/
|
|
311
|
+
#dropPending(live: LiveSession, decision: PermissionDecision = { outcome: "cancelled" }): void {
|
|
312
|
+
if (!live.pendingPermissions.size) return;
|
|
313
|
+
for (const [requestId, { resolve, timer }] of live.pendingPermissions) {
|
|
314
|
+
clearTimeout(timer);
|
|
315
|
+
resolve({ outcome: decision });
|
|
316
|
+
this.#emit({ t: "permission-resolved", requestId, decision });
|
|
317
|
+
}
|
|
318
|
+
live.pendingPermissions.clear();
|
|
319
|
+
}
|
|
320
|
+
|
|
271
321
|
/** Graceful shutdown: SIGTERM every live child before server exit. */
|
|
272
322
|
async shutdown(): Promise<void> {
|
|
273
323
|
const kills = [...this.#sessions.values()].map(async (s) => {
|
|
274
|
-
|
|
275
|
-
clearTimeout(timer);
|
|
276
|
-
resolve({ outcome: { outcome: "cancelled" } });
|
|
277
|
-
}
|
|
278
|
-
s.pendingPermissions.clear();
|
|
324
|
+
this.#dropPending(s);
|
|
279
325
|
s.child?.kill("SIGTERM");
|
|
280
326
|
});
|
|
281
327
|
await Promise.allSettled(kills);
|
|
@@ -398,6 +444,33 @@ export class SessionManager {
|
|
|
398
444
|
});
|
|
399
445
|
child.on("exit", (code, sig) => this.#onChildExit(live, code, sig));
|
|
400
446
|
|
|
447
|
+
// The row is complete and already persisted with `status: "starting"` — hand it back to the
|
|
448
|
+
// caller NOW and let the handshake finish OFF the request. See `#startup`.
|
|
449
|
+
// Kept on the slot (not dropped with `void`) so a close during the window can wait for the
|
|
450
|
+
// agent-side id instead of orphaning the session — see `#stop`.
|
|
451
|
+
live.startup = this.#startup(live, backend, spec, cwd, spawnStarted);
|
|
452
|
+
void live.startup.catch(() => {}); // `#startup` reports failure on the slot; nobody is awaiting
|
|
453
|
+
return live.info;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The spawn-time handshake — `initialize` + `newSession` + the row's default permission mode.
|
|
457
|
+
*
|
|
458
|
+
* Deliberately NOT awaited by `create()`. Holding the HTTP response open for the whole handshake
|
|
459
|
+
* is what made 「新建会话」 feel broken: hermes boots a python process and answers in 10–40 s, the
|
|
460
|
+
* page sat on a busy dialog with no progress to show and a 120 s timeout behind it (measured; the
|
|
461
|
+
* old `state.ts` comment said so in as many words). The row is born `starting` — the rail can
|
|
462
|
+
* draw it, the page can open it and file the first message — and when this resolves,
|
|
463
|
+
* `#updateSession` broadcasts `ready`, which is also the signal the client drains that queued
|
|
464
|
+
* first message on (`state.ts`, `case "session"`).
|
|
465
|
+
*
|
|
466
|
+
* Failure is reported INSIDE the session, never to a caller that has long since answered: the
|
|
467
|
+
* status goes `error` with `lastError` (the reason + the agent's stderr tail, delivered while the
|
|
468
|
+
* slot is still in the live map so the frame carries it), then the slot is dropped and the row is
|
|
469
|
+
* closed. That is what makes 「启动失败 → 因为什么 → 再试一次」 possible at all. */
|
|
470
|
+
async #startup(live: LiveSession, backend: BackendId, spec: BackendSpec, cwd: string, spawnStarted: number): Promise<void> {
|
|
471
|
+
const id = live.info.id;
|
|
472
|
+
const child = live.child;
|
|
473
|
+
if (!child) return; // the slot was torn down between `create()` returning and this line
|
|
401
474
|
try {
|
|
402
475
|
const conn = new ClientSideConnection(
|
|
403
476
|
() => ({
|
|
@@ -439,18 +512,19 @@ export class SessionManager {
|
|
|
439
512
|
// this method — that is exactly what keeps a per-session pick from being silently undone.
|
|
440
513
|
await this.#applyDefaultMode(live);
|
|
441
514
|
this.#updateSession(live);
|
|
442
|
-
return live.info;
|
|
443
515
|
} catch (err) {
|
|
444
516
|
child.kill("SIGKILL");
|
|
445
517
|
live.info.status = "error";
|
|
446
518
|
live.info.lastError = errMessage(err) + stderrTail(live);
|
|
447
|
-
|
|
519
|
+
// Announce the failure WHILE the slot is still live: `{t:"session"}` is the only frame that
|
|
520
|
+
// carries `lastError`, and the page needs it to say why nothing came up.
|
|
521
|
+
this.#updateSession(live);
|
|
522
|
+
this.#sessions.delete(id); // failed handshake => don't keep a zombie slot
|
|
448
523
|
this.#store.upsertSession({ ...sessionRow(live.info), closedAt: Date.now() });
|
|
449
524
|
this.#noteBackendCheck(String(backend), {
|
|
450
525
|
ok: false, error: live.info.lastError, errorCode: classifyError(err),
|
|
451
526
|
latencyMs: Date.now() - spawnStarted,
|
|
452
527
|
});
|
|
453
|
-
throw err instanceof Error ? err : new Error(String(err));
|
|
454
528
|
}
|
|
455
529
|
}
|
|
456
530
|
|
|
@@ -690,11 +764,7 @@ export class SessionManager {
|
|
|
690
764
|
// operator watched their slot flicker into the archive for a second — measured on :8901).
|
|
691
765
|
// Mark the swap BEFORE the signal, so the exit we are causing is not read as a dead agent.
|
|
692
766
|
live.replacing = true;
|
|
693
|
-
|
|
694
|
-
clearTimeout(timer);
|
|
695
|
-
resolve({ outcome: { outcome: "cancelled" } });
|
|
696
|
-
}
|
|
697
|
-
live.pendingPermissions.clear();
|
|
767
|
+
this.#dropPending(live);
|
|
698
768
|
live.busy = false;
|
|
699
769
|
// show the swap honestly: "starting" with no pid, not a pid that is already gone
|
|
700
770
|
live.info.status = "starting";
|
|
@@ -826,10 +896,24 @@ export class SessionManager {
|
|
|
826
896
|
if (!blocks.length) throw new Error("empty prompt");
|
|
827
897
|
// The plan hand-over, owed once per agent process (§7): a restarted agent never knew about the
|
|
828
898
|
// plan, and nothing else on this wire would tell it (ACP has no server->agent state channel).
|
|
829
|
-
|
|
899
|
+
//
|
|
900
|
+
// It carries a second thing the agent cannot learn on its own: that this session HAS a plan
|
|
901
|
+
// tool. Its own description says "prefer this over any todo tool", but a runtime that keeps MCP
|
|
902
|
+
// tools in a lazy-loaded catalog (Hermes) never shows that text to the model unless the model
|
|
903
|
+
// goes looking — so the plan card stayed empty while the agent kept a todo list only its own
|
|
904
|
+
// process could see. The nudge therefore has to come from the wire; Studio does the same
|
|
905
|
+
// (its `task-plan-runs.ts` appends a template to the latest input every turn).
|
|
906
|
+
//
|
|
907
|
+
// Read per prompt, and only CONSUME the hand-over when it is actually injected: an operator who
|
|
908
|
+
// turns the hint off and later back on gets the plan hand-over then, rather than losing it.
|
|
909
|
+
const hint = this.#planHint();
|
|
910
|
+
if (hint.on) {
|
|
911
|
+
const owed = Boolean(s.planReminderPending);
|
|
830
912
|
s.planReminderPending = false;
|
|
831
|
-
|
|
832
|
-
|
|
913
|
+
if (owed || hint.everyTurn) {
|
|
914
|
+
const reminder = this.#planReminder(s, !owed);
|
|
915
|
+
if (reminder) blocks.push(reminder);
|
|
916
|
+
}
|
|
833
917
|
}
|
|
834
918
|
s.turnSeq += 1;
|
|
835
919
|
s.busy = true;
|
|
@@ -1166,46 +1250,78 @@ export class SessionManager {
|
|
|
1166
1250
|
/** The plan, handed to an agent that just (re)started, as an ACP `resource` content block: a
|
|
1167
1251
|
* stable URI plus the steps still owed. The card is fed by the plan OBJECT, but the AGENT's own
|
|
1168
1252
|
* copy died with its process — this block is the only thing that tells it the plan exists
|
|
1169
|
-
* (design-plan-service.md §7).
|
|
1170
|
-
*
|
|
1171
|
-
*
|
|
1172
|
-
|
|
1253
|
+
* (design-plan-service.md §7).
|
|
1254
|
+
*
|
|
1255
|
+
* `perTurn` = this is a mid-conversation nudge (the 每回合 setting), not the once-per-process
|
|
1256
|
+
* hand-over. It changes only what is worth SAYING: with no plan on the card there is nothing to
|
|
1257
|
+
* hand over and repeating the introduction every turn would be the noise the operator asked to
|
|
1258
|
+
* avoid, so a per-turn call with nothing to show returns null. Returns null too when a plan
|
|
1259
|
+
* exists but every step is closed — spending context to say "you are done" is worse than saying
|
|
1260
|
+
* nothing. */
|
|
1261
|
+
#planReminder(s: LiveSession, perTurn = false): unknown | null {
|
|
1173
1262
|
const plan = this.#store.getPlan(s.info.id);
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1263
|
+
const open = plan
|
|
1264
|
+
? plan.items.filter((i) => i.status !== "completed" && i.status !== "cancelled")
|
|
1265
|
+
: [];
|
|
1266
|
+
if (plan && !open.length) return null;
|
|
1267
|
+
if (!open.length && perTurn) return null;
|
|
1268
|
+
|
|
1178
1269
|
// How the agent keeps it current depends on which channel it actually has: an agent whose row
|
|
1179
1270
|
// opted into native frames must use its own todo tool (those frames are what feed the card),
|
|
1180
1271
|
// while an MCP-driven agent — which is every backend by default, Hermes included — was handed
|
|
1181
1272
|
// `update_plan` in this very handshake, and its own todo tool does NOT reach the card.
|
|
1182
1273
|
const mcpDriven = nativePlanSourceOf(this.#specFor(s.info.backend)) === "none";
|
|
1183
|
-
const
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
]
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1274
|
+
const body: string[] = [];
|
|
1275
|
+
|
|
1276
|
+
if (open.length) {
|
|
1277
|
+
body.push(
|
|
1278
|
+
"This session has an unfinished plan. It is kept by the cockpit and survives your",
|
|
1279
|
+
"restarts; your own in-memory task list does not.",
|
|
1280
|
+
"",
|
|
1281
|
+
...open.map((i) => `- [${i.status}] ${i.content}`),
|
|
1282
|
+
"",
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
if (mcpDriven && open.length) {
|
|
1287
|
+
body.push(
|
|
1288
|
+
"Carry on with it, and keep it updated with the `update_plan` tool as you go — that list is",
|
|
1289
|
+
"the ONLY one the cockpit shows the operator, so a built-in todo/checklist tool of yours will",
|
|
1290
|
+
"not be seen there. Mark a step completed once its work is actually verified, and drop or",
|
|
1291
|
+
"cancel what you are no longer doing. If you disagree with an item, change it rather than",
|
|
1292
|
+
"quietly skipping it.",
|
|
1293
|
+
);
|
|
1294
|
+
} else if (mcpDriven) {
|
|
1295
|
+
// No plan yet. This is the case the cockpit used to say NOTHING about — which is exactly why
|
|
1296
|
+
// an agent reached for its own todo list and the card stayed empty: `update_plan`'s own
|
|
1297
|
+
// description ("prefer this over any todo tool") lives on a tool a lazy-loaded MCP catalog
|
|
1298
|
+
// never puts in front of the model. Keep it short; it rides on every first prompt.
|
|
1299
|
+
body.push(
|
|
1300
|
+
"This session has an `update_plan` tool mounted on it (MCP server `agentus_plan`), and the",
|
|
1301
|
+
"plan it holds is the one the operator sees — a todo list of your own is invisible there and",
|
|
1302
|
+
"dies with your process. For anything with more than one step, publish the steps with",
|
|
1303
|
+
"`update_plan` and update it as you go: mark a step completed only once its work is actually",
|
|
1304
|
+
"verified, and drop or cancel what you are no longer doing. If `update_plan` is not in your",
|
|
1305
|
+
"tool list it is deferred — search for `agentus_plan update_plan` to find it, then call it by",
|
|
1306
|
+
"that exact name. A one-step request needs no plan.",
|
|
1307
|
+
);
|
|
1308
|
+
} else if (open.length) {
|
|
1309
|
+
body.push(
|
|
1310
|
+
"Carry on with it, and keep it updated with your own plan/todo tool as you go: mark a step",
|
|
1311
|
+
"completed only once its work is actually verified, and drop or cancel what you are no",
|
|
1312
|
+
"longer doing. If you disagree with an item, change it rather than quietly skipping it.",
|
|
1313
|
+
);
|
|
1314
|
+
} else {
|
|
1315
|
+
// A native-frames backend with nothing to hand over: nothing worth saying.
|
|
1316
|
+
return null;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1196
1319
|
return {
|
|
1197
1320
|
type: "resource",
|
|
1198
1321
|
resource: {
|
|
1199
1322
|
uri: `agentus://plan/${s.info.id}`,
|
|
1200
1323
|
mimeType: "text/plain",
|
|
1201
|
-
text:
|
|
1202
|
-
"This session has an unfinished plan. It is kept by the cockpit and survives your",
|
|
1203
|
-
"restarts; your own in-memory task list does not.",
|
|
1204
|
-
"",
|
|
1205
|
-
lines,
|
|
1206
|
-
"",
|
|
1207
|
-
...closing,
|
|
1208
|
-
].join("\n"),
|
|
1324
|
+
text: body.join("\n"),
|
|
1209
1325
|
},
|
|
1210
1326
|
// ACP reserves `_meta` for the client: tagging our own blocks is what lets us tell them from
|
|
1211
1327
|
// the operator's words later (and strip them from anything we echo back).
|
|
@@ -1215,7 +1331,10 @@ export class SessionManager {
|
|
|
1215
1331
|
|
|
1216
1332
|
async closeSession(sessionId: string): Promise<void> {
|
|
1217
1333
|
const s = this.#need(sessionId);
|
|
1218
|
-
|
|
1334
|
+
// Awaited, not fired and forgotten: the row has to be settled (and carry the agent-side id) by the
|
|
1335
|
+
// time the DELETE that asked for this answers, or the page loads a slot the store still calls
|
|
1336
|
+
// `starting`.
|
|
1337
|
+
await this.#stop(s, "closed");
|
|
1219
1338
|
this.#emitSessions();
|
|
1220
1339
|
}
|
|
1221
1340
|
|
|
@@ -1230,7 +1349,7 @@ export class SessionManager {
|
|
|
1230
1349
|
const live = this.#sessions.get(id);
|
|
1231
1350
|
const row = this.#store.getSession(id);
|
|
1232
1351
|
if (!row) throw new Error(`no such session: ${id}`);
|
|
1233
|
-
if (on && live) this.#stop(live, "archived");
|
|
1352
|
+
if (on && live) await this.#stop(live, "archived");
|
|
1234
1353
|
this.#store.setArchived(id, on ? Date.now() : null);
|
|
1235
1354
|
this.#emitSessions();
|
|
1236
1355
|
return this.#sessions.get(id)?.info ?? this.#coldInfo(id);
|
|
@@ -1242,15 +1361,30 @@ export class SessionManager {
|
|
|
1242
1361
|
* `reaped` is its own status on purpose — 「它怎么变冷了」 has three different answers (I closed
|
|
1243
1362
|
* it / it crashed / the cockpit reclaimed it for being idle) and a rail that shows one word for
|
|
1244
1363
|
* all three teaches the operator to distrust the column. */
|
|
1245
|
-
#stop(live: LiveSession, why: "closed" | "archived" | "reaped"): void {
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1364
|
+
async #stop(live: LiveSession, why: "closed" | "archived" | "reaped"): Promise<void> {
|
|
1365
|
+
this.#dropPending(live);
|
|
1366
|
+
// A slot can be closed while its handshake is still in flight — `create()` no longer waits for it
|
|
1367
|
+
// (see there). Killing the child on the spot was WRONG in a way the operator would eventually hit:
|
|
1368
|
+
// the agent had already created a session, but the row was written with `acpSessionId: null`, and
|
|
1369
|
+
// `resume()` refuses such a row outright (「was never handed to an agent」) — the slot became a
|
|
1370
|
+
// transcript nobody could ever wake again. So let the startup land first: it is the only thing that
|
|
1371
|
+
// knows the agent-side id, and the child it spawned is the one really there to kill.
|
|
1372
|
+
//
|
|
1373
|
+
// The slot stays in the live map for the duration, on purpose: the child IS alive and about to be
|
|
1374
|
+
// `ready`, and calling it cold while it boots is the mirror-image lie (a cold row that wakes up by
|
|
1375
|
+
// itself). The wait is bounded — see `CLOSE_STARTUP_WAIT_MS`.
|
|
1376
|
+
if (live.startup && live.info.status === "starting") {
|
|
1377
|
+
await Promise.race([
|
|
1378
|
+
live.startup.catch(() => {}),
|
|
1379
|
+
new Promise<void>((r) => { setTimeout(r, CLOSE_STARTUP_WAIT_MS).unref?.(); }),
|
|
1380
|
+
]);
|
|
1249
1381
|
}
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1382
|
+
// Applied LAST, and only over a non-terminal status: `#startup` writes `ready` when the handshake
|
|
1383
|
+
// lands and `error` (with `lastError`) when it fails — and a handshake that failed on its own has a
|
|
1384
|
+
// better answer than 「closed」, so it keeps it.
|
|
1385
|
+
if (live.info.status !== "error") live.info.status = why === "reaped" ? "reaped" : "closed";
|
|
1253
1386
|
live.info.pid = null;
|
|
1387
|
+
live.child?.kill("SIGTERM");
|
|
1254
1388
|
const row = this.#store.getSession(live.info.id);
|
|
1255
1389
|
if (row) {
|
|
1256
1390
|
this.#store.upsertSession({
|
|
@@ -1371,7 +1505,9 @@ export class SessionManager {
|
|
|
1371
1505
|
`[agentus] idle reap: ${s.info.id} (${Math.round(age / 60_000)} min idle, limit ${Math.round(idleMs / 60_000)} min)`,
|
|
1372
1506
|
);
|
|
1373
1507
|
}
|
|
1374
|
-
|
|
1508
|
+
// Fire and forget: the reaper skips `starting` slots, so there is no in-flight handshake to
|
|
1509
|
+
// wait for here and nothing downstream needs the row settled in this tick.
|
|
1510
|
+
void this.#stop(s, "reaped");
|
|
1375
1511
|
reaped += 1;
|
|
1376
1512
|
}
|
|
1377
1513
|
// Only speak when something changed: a frame every tick would be noise on the socket (and on
|
|
@@ -1647,11 +1783,7 @@ export class SessionManager {
|
|
|
1647
1783
|
// …and if we are REPLACING this slot's process (restart), the exit is the one we asked for: the
|
|
1648
1784
|
// slot has not failed, it is coming back on a new child.
|
|
1649
1785
|
if (live.replacing) return;
|
|
1650
|
-
|
|
1651
|
-
clearTimeout(timer);
|
|
1652
|
-
resolve({ outcome: { outcome: "cancelled" } });
|
|
1653
|
-
}
|
|
1654
|
-
live.pendingPermissions.clear();
|
|
1786
|
+
this.#dropPending(live);
|
|
1655
1787
|
live.busy = false;
|
|
1656
1788
|
live.info.status = "error";
|
|
1657
1789
|
live.info.lastError = `agent process exited (code=${code} sig=${sig})` + stderrTail(live);
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Agentus server entry: HTTP (REST + static web build) + WebSocket relay.
|
|
2
2
|
// The browser never talks to CLI subprocesses directly (design.md §1).
|
|
3
3
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
4
|
-
import { createServer as createHttpsServer } from "node:https";
|
|
5
4
|
import type { Duplex } from "node:stream";
|
|
6
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
6
|
import { homedir } from "node:os";
|
|
@@ -20,7 +19,7 @@ import { terms } from "./term.js";
|
|
|
20
19
|
import { VoiceError, listVoiceModels, setHotwordSource, synthesize, transcribe, voiceCapabilities } from "./voice.js";
|
|
21
20
|
import { hotwordsFor, vocabularyOf } from "./hotwords.js";
|
|
22
21
|
import { openDashscopeStream } from "./dashscope.js";
|
|
23
|
-
import { agentSettings, initSettings, publicSettings, saveAgent, saveCall, savePrefs, saveSettings, saveTheme } from "./settings.js";
|
|
22
|
+
import { agentSettings, initSettings, planHintSettings, publicSettings, saveAgent, saveCall, savePrefs, saveSettings, saveTheme } from "./settings.js";
|
|
24
23
|
import * as auth from "./auth.js";
|
|
25
24
|
import { NotifyCenter } from "./notify/center.js";
|
|
26
25
|
import type { BackendId, ClientCommand, PermissionDecision, PromptAttachment, ServerEvent } from "../../shared/src/index.js";
|
|
@@ -31,7 +30,7 @@ const PORT = Number(process.env.AGENTUS_PORT ?? 8788);
|
|
|
31
30
|
// cwd-relative path would silently open a second database (the operator's slots would
|
|
32
31
|
// "disappear"). Both entry points must land on the same store.
|
|
33
32
|
const HERE = path.dirname(fileURLToPath(import.meta.url)); // …/packages/server/src
|
|
34
|
-
// WHERE STATE LIVES — sqlite, credentials.json, auth.token,
|
|
33
|
+
// WHERE STATE LIVES — sqlite, credentials.json, auth.token, settings.json.
|
|
35
34
|
// 1. `AGENTUS_DATA` when it is set. Every launcher and dev/QA instance pins it, so the
|
|
36
35
|
// rules below can never move a running install's data out from under it.
|
|
37
36
|
// 2. `~/.agentus` once it holds something, or when there is no repo-side data yet.
|
|
@@ -41,7 +40,7 @@ const HERE = path.dirname(fileURLToPath(import.meta.url)); // …/packages/serve
|
|
|
41
40
|
// directory won and how to move).
|
|
42
41
|
// NOTHING may write inside node_modules: for an installed package
|
|
43
42
|
// `<pkg>/packages/server/.data` is deleted by the next `npm i -g agentus`, taking the
|
|
44
|
-
// transcript
|
|
43
|
+
// transcript and the login with it.
|
|
45
44
|
const DEFAULT_DATA_DIR = path.join(homedir(), ".agentus");
|
|
46
45
|
const REPO_DATA_DIR = path.resolve(HERE, "../.data");
|
|
47
46
|
const hasData = (dir: string): boolean => {
|
|
@@ -69,19 +68,6 @@ const APK_FILE = process.env.AGENTUS_APK
|
|
|
69
68
|
path.join(DATA_DIR, "agentus-companion.apk"),
|
|
70
69
|
].find((p) => fs.existsSync(p))
|
|
71
70
|
?? path.resolve(HERE, "../../../android/artifacts/agentus-companion.apk");
|
|
72
|
-
// Optional second listener, TLS (see the block right before listen). Declared up here
|
|
73
|
-
// because the request handler also serves the public cert — a phone that has to trust a
|
|
74
|
-
// self-signed issuer needs to fetch the cert from somewhere, and that somewhere should
|
|
75
|
-
// not require already trusting it.
|
|
76
|
-
// AGENTUS_TLS_PORT=0 -> off; no cert on disk -> off (the LAN listener is unaffected).
|
|
77
|
-
const TLS_PORT = Number(process.env.AGENTUS_TLS_PORT ?? 8443);
|
|
78
|
-
const TLS_CERT = process.env.AGENTUS_TLS_CERT ?? path.join(DATA_DIR, "tls", "cert.pem");
|
|
79
|
-
const TLS_KEY = process.env.AGENTUS_TLS_KEY ?? path.join(DATA_DIR, "tls", "key.pem");
|
|
80
|
-
// The issuer a device installs once (make-cert.sh: long-lived root + short-lived leaf).
|
|
81
|
-
// Falls back to the leaf itself for a single self-signed cert from an older setup.
|
|
82
|
-
const TLS_CA = process.env.AGENTUS_TLS_CA ?? path.join(DATA_DIR, "tls", "ca.pem");
|
|
83
|
-
const TLS_READY = TLS_PORT > 0 && fs.existsSync(TLS_CERT) && fs.existsSync(TLS_KEY);
|
|
84
|
-
|
|
85
71
|
// `agentus --where` (bin/agentus.mjs) asks the ONE place that decides these paths to say
|
|
86
72
|
// what it decided, then exits — before any listener, store or subprocess exists. Print and
|
|
87
73
|
// exit here rather than re-deriving the same rules in the CLI: "where is my data" must not
|
|
@@ -92,12 +78,11 @@ if (process.env.AGENTUS_PRINT_PATHS) {
|
|
|
92
78
|
console.log(`[agentus] data dir ${DATA_DIR} (${DATA_DIR_SOURCE})`);
|
|
93
79
|
console.log(`[agentus] web bundle ${WEB_DIST} (${has(path.join(WEB_DIST, "index.html"))})`);
|
|
94
80
|
console.log(`[agentus] companion apk ${APK_FILE} (${has(APK_FILE)})`);
|
|
95
|
-
console.log(`[agentus] tls listener ${TLS_READY ? `on :${TLS_PORT} (${TLS_CERT})` : `off (AGENTUS_TLS_PORT=${TLS_PORT}, cert ${has(TLS_CERT)})`}`);
|
|
96
81
|
console.log(`[agentus] http port ${PORT}`);
|
|
97
82
|
process.exit(0);
|
|
98
83
|
}
|
|
99
84
|
|
|
100
|
-
fs.mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 }); // 0700: credentials
|
|
85
|
+
fs.mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 }); // 0700: credentials live here
|
|
101
86
|
const store = new Store(path.join(DATA_DIR, "agentus.sqlite"));
|
|
102
87
|
// M6: an existing cockpit must behave exactly as before, so the registry starts as a copy of
|
|
103
88
|
// the builtin (env-driven) rows and the operator edits from there. Seeding is one-shot: an
|
|
@@ -223,16 +208,25 @@ notify.onAction(({ ref, action, activity, device }) => {
|
|
|
223
208
|
console.log(`[notify] action device=${device.name} activity=${activity.activityId} action=${action.actionId} type=${ref?.type ?? "none"}`);
|
|
224
209
|
if (!ref) return; // the button existed but nobody claimed it: log and drop, never guess
|
|
225
210
|
switch (ref.type) {
|
|
226
|
-
case "permission":
|
|
211
|
+
case "permission": {
|
|
227
212
|
// The lock screen answering an agent's permission prompt — the whole point of the channel.
|
|
228
|
-
mgr.respondPermission(String(ref.sessionId), String(ref.requestId), {
|
|
213
|
+
const applied = mgr.respondPermission(String(ref.sessionId), String(ref.requestId), {
|
|
229
214
|
outcome: "selected",
|
|
230
215
|
optionId: String(ref.optionId),
|
|
231
216
|
}, {
|
|
232
217
|
optionKind: typeof ref.optionKind === "string" ? ref.optionKind : undefined,
|
|
233
218
|
signature: typeof ref.signature === "string" ? ref.signature : undefined,
|
|
234
219
|
});
|
|
220
|
+
// The request is gone (our timeout, the agent gave up, its process died). Take the card off
|
|
221
|
+
// the phone instead of leaving a 「待你确认」 that no longer has a request behind it — the
|
|
222
|
+
// browser gets `permission-expired` for the same click, and a phone that keeps a dead button
|
|
223
|
+
// is the same defect one device over.
|
|
224
|
+
if (!applied) {
|
|
225
|
+
console.log(`[notify] permission answer dropped for ${ref.requestId}: card dismissed`);
|
|
226
|
+
notify.dismiss(`perm:${ref.requestId}`);
|
|
227
|
+
}
|
|
235
228
|
return;
|
|
229
|
+
}
|
|
236
230
|
case "open":
|
|
237
231
|
return; // the app already opened the deeplink; nothing to do server-side
|
|
238
232
|
default:
|
|
@@ -240,6 +234,19 @@ notify.onAction(({ ref, action, activity, device }) => {
|
|
|
240
234
|
}
|
|
241
235
|
});
|
|
242
236
|
|
|
237
|
+
// The page channel of the same decision: one `notify` frame to every cockpit websocket.
|
|
238
|
+
//
|
|
239
|
+
// No cursor, no replay, and no per-client state — deliberately unlike the device socket above. The
|
|
240
|
+
// phone replays because it was PAIRED and may have been asleep in a pocket; a browser tab that missed
|
|
241
|
+
// frames has a transcript to catch up in, and a wall of stale cards after a reload is worse than none.
|
|
242
|
+
// Every gate that matters (which kinds, whether the channel is on) was applied in the notify centre.
|
|
243
|
+
notify.onNotice((notice) => {
|
|
244
|
+
const wire = JSON.stringify({ t: "notify", notice });
|
|
245
|
+
for (const { ws } of clients.values()) {
|
|
246
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(wire);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
243
250
|
// Operator settings (voice, theme, call knobs, talk-to-agent prefs) live in the store's
|
|
244
251
|
// `settings` table: the UI owns them and env only bootstraps them (settings.ts). Read
|
|
245
252
|
// before anything asks.
|
|
@@ -291,6 +298,9 @@ setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word)
|
|
|
291
298
|
envWarmTtlMs != null ? envWarmTtlMs / 3_600_000 : agentSettings().warmTtlHours
|
|
292
299
|
} h untouched (${envWarm != null || envWarmTtlMs != null ? "AGENTUS_WARM_*" : "settings.agent"}), 0 = off / never`,
|
|
293
300
|
);
|
|
301
|
+
// The plan-tool hint: read per prompt, so the 设置 switch applies to the next turn with nothing
|
|
302
|
+
// to restart (the session's own plan card is what it fills).
|
|
303
|
+
mgr.setPlanHintSource(planHintSettings);
|
|
294
304
|
mgr.startIdleReaper({
|
|
295
305
|
idleMs: () => envIdleMs ?? agentSettings().idleKillMin * 60_000,
|
|
296
306
|
warmSlots: () => envWarm ?? agentSettings().warmSlots,
|
|
@@ -408,9 +418,10 @@ function serveStatic(req: IncomingMessage, res: ServerResponse): void {
|
|
|
408
418
|
fs.createReadStream(file).pipe(res);
|
|
409
419
|
}
|
|
410
420
|
|
|
411
|
-
// One request handler,
|
|
412
|
-
//
|
|
413
|
-
//
|
|
421
|
+
// One request handler, plain HTTP on PORT (LAN, loopback, curl/scripts).
|
|
422
|
+
// TLS is NOT terminated in-process: put a TLS-terminating reverse proxy (or a tunnel that
|
|
423
|
+
// does its own `public_tls`) in FRONT of this port. That keeps the LAN path warning-free
|
|
424
|
+
// and `curl`/scripts byte-identical, and keeps cert lifecycle out of the app.
|
|
414
425
|
const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
|
415
426
|
const url = new URL(req.url ?? "/", "http://x");
|
|
416
427
|
try {
|
|
@@ -452,24 +463,6 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
|
|
452
463
|
return res.end("Agentus: authentication required\n");
|
|
453
464
|
}
|
|
454
465
|
|
|
455
|
-
// GET /cert.crt — the ISSUER to install into a phone's trust store (the "proceed
|
|
456
|
-
// anyway" click, once per device, is the whole cost of TOFU; this makes it a download).
|
|
457
|
-
// With make-cert.sh's root+leaf setup this is the root, so the leaf can be rotated
|
|
458
|
-
// yearly without every device having to re-install.
|
|
459
|
-
// PUBLIC material only: the key never leaves <DATA>/tls/.
|
|
460
|
-
if (url.pathname === "/cert.crt") {
|
|
461
|
-
if (!TLS_READY) return send(res, 404, { error: "tls is off on this server" });
|
|
462
|
-
const der = fs.readFileSync(fs.existsSync(TLS_CA) ? TLS_CA : TLS_CERT);
|
|
463
|
-
res.writeHead(200, {
|
|
464
|
-
// Apple's installer claims this type and opens the profile flow directly;
|
|
465
|
-
// Android/Chrome just downloads the file.
|
|
466
|
-
"content-type": "application/x-x509-ca-cert",
|
|
467
|
-
"content-disposition": 'attachment; filename="agentus.crt"',
|
|
468
|
-
"content-length": String(der.length),
|
|
469
|
-
});
|
|
470
|
-
return res.end(der);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
466
|
// ---- the phone's onboarding page + the APK, both behind the operator login ----------
|
|
474
467
|
// The pairing code lives on this page, so it is NOT public: an unauthenticated visitor is
|
|
475
468
|
// sent to the login screen (the SPA at /) and comes back with a session cookie.
|
|
@@ -1250,7 +1243,14 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
1250
1243
|
const html = fs.readFileSync(path.join(WEB_DIST, "index.html"), "utf8");
|
|
1251
1244
|
asset = html.match(/\/assets\/index-([A-Za-z0-9_-]+)\.js/)?.[1] ?? null;
|
|
1252
1245
|
} catch { /* no build on disk (dev server without dist) */ }
|
|
1253
|
-
|
|
1246
|
+
// …and `dev`: WHICH INSTANCE this is. Only two things in the UI care, and both are about
|
|
1247
|
+
// keeping a QA backend out of the operator's way — the mock agent shows up in the new-session
|
|
1248
|
+
// form on a dev instance only. The marker is the dev runner's own environment (`DEV_TREE`,
|
|
1249
|
+
// written by scripts/dev.sh; also AGENTUS_DEV=1 for a launcher of one's own); the live
|
|
1250
|
+
// launcher sets neither, so live answers false. An instance-level fact, so it rides the
|
|
1251
|
+
// endpoint that already describes the instance rather than every backend row.
|
|
1252
|
+
const dev = Boolean(process.env.DEV_TREE) || process.env.AGENTUS_DEV === "1";
|
|
1253
|
+
return send(res, 200, { asset, dev });
|
|
1254
1254
|
}
|
|
1255
1255
|
if (url.pathname.startsWith("/api/")) return send(res, 404, { error: "not_found" });
|
|
1256
1256
|
return serveStatic(req, res);
|
|
@@ -1269,8 +1269,7 @@ const httpServer = createServer(handleRequest);
|
|
|
1269
1269
|
// it becomes a socket. A browser cannot set headers on a WS handshake, so auth
|
|
1270
1270
|
// rides on the cookie (sent automatically, same-origin) — or ?token= for scripts.
|
|
1271
1271
|
const wss = new WebSocketServer({ noServer: true });
|
|
1272
|
-
// Bound to
|
|
1273
|
-
// needs its wss sockets too, and this handler reads the cookie / ?token=, never the scheme.
|
|
1272
|
+
// Bound to the single HTTP listener; this handler reads the cookie / ?token=, never the scheme.
|
|
1274
1273
|
const handleUpgrade = (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
1275
1274
|
const url = new URL(req.url ?? "/", "http://x");
|
|
1276
1275
|
// The companion's socket carries its own device token in the query — a phone cannot set
|
|
@@ -1596,25 +1595,6 @@ httpServer.listen(PORT, "0.0.0.0", () => {
|
|
|
1596
1595
|
);
|
|
1597
1596
|
}
|
|
1598
1597
|
});
|
|
1599
|
-
// ---- optional TLS listener (self-signed) ----
|
|
1600
|
-
// A tunnel that terminates on the public internet must NOT be plain HTTP: the login
|
|
1601
|
-
// password and the session cookie would cross it in the clear. SEPARATE from PORT on
|
|
1602
|
-
// purpose, so the LAN path stays plain HTTP — no cert warning in the house, curl
|
|
1603
|
-
// unchanged. (Config constants live at the top, next to the other paths.)
|
|
1604
|
-
if (TLS_READY) {
|
|
1605
|
-
const httpsServer = createHttpsServer({ cert: fs.readFileSync(TLS_CERT), key: fs.readFileSync(TLS_KEY) }, handleRequest);
|
|
1606
|
-
httpsServer.on("upgrade", handleUpgrade);
|
|
1607
|
-
httpsServer.listen(TLS_PORT, "0.0.0.0", () => {
|
|
1608
|
-
console.log(`[agentus-server] https://0.0.0.0:${TLS_PORT} (self-signed, SAN ${TLS_CERT} — point a public tunnel at THIS port; ${PORT} stays for the LAN)`);
|
|
1609
|
-
});
|
|
1610
|
-
// A busy TLS port must not take the LAN listener down with it: log, keep serving.
|
|
1611
|
-
httpsServer.on("error", (err) => {
|
|
1612
|
-
console.error(`[agentus-server] tls listener failed on ${TLS_PORT}: ${(err as Error).message}`);
|
|
1613
|
-
});
|
|
1614
|
-
} else if (TLS_PORT > 0) {
|
|
1615
|
-
console.log(`[agentus-server] tls off: no cert at ${TLS_CERT} (create it with scripts/make-cert.sh, or set AGENTUS_TLS_PORT=0)`);
|
|
1616
|
-
}
|
|
1617
|
-
|
|
1618
1598
|
httpServer.on("error", (err) => {
|
|
1619
1599
|
console.error(`[agentus] cannot listen on ${PORT}: ${(err as Error).message}`);
|
|
1620
1600
|
process.exit(1); // fail fast: never linger as a listener-less zombie
|