agent-lyceum 0.5.0 → 0.7.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.
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { realInvoker } from "./adapters/index.js";
4
4
  import { briefOf, doneContract, parseSteps } from "./format.js";
5
5
  import { ProtectedGuard } from "./guard.js";
6
+ import { addBatch, askersWithAnswers, askReplyPath, checkAnswers, describeCheck, isComplete, markDelivered, parseAskBody, readAskReply, renderAnswers, writeAskReply } from "./ask-reply.js";
6
7
  import { deliver, deliverOnce, ensureProjectDirs, finishDone, listUnread, rejectDone, routeOutboxes, unreadFiles } from "./mailbox.js";
7
8
  import { abandonClaim, attemptLogDir, sourceIdOf, beginAttempt, claimMessages, commitClaim, finishAttempt, markOutputReady, parkOutbox, recoverRunMail, RouteJournal } from "./message-store.js";
8
9
  import { isInside } from "./paths.js";
@@ -13,12 +14,15 @@ import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
13
14
  import { atomicWrite } from "./fs-util.js";
14
15
  import { assertWorktreeRunnable, collectAgentChanges, existingAgentWorkspace, integrateAgentChanges, prepareAgentWorkspace, resolveWorkspaceMode, snapshotBase } from "./worktree.js";
15
16
  import { bindRunProject, newRunId, newRunState, saveRunState } from "./run-store.js";
17
+ import { soloProject } from "./solo.js";
16
18
  import { taskMessageBody } from "./task.js";
17
19
  import { must } from "./assert.js";
18
20
  export { newRunId };
19
21
  export const RESULT_FILE = "result.md";
20
22
  /** How many times a non-conforming `done` goes back to the lead before it is accepted as `partial`. */
21
23
  export const MAX_DONE_REJECTIONS = 2;
24
+ /** How many times a malformed `ask` goes back to its sender before the lead is told instead. */
25
+ export const MAX_ASK_REJECTIONS = 2;
22
26
  /** Prompt for a repeat attempt: the earlier one failed and may have changed things already. */
23
27
  function retryPrompt(prompt, error) {
24
28
  return `${prompt}\n\n# Retry notice\nYour previous attempt at this wake-up failed${error ? ` (${error})` : ""} after possibly changing files already. Its mail was discarded. Check the current state of the repository before redoing the work, and send the mail again.`;
@@ -53,15 +57,19 @@ export class RunSession {
53
57
  attempts = new Map();
54
58
  constructor(opts) {
55
59
  const { task, resume, runDir } = opts;
60
+ if (resume?.end_reason === "waiting")
61
+ RunSession.assertAnswered(runDir, resume.run_id);
56
62
  if (!task && !resume)
57
63
  throw new Error("runTeam needs a task or a run to resume");
58
64
  const invoke = opts.invoker ?? realInvoker;
59
65
  const say = opts.log ?? ((s) => console.log(s));
60
- const cfg = opts.project.dispatcher;
66
+ const soloName = resume ? resume.solo_agent : opts.soloAgent;
67
+ const base = soloName ? soloProject(opts.project, soloName) : opts.project;
68
+ const cfg = base.dispatcher;
61
69
  const runId = path.basename(runDir);
62
70
  const layout = resume ? resume.mail_layout : "run";
63
71
  // Each run gets its own memory directory per agent and (new runs) its own mailboxes.
64
- const project = bindRunProject(opts.project, runDir, layout);
72
+ const project = bindRunProject(base, runDir, layout);
65
73
  // Parallel agents work in their own git worktrees; refuse early (before anything is written) when that cannot be done.
66
74
  const wsMode = resolveWorkspaceMode(cfg);
67
75
  if (wsMode === "worktree")
@@ -99,6 +107,7 @@ export class RunSession {
99
107
  task_summary: t.subject,
100
108
  mail_layout: layout,
101
109
  workspace_mode: wsMode,
110
+ solo_agent: soloName,
102
111
  });
103
112
  const seed = parseSteps(task.content, false);
104
113
  if (seed.length)
@@ -129,6 +138,16 @@ export class RunSession {
129
138
  this.guard = guard;
130
139
  this.state = state;
131
140
  }
141
+ /** A waiting run only continues once every unanswered question has a valid answer. */
142
+ static assertAnswered(runDir, runId) {
143
+ const reply = readAskReply(runDir);
144
+ if (!reply)
145
+ return;
146
+ const check = checkAnswers(reply);
147
+ if (!isComplete(check)) {
148
+ throw new Error(`Run ${runId} is still waiting for answers in ${askReplyPath(runDir)}:\n${describeCheck(check).map((l) => ` ${l}`).join("\n")}`);
149
+ }
150
+ }
132
151
  saveState() {
133
152
  saveRunState(this.runDir, this.state);
134
153
  }
@@ -354,6 +373,7 @@ export class RunSession {
354
373
  contract = undefined;
355
374
  }
356
375
  }
376
+ const askAccepted = this.handleAsks(route);
357
377
  for (const d of route.delivered)
358
378
  log("route", d);
359
379
  const handedOff = (from, t) => {
@@ -402,6 +422,10 @@ export class RunSession {
402
422
  this.endReason = "done";
403
423
  return;
404
424
  }
425
+ if (askAccepted) {
426
+ this.endReason = "waiting";
427
+ return;
428
+ }
405
429
  const leadIdx = batch.findIndex((a) => a.name === project.lead);
406
430
  const leadResult = leadIdx >= 0 ? results[leadIdx] : undefined;
407
431
  if (leadResult && !leadResult.ok && !leadResult.cancelled) {
@@ -409,9 +433,86 @@ export class RunSession {
409
433
  return;
410
434
  }
411
435
  }
436
+ /** The user answered: send each asker its answers as a `reply`, then mark them delivered. Safe to repeat after a crash. */
437
+ deliverAnswers() {
438
+ const { runDir, project, log } = this;
439
+ const reply = readAskReply(runDir);
440
+ if (!reply)
441
+ return;
442
+ const ids = reply.questions.filter((q) => !q.delivered).map((q) => q.id).join(",");
443
+ for (const asker of askersWithAnswers(reply)) {
444
+ deliverOnce(project, this.journal(), `ask-answer:${ids}:${asker}`, {
445
+ from: "user",
446
+ to: asker,
447
+ type: "reply",
448
+ subject: "Answers to your questions",
449
+ body: renderAnswers(reply, asker),
450
+ });
451
+ }
452
+ writeAskReply(runDir, markDelivered(reply));
453
+ log("ask-answered", { questions: ids });
454
+ }
455
+ /** Store the questions of this pass's valid `ask` mails; malformed ones go back to their sender. Returns true when the run should now wait. */
456
+ handleAsks(route) {
457
+ const { say, runDir, project, log, state } = this;
458
+ if (route.asks.length === 0)
459
+ return false;
460
+ let reply = readAskReply(runDir);
461
+ const accepted = [];
462
+ for (const a of route.asks) {
463
+ const parsed = parseAskBody(a.body);
464
+ const added = parsed.ok ? addBatch(reply, a.from, state.rounds, parsed.questions) : parsed;
465
+ if (!added.ok) {
466
+ const n = (state.ask_rejections?.[a.from] ?? 0) + 1;
467
+ state.ask_rejections = { ...state.ask_rejections, [a.from]: n };
468
+ rejectDone(a.file);
469
+ const reason = added.error;
470
+ say(` ask from ${a.from} rejected: ${reason}`);
471
+ log("ask-rejected", { from: a.from, reason, count: n });
472
+ if (n <= MAX_ASK_REJECTIONS) {
473
+ deliverOnce(project, this.journal(), `ask-reject:${a.source}`, {
474
+ from: "dispatcher",
475
+ to: a.from,
476
+ type: "failure",
477
+ subject: "Your ask was not accepted",
478
+ body: `Your \`ask\` was not accepted: ${reason}.\n\nSend it again with this shape (at most 10 questions; \`options\` and \`suggested\` are optional):\n\n<ask>\n <question id="q1">\n <text>Question?</text>\n <options><option>a</option><option>b</option></options>\n <suggested reason="why">a</suggested>\n </question>\n</ask>\n\n(Reminder ${n} of ${MAX_ASK_REJECTIONS}.)`,
479
+ });
480
+ }
481
+ else {
482
+ deliverOnce(project, this.journal(), `ask-reject:${a.source}`, {
483
+ from: "dispatcher",
484
+ to: project.lead,
485
+ type: "failure",
486
+ subject: `${a.from}'s ask was rejected ${n} times`,
487
+ body: `${a.from} sent a malformed \`ask\` rejected ${n} times (last problem: ${reason}). It was dropped. Decide without asking the user, or send the question yourself in the correct format.`,
488
+ });
489
+ }
490
+ continue;
491
+ }
492
+ reply = added.reply;
493
+ accepted.push(a.file);
494
+ }
495
+ if (!accepted.length || !reply)
496
+ return false;
497
+ if (route.done) {
498
+ for (const f of accepted)
499
+ finishDone(f);
500
+ this.note("An ask was ignored because the lead also sent done in the same round.");
501
+ return false;
502
+ }
503
+ // Questions are stored first, the outbox file moves second: a crash in between re-routes the same ask, and addBatch skips it.
504
+ writeAskReply(runDir, reply);
505
+ for (const f of accepted)
506
+ finishDone(f);
507
+ log("ask", { questions: reply.questions.filter((q) => !q.delivered).map((q) => q.id) });
508
+ say(` waiting for your answers: ${askReplyPath(runDir)}`);
509
+ return true;
510
+ }
412
511
  /** On resume: sort out what the interrupted process left behind before anything is woken. */
413
512
  recover() {
414
513
  const { say, runId, runDir, project, wsMode, log } = this;
514
+ if (this.resume?.end_reason === "waiting")
515
+ this.deliverAnswers();
415
516
  const rec = recoverRunMail(runDir, { outboxOf: (agent) => outboxDir(project, agent) });
416
517
  for (const i of rec.interrupted) {
417
518
  this.note(`${i.attempt.agent} attempt ${i.attempt.n} was interrupted${i.parked.length ? `; ${i.parked.length} output file(s) set aside in ${path.join("mail", "attempts", i.attempt.id, "outbox")}` : ""}. ` +
@@ -500,6 +601,6 @@ export class RunSession {
500
601
  this.saveState();
501
602
  if (this.pendingDoneFile)
502
603
  finishDone(this.pendingDoneFile);
503
- return { runId, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, doneMessage: this.doneMessage };
604
+ return { runId, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, doneMessage: this.doneMessage, askReplyPath: endReason === "waiting" ? askReplyPath(runDir) : undefined };
504
605
  }
505
606
  }
@@ -3,7 +3,7 @@ import type { Step } from "./format.js";
3
3
  import type { RunOutcome } from "./schema.js";
4
4
  export declare const STATE_FILE = "state.json";
5
5
  export declare const STATE_SCHEMA_VERSION = 2;
6
- export type EndReason = "done" | "idle" | "max_rounds" | "lead_failed" | "cancelled";
6
+ export type EndReason = "done" | "idle" | "max_rounds" | "lead_failed" | "cancelled" | "waiting";
7
7
  export type MailLayout = "run" | "legacy";
8
8
  export interface WakeTopic {
9
9
  from: string;
@@ -56,10 +56,14 @@ export interface RunState {
56
56
  verification?: string;
57
57
  /** How many `done` mails were sent back because they broke the completion contract. */
58
58
  done_rejections?: number;
59
+ /** How many malformed `ask` mails each agent had sent back (the limit is MAX_ASK_REJECTIONS). */
60
+ ask_rejections?: Record<string, number>;
59
61
  /** Recovery remarks (interrupted attempts whose side effects may have happened). */
60
62
  notes?: string[];
61
63
  /** "worktree": non-lead agents work in their own git worktrees. */
62
64
  workspace_mode?: "shared" | "worktree";
65
+ /** Set when the run was started with `run --agent <name>`: that member works alone (a resumed run keeps it). */
66
+ solo_agent?: string;
63
67
  /** Agents whose finished work could not be brought into the repo yet; their work is kept on their branch. */
64
68
  blocked_integrations?: {
65
69
  agent: string;
package/dist/run-store.js CHANGED
@@ -23,12 +23,13 @@ const WireState = z
23
23
  ended_at: z.string().optional(),
24
24
  rounds: nonNeg.optional(),
25
25
  max_rounds: nonNeg.optional(),
26
- end_reason: z.enum(["done", "idle", "max_rounds", "lead_failed", "cancelled"]).optional(),
27
- outcome: z.enum(["completed", "partial", "blocked", "failed", "cancelled"]).optional(),
26
+ end_reason: z.enum(["done", "idle", "max_rounds", "lead_failed", "cancelled", "waiting"]).optional(),
27
+ outcome: z.enum(["completed", "partial", "blocked", "failed", "waiting", "cancelled"]).optional(),
28
28
  outcome_note: z.string().optional(),
29
29
  verification: z.string().optional(),
30
30
  done_rejections: nonNeg.optional(),
31
31
  notes: z.array(z.string()).optional(),
32
+ ask_rejections: z.record(z.string(), nonNeg).optional(),
32
33
  sessions: z.record(z.string(), z.string()).optional(),
33
34
  output_tokens: nonNeg.optional(),
34
35
  last_wake: z.record(z.string(), z.any()).optional(),
@@ -36,6 +37,7 @@ const WireState = z
36
37
  task_summary: z.string().optional(),
37
38
  active: z.record(z.string(), z.any()).optional(),
38
39
  wakes: z.array(z.any()).optional(),
40
+ solo_agent: z.string().optional(),
39
41
  steps: z.array(z.object({ text: z.string(), done: z.boolean() })).optional(),
40
42
  })
41
43
  .passthrough();
@@ -115,5 +117,5 @@ export function outcomeOf(s) {
115
117
  return { outcome: s.outcome, verified: true };
116
118
  if (!s.end_reason)
117
119
  return undefined;
118
- return { outcome: s.end_reason === "lead_failed" ? "failed" : s.end_reason === "cancelled" ? "cancelled" : "partial", verified: false };
120
+ return { outcome: s.end_reason === "lead_failed" ? "failed" : s.end_reason === "cancelled" ? "cancelled" : s.end_reason === "waiting" ? "waiting" : "partial", verified: false };
119
121
  }
package/dist/schema.d.ts CHANGED
@@ -30,6 +30,7 @@ export declare const AgentPartial: z.ZodObject<{
30
30
  resume: z.ZodOptional<z.ZodBoolean>;
31
31
  can_message: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodArray<z.ZodString>]>>;
32
32
  can_edit_agent_md: z.ZodOptional<z.ZodBoolean>;
33
+ can_ask_user: z.ZodOptional<z.ZodBoolean>;
33
34
  owns: z.ZodOptional<z.ZodArray<z.ZodString>>;
34
35
  }, z.core.$strict>;
35
36
  export type AgentPartialT = z.infer<typeof AgentPartial>;
@@ -69,6 +70,7 @@ export declare const GlobalConfig: z.ZodObject<{
69
70
  resume: z.ZodOptional<z.ZodBoolean>;
70
71
  can_message: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodArray<z.ZodString>]>>;
71
72
  can_edit_agent_md: z.ZodOptional<z.ZodBoolean>;
73
+ can_ask_user: z.ZodOptional<z.ZodBoolean>;
72
74
  owns: z.ZodOptional<z.ZodArray<z.ZodString>>;
73
75
  }, z.core.$strict>>>;
74
76
  }, z.core.$strict>;
@@ -112,6 +114,7 @@ export declare const ProjectConfig: z.ZodObject<{
112
114
  resume: z.ZodOptional<z.ZodBoolean>;
113
115
  can_message: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodArray<z.ZodString>]>>;
114
116
  can_edit_agent_md: z.ZodOptional<z.ZodBoolean>;
117
+ can_ask_user: z.ZodOptional<z.ZodBoolean>;
115
118
  owns: z.ZodOptional<z.ZodArray<z.ZodString>>;
116
119
  }, z.core.$strict>>>;
117
120
  }, z.core.$strict>;
@@ -128,6 +131,6 @@ export interface DispatcherSettings {
128
131
  }
129
132
  export declare const DISPATCHER_DEFAULTS: DispatcherSettings;
130
133
  /** How a run turned out, independent of why it stopped (`EndReason`). Only "completed" means success. */
131
- export declare const RUN_OUTCOMES: readonly ["completed", "partial", "blocked", "failed", "cancelled"];
134
+ export declare const RUN_OUTCOMES: readonly ["completed", "partial", "blocked", "failed", "waiting", "cancelled"];
132
135
  export type RunOutcome = (typeof RUN_OUTCOMES)[number];
133
- export declare function exitCodeForOutcome(outcome: RunOutcome): 0 | 1 | 2 | 130;
136
+ export declare function exitCodeForOutcome(outcome: RunOutcome): 0 | 1 | 2 | 3 | 130;
package/dist/schema.js CHANGED
@@ -34,6 +34,7 @@ export const AgentPartial = z
34
34
  resume: z.boolean().optional(),
35
35
  can_message: z.union([z.literal("all"), z.array(z.string())]).optional(),
36
36
  can_edit_agent_md: z.boolean().optional(),
37
+ can_ask_user: z.boolean().optional(),
37
38
  owns: z.array(z.string()).optional(),
38
39
  })
39
40
  .strict();
@@ -71,13 +72,15 @@ export const DISPATCHER_DEFAULTS = {
71
72
  log_max_bytes: 8 * 1024 * 1024,
72
73
  };
73
74
  /** How a run turned out, independent of why it stopped (`EndReason`). Only "completed" means success. */
74
- export const RUN_OUTCOMES = ["completed", "partial", "blocked", "failed", "cancelled"];
75
+ export const RUN_OUTCOMES = ["completed", "partial", "blocked", "failed", "waiting", "cancelled"];
75
76
  export function exitCodeForOutcome(outcome) {
76
77
  switch (outcome) {
77
78
  case "completed":
78
79
  return 0;
79
80
  case "failed":
80
81
  return 1;
82
+ case "waiting":
83
+ return 3;
81
84
  case "cancelled":
82
85
  return 130;
83
86
  default:
package/dist/solo.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { type ResolvedProject } from "./config.js";
2
+ /**
3
+ * The project as `run --agent <name>` sees it: `name` is the lead of a team of one, so the task goes to it and its `done` ends the run.
4
+ * It cannot mail anyone, ask the user, edit any AGENT.md or share the repo with a parallel agent. The other members stay listed, only
5
+ * so that their files stay protected; nobody mails them, so they are never woken.
6
+ * `globalAgents` (names in team.yaml) only improves the error message.
7
+ */
8
+ export declare function soloProject(project: ResolvedProject, name: string, hints?: {
9
+ globalAgents?: string[];
10
+ }): ResolvedProject;
11
+ /** `project` with only `name` listed: what a preflight of a solo run needs to look at. */
12
+ export declare function onlyAgent(project: ResolvedProject, name: string): ResolvedProject;
package/dist/solo.js ADDED
@@ -0,0 +1,28 @@
1
+ import { ConfigError } from "./config.js";
2
+ /**
3
+ * The project as `run --agent <name>` sees it: `name` is the lead of a team of one, so the task goes to it and its `done` ends the run.
4
+ * It cannot mail anyone, ask the user, edit any AGENT.md or share the repo with a parallel agent. The other members stay listed, only
5
+ * so that their files stay protected; nobody mails them, so they are never woken.
6
+ * `globalAgents` (names in team.yaml) only improves the error message.
7
+ */
8
+ export function soloProject(project, name, hints = {}) {
9
+ const a = project.agents[name];
10
+ if (!a) {
11
+ const call = hints.globalAgents?.includes(name) ? ` "${name}" is in the global library: use \`agent-lyceum call ${name}\` to run it outside the project.` : "";
12
+ throw new ConfigError(`Agent "${name}" is not a member of project "${project.name}" (members: ${Object.keys(project.agents).join(", ")}).${call}`);
13
+ }
14
+ return {
15
+ ...project,
16
+ solo: "run",
17
+ lead: name,
18
+ dispatcher: { ...project.dispatcher, max_parallel: 1, workspace_mode: "shared" },
19
+ agents: { ...project.agents, [name]: { ...a, canMessage: [], canAskUser: false, canEditAgentMd: false, owns: [] } },
20
+ };
21
+ }
22
+ /** `project` with only `name` listed: what a preflight of a solo run needs to look at. */
23
+ export function onlyAgent(project, name) {
24
+ const a = project.agents[name];
25
+ if (!a)
26
+ throw new ConfigError(`Agent "${name}" is not a member of project "${project.name}" (members: ${Object.keys(project.agents).join(", ")}).`);
27
+ return { ...project, agents: { [name]: a } };
28
+ }
package/dist/status.d.ts CHANGED
@@ -44,7 +44,7 @@ export interface RunReport {
44
44
  run_id: string;
45
45
  dir: string;
46
46
  /** running: its dispatcher is alive; interrupted: it died mid-run; ended: it stopped (see end_reason). */
47
- state: "running" | "interrupted" | "ended";
47
+ state: "running" | "interrupted" | "ended" | "waiting";
48
48
  live: boolean;
49
49
  end_reason?: EndReason;
50
50
  outcome?: RunOutcome;
@@ -83,6 +83,12 @@ export interface RunReport {
83
83
  ok: boolean;
84
84
  error?: string;
85
85
  }>;
86
+ /** Present while the run waits for the user: the file to fill in and how many questions are still open. */
87
+ ask?: {
88
+ path: string;
89
+ total: number;
90
+ unanswered: number;
91
+ };
86
92
  }
87
93
  export interface AgentReport {
88
94
  name: string;
@@ -104,6 +110,8 @@ export interface StatusReport {
104
110
  };
105
111
  agents: AgentReport[];
106
112
  run?: RunReport;
113
+ /** Hints that a `memory tidy` might help. Only a suggestion; nothing runs it. */
114
+ memory_advice?: string[];
107
115
  }
108
116
  export interface TaskListReport {
109
117
  schema_version: 1;
package/dist/status.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { askReplyPath, readAskReply } from "./ask-reply.js";
3
4
  import { RESULT_FILE } from "./dispatcher.js";
4
5
  import { bindRunProject, loadRunState, outcomeOf } from "./run-store.js";
5
6
  import { listUnread } from "./mailbox.js";
7
+ import { tidyAdvice } from "./memory-tidy.js";
6
8
  import { inspectProjectLock, lockHolderAlive } from "./project-lock.js";
7
9
  export function latestRun(project) {
8
10
  const root = project.paths.runs;
@@ -76,8 +78,10 @@ function alive(pid) {
76
78
  return e.code === "EPERM";
77
79
  }
78
80
  }
79
- const OUTCOME_ZH = { completed: "完成", partial: "部分完成", blocked: "受阻", failed: "失敗", cancelled: "已取消" };
81
+ const OUTCOME_ZH = { completed: "完成", partial: "部分完成", blocked: "受阻", failed: "失敗", waiting: "等待回答", cancelled: "已取消" };
80
82
  export function runStateLabel(s, live = alive(s.pid)) {
83
+ if (s.end_reason === "waiting")
84
+ return "等待回答";
81
85
  if (s.end_reason) {
82
86
  const o = outcomeOf(s);
83
87
  // Runs from before outcomes existed: keep what they recorded, but never present it as a checked success.
@@ -155,10 +159,23 @@ export function buildRunReport(run, project, opts = {}) {
155
159
  const result = path.join(run.dir, RESULT_FILE);
156
160
  const hasResult = detail && s.end_reason === "done" && fs.existsSync(result);
157
161
  const cur = s.steps?.find((x) => !x.done);
162
+ let ask;
163
+ if (s.end_reason === "waiting") {
164
+ try {
165
+ const r = readAskReply(run.dir);
166
+ if (r) {
167
+ const open = r.questions.filter((q) => !q.delivered);
168
+ ask = { path: askReplyPath(run.dir), total: open.length, unanswered: open.filter((q) => !q.answer?.value.trim()).length };
169
+ }
170
+ }
171
+ catch {
172
+ ask = { path: askReplyPath(run.dir), total: 0, unanswered: 0 };
173
+ }
174
+ }
158
175
  return {
159
176
  run_id: s.run_id,
160
177
  dir: run.dir,
161
- state: s.end_reason ? "ended" : live ? "running" : "interrupted",
178
+ state: s.end_reason === "waiting" ? "waiting" : s.end_reason ? "ended" : live ? "running" : "interrupted",
162
179
  live,
163
180
  end_reason: s.end_reason,
164
181
  outcome: o?.outcome,
@@ -186,6 +203,7 @@ export function buildRunReport(run, project, opts = {}) {
186
203
  mail_layout: s.mail_layout,
187
204
  workspace_mode: s.workspace_mode,
188
205
  last_wake: s.last_wake,
206
+ ...(ask ? { ask } : {}),
189
207
  };
190
208
  }
191
209
  const projectInfo = (p) => ({ name: p.name, dir: p.dir, lead: p.lead });
@@ -203,6 +221,13 @@ export function buildStatusReport(base, runId) {
203
221
  // Mail is per run: count the unread mail of the run being shown.
204
222
  const project = run ? bindRunProject(base, run.dir, run.state.mail_layout) : base;
205
223
  const report = run ? buildRunReport(run, base) : undefined;
224
+ let advice = [];
225
+ try {
226
+ advice = tidyAdvice(base).map((a) => `${a.agent}: ${a.reason}`);
227
+ }
228
+ catch {
229
+ /* a hint only: never let it break status */
230
+ }
206
231
  return {
207
232
  schema_version: 1,
208
233
  project: projectInfo(base),
@@ -214,6 +239,7 @@ export function buildStatusReport(base, runId) {
214
239
  last_wake: run?.state.last_wake[a.name],
215
240
  })),
216
241
  run: report,
242
+ ...(advice.length ? { memory_advice: advice } : {}),
217
243
  };
218
244
  }
219
245
  /** Every run of the project, newest first. */
@@ -274,6 +300,8 @@ function summaryBlock(run, now, c) {
274
300
  const lastWake = (s.wakes ?? []).at(-1);
275
301
  if (lastWake)
276
302
  lines.push(`${L("上一次:")}#${lastWake.round} ${c.cyan(lastWake.agent)} ${okText(c, lastWake.ok)} → ${sentText(lastWake.sent)}`);
303
+ if (s.ask)
304
+ lines.push(`${L("等你回答:")}${s.ask.unanswered}/${s.ask.total} 題未答 → ${s.ask.path}`, c.dim(` 填完後執行 agent-lyceum answer ${s.run_id}(或 resume)`));
277
305
  lines.push(`${L("流程:")}${flowLine(s, c)}`);
278
306
  return lines;
279
307
  }
@@ -401,5 +429,7 @@ export function formatStatusReport(report, now = Date.now(), color = false) {
401
429
  const lines = [`${c.bold("專案:")}${report.project.name} ${c.dim(`(repo:${report.project.dir})`)}`, "", ...summaryBlock(run, now, c), "", ...table];
402
430
  if (run)
403
431
  lines.push("", `輸出 tokens:${tokens(run.output_tokens)}`);
432
+ if (report.memory_advice?.length)
433
+ lines.push("", `記憶:${report.memory_advice.join(";")}(建議執行 agent-lyceum memory tidy -p ${report.project.name};status 只提示,不會自動整理)`);
404
434
  return lines.join("\n");
405
435
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Config + dispatcher for teams of Claude Code / Codex agents with scoped context and file-based mailboxes",
5
5
  "keywords": [
6
6
  "ai",
@@ -58,7 +58,7 @@
58
58
  "@types/node": "^20.19.43",
59
59
  "tsx": "^4.19.0",
60
60
  "typescript": "^7.0.2",
61
- "vite": "^7.3.7",
61
+ "vite": "^8.3.3",
62
62
  "vitest": "^4.1.11"
63
63
  }
64
64
  }