agent-lyceum 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/status.js CHANGED
@@ -5,7 +5,9 @@ import { RESULT_FILE } from "./dispatcher.js";
5
5
  import { bindRunProject, loadRunState, outcomeOf, roundsLabel, runLabel } from "./run-store.js";
6
6
  import { candidatesFor } from "./fallback.js";
7
7
  import { briefOf, digestOf } from "./format.js";
8
- import { listUnread } from "./mailbox.js";
8
+ import { PENDING_DIR, listUnread, readMessageNoFollow } from "./mailbox.js";
9
+ import { parseSlotName } from "./instances.js";
10
+ import { inboxDir } from "./policy.js";
9
11
  import { tidyAdvice } from "./memory-tidy.js";
10
12
  import { inspectRunLock, lockHolderAlive } from "./project-lock.js";
11
13
  /** The run to show: the newest one still running (a resumed run keeps its old id, so newer runs may sit above it), else the newest run. */
@@ -154,7 +156,7 @@ function stepsLine(steps, c, finished = false) {
154
156
  return `目前:${clip(cur.text, 60)}`;
155
157
  }
156
158
  /** Order the dispatcher will wake agents: the lead first, otherwise the oldest mail. */
157
- function queueOf(project) {
159
+ function queueOf(project, instances) {
158
160
  const rows = Object.values(project.agents)
159
161
  .map((a) => ({ agent: a.name, msg: listUnread(project, a.name)[0] }))
160
162
  .flatMap((r) => (r.msg ? [{ agent: r.agent, msg: r.msg }] : []));
@@ -163,7 +165,7 @@ function queueOf(project) {
163
165
  return x.agent === project.lead ? -1 : 1;
164
166
  return path.basename(x.msg.file).localeCompare(path.basename(y.msg.file));
165
167
  });
166
- return rows.map((r) => ({
168
+ const items = rows.map((r) => ({
167
169
  agent: r.agent,
168
170
  from: r.msg.meta.from,
169
171
  type: r.msg.meta.type,
@@ -171,14 +173,44 @@ function queueOf(project) {
171
173
  brief: briefOf(r.msg.body),
172
174
  digest: digestOf(r.msg.meta.type, r.msg.body),
173
175
  }));
176
+ return [...items, ...heldQueue(project, instances)];
174
177
  }
178
+ /** Mail the assigner still holds for roles with instances, oldest first per role. Unreadable files are skipped. */
179
+ function heldQueue(project, instances) {
180
+ const out = [];
181
+ for (const role of Object.keys(instances?.roles ?? {}).sort()) {
182
+ const dir = path.join(inboxDir(project, role), PENDING_DIR);
183
+ let files = [];
184
+ try {
185
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith(".")).sort();
186
+ }
187
+ catch {
188
+ continue;
189
+ }
190
+ for (const f of files) {
191
+ try {
192
+ const m = readMessageNoFollow(path.join(dir, f));
193
+ out.push({ agent: role, held: true, from: m.meta.from, type: m.meta.type, subject: m.meta.subject, brief: briefOf(m.body), digest: digestOf(m.meta.type, m.body) });
194
+ }
195
+ catch {
196
+ /* a half-written file: the next poll sees it */
197
+ }
198
+ }
199
+ }
200
+ return out;
201
+ }
202
+ /** How an agent is named on the text pages: an instance of a role shows as `role #k`. */
203
+ const shownName = (name, slots) => {
204
+ const i = parseSlotName(name);
205
+ return i && slots?.[i.role]?.[i.k - 1] === name ? `${i.role} #${i.k}` : name;
206
+ };
175
207
  /** One run as `status` sees it. Without `project`, liveness falls back to the recorded pid and there is no queue. */
176
208
  export function buildRunReport(run, project, opts = {}) {
177
209
  const s = run.state;
178
210
  const detail = opts.detail ?? true;
179
211
  const live = runIsAlive(s, project) && !s.end_reason;
180
212
  const o = outcomeOf(s);
181
- const bound = project ? bindRunProject(project, run.dir, s.mail_layout) : undefined;
213
+ const bound = project ? bindRunProject(project, run.dir, s.mail_layout, s.instances) : undefined;
182
214
  const result = path.join(run.dir, RESULT_FILE);
183
215
  const hasResult = detail && s.end_reason === "done" && fs.existsSync(result);
184
216
  const cur = s.steps?.find((x) => !x.done);
@@ -221,7 +253,7 @@ export function buildRunReport(run, project, opts = {}) {
221
253
  current_step: cur?.text ?? null,
222
254
  active: s.active,
223
255
  // mail still waiting is what `resume` will pick up, so it is shown for every run that did not finish with a done
224
- queue: detail && bound && s.end_reason !== "done" ? queueOf(bound) : [],
256
+ queue: detail && bound && s.end_reason !== "done" ? queueOf(bound, s.instances) : [],
225
257
  wakes: s.wakes,
226
258
  notes: s.notes ?? [],
227
259
  blocked_integrations: s.blocked_integrations ?? [],
@@ -230,6 +262,7 @@ export function buildRunReport(run, project, opts = {}) {
230
262
  mail_layout: s.mail_layout,
231
263
  workspace_mode: s.workspace_mode,
232
264
  last_wake: s.last_wake,
265
+ ...(s.instances ? { instances: s.instances.roles } : {}),
233
266
  ...(ask ? { ask } : {}),
234
267
  };
235
268
  }
@@ -277,7 +310,7 @@ export function buildStatusReport(base, runId, now = Date.now()) {
277
310
  else
278
311
  run = currentRun(base);
279
312
  // Mail is per run: count the unread mail of the run being shown.
280
- const project = run ? bindRunProject(base, run.dir, run.state.mail_layout) : base;
313
+ const project = run ? bindRunProject(base, run.dir, run.state.mail_layout, run.state.instances) : base;
281
314
  const report = run ? buildRunReport(run, base) : undefined;
282
315
  let advice = [];
283
316
  try {
@@ -293,6 +326,7 @@ export function buildStatusReport(base, runId, now = Date.now()) {
293
326
  name: a.name,
294
327
  runtime: a.runtime,
295
328
  lead: a.name === project.lead,
329
+ ...(a.instance ? { role: a.instance.role, instance: a.instance.k } : {}),
296
330
  unread: listUnread(project, a.name).length,
297
331
  can_message: (a.canMessage === "all" ? Object.keys(project.agents) : a.canMessage).filter((n) => n !== a.name),
298
332
  last_wake: run?.state.last_wake[a.name],
@@ -309,10 +343,10 @@ export function buildTaskListReport(project) {
309
343
  // ---- text pages ----
310
344
  /** Agents in wake order, newest last; the ones working now are bracketed. */
311
345
  function flowLine(r, c) {
312
- const names = (r.wakes ?? []).map((w) => w.agent);
346
+ const names = (r.wakes ?? []).map((w) => shownName(w.agent, r.instances));
313
347
  const shown = names.slice(-7);
314
348
  const parts = shown.map((n) => c.cyan(n));
315
- const activeNames = r.live ? Object.keys(r.active ?? {}) : [];
349
+ const activeNames = r.live ? Object.keys(r.active ?? {}).map((n) => shownName(n, r.instances)) : [];
316
350
  const out = (names.length > shown.length ? "… → " : "") + parts.join(" → ");
317
351
  if (!activeNames.length)
318
352
  return out || "(尚無)";
@@ -320,7 +354,7 @@ function flowLine(r, c) {
320
354
  }
321
355
  /** The state label of a report: an outcome that was only inferred (old runs) is not shown as a checked result. */
322
356
  const reportLabel = (r) => runStateLabel({ end_reason: r.end_reason, outcome: r.outcome_verified ? r.outcome : undefined }, r.live);
323
- const mailText = (m) => `來自 ${m.from} 的 ${m.type}:「${clip(m.subject, 60)}」`;
357
+ const mailText = (m, slots) => `來自 ${shownName(m.from, slots)} 的 ${m.type}:「${clip(m.subject, 60)}」${m.held ? "(保留中,等待空閒實例)" : ""}`;
324
358
  const taskText = (s) => s.task_summary ?? (s.task_source === "file" ? `檔案 ${s.task_path}` : "文字");
325
359
  /** The readable top block: what is happening, what is next, how far along the task is. */
326
360
  function summaryBlock(run, now, c) {
@@ -338,7 +372,7 @@ function summaryBlock(run, now, c) {
338
372
  const active = Object.entries(s.active ?? {});
339
373
  if (active.length) {
340
374
  for (const [i, [agent, a]] of active.entries()) {
341
- lines.push(`${i === 0 ? L("現在:") : " "}${c.cyan(agent)} ${live ? c.green(`工作中 ${since(a.since, now)}`) : c.yellow("曾在工作(已中斷)")},處理 ${handlingText(a.handling)}`);
375
+ lines.push(`${i === 0 ? L("現在:") : " "}${c.cyan(shownName(agent, s.instances))} ${live ? c.green(`工作中 ${since(a.since, now)}`) : c.yellow("曾在工作(已中斷)")},處理 ${handlingText(a.handling)}`);
342
376
  }
343
377
  }
344
378
  else
@@ -346,7 +380,7 @@ function summaryBlock(run, now, c) {
346
380
  const queue = s.queue;
347
381
  if (queue.length) {
348
382
  for (const [i, q] of queue.slice(0, 3).entries())
349
- lines.push(`${i === 0 ? L("下一個:") : " "}${c.cyan(q.agent)} ← ${mailText(q)}`);
383
+ lines.push(`${i === 0 ? L("下一個:") : " "}${c.cyan(q.agent)} ← ${mailText(q, s.instances)}`);
350
384
  if (queue.length > 3)
351
385
  lines.push(c.dim(` … 還有 ${queue.length - 3} 筆`));
352
386
  }
@@ -387,10 +421,10 @@ function describeRun(s, now, c, limit = Infinity) {
387
421
  if (wakes.length > limit)
388
422
  lines.push(c.dim(` … 省略較早的 ${wakes.length - limit} 筆(不帶 --monitor 可看完整紀錄)`));
389
423
  for (const w of limit < wakes.length ? wakes.slice(-limit) : wakes) {
390
- lines.push(` #${w.round} ${c.cyan(w.agent)} ${okText(c, w.ok)} (${Math.round(w.duration_ms / 1000)}s, ${tokens(w.output_tokens)} tok) — ${handlingText(w.handling)}`, ` → ${sentText(w.sent)}`);
424
+ lines.push(` #${w.round} ${c.cyan(shownName(w.agent, s.instances))} ${okText(c, w.ok)} (${Math.round(w.duration_ms / 1000)}s, ${tokens(w.output_tokens)} tok) — ${handlingText(w.handling)}`, ` → ${sentText(w.sent)}`);
391
425
  }
392
426
  for (const [agent, a] of Object.entries(s.active ?? {})) {
393
- lines.push(` #${a.round} ${c.cyan(agent)} ${c.green(`工作中 ${since(a.since, now)}`)} — ${handlingText(a.handling)}`);
427
+ lines.push(` #${a.round} ${c.cyan(shownName(agent, s.instances))} ${c.green(`工作中 ${since(a.since, now)}`)} — ${handlingText(a.handling)}`);
394
428
  }
395
429
  return lines;
396
430
  }
@@ -486,7 +520,7 @@ export function formatStatusReport(report, now = Date.now(), color = false) {
486
520
  const rows = [header];
487
521
  for (const a of report.agents) {
488
522
  const lw = a.last_wake;
489
- rows.push([a.name + (a.lead ? " (lead)" : ""), a.runtime ?? "?", String(a.unread), lw ? `${lw.ok ? "成功" : "失敗"} ${lw.at}` : "-"]);
523
+ rows.push([(a.instance && a.instance >= 2 && a.role ? `${a.role} #${a.instance}` : a.name) + (a.lead ? " (lead)" : ""), a.runtime ?? "?", String(a.unread), lw ? `${lw.ok ? "成功" : "失敗"} ${lw.at}` : "-"]);
490
524
  }
491
525
  const w = header.map((_, i) => Math.max(...rows.map((r) => cols(r[i] ?? ""))));
492
526
  // Pad on the plain text first so ANSI codes never skew the column widths.
@@ -81,6 +81,7 @@ export declare function snapshotHead(project: ResolvedProject, runId: string): s
81
81
  * `from`: the checkout to snapshot — the lead's worktree in a concurrent run; by default your repo's working tree.
82
82
  */
83
83
  export declare function snapshotBase(project: ResolvedProject, runId: string, n: number, from?: AgentWorkspace): string;
84
+ export declare const workspaceRoot: (project: ResolvedProject, runId: string, agent: string) => string;
84
85
  /**
85
86
  * The agent's own checkout of the snapshot `baseRef`, at `<project home>/worktrees/<run>/<agent>`.
86
87
  * An existing checkout is reused: untouched, when it already is on this snapshot (resume) or holds unfinished
@@ -144,6 +145,11 @@ export declare function integrateAgentChanges(project: ResolvedProject, changes:
144
145
  * Throws when the metadata predates recording the git dir and the git dir cannot be told for certain.
145
146
  */
146
147
  export declare function existingAgentWorkspace(project: ResolvedProject, runId: string, agent: string): AgentWorkspace | undefined;
148
+ /**
149
+ * The git dir recorded in an agent's worktree metadata, read-only (never completes or rewrites legacy metadata). undefined when there
150
+ * is no readable metadata or it predates recording the git dir.
151
+ */
152
+ export declare function recordedGitDir(project: ResolvedProject, runId: string, agent: string): string | undefined;
147
153
  export interface WorkspaceWork {
148
154
  agent: string;
149
155
  root: string;
package/dist/worktree.js CHANGED
@@ -290,7 +290,7 @@ export function snapshotBase(project, runId, n, from) {
290
290
  fs.rmSync(index, { force: true });
291
291
  }
292
292
  }
293
- const workspaceRoot = (project, runId, agent) => path.join(project.paths.root, "worktrees", runId, agent);
293
+ export const workspaceRoot = (project, runId, agent) => path.join(project.paths.root, "worktrees", runId, agent);
294
294
  /** The user's repo's git common dir, as a real absolute path. Asked of the user's repo, never of a worktree. */
295
295
  const commonDirOf = (top) => fs.realpathSync(git(top, ["rev-parse", "--path-format=absolute", "--git-common-dir"]));
296
296
  /** The admin dirs under `<commonDir>/worktrees/` whose `gitdir` file names `<root>/.git` (the file may hold a relative path). */
@@ -594,6 +594,19 @@ export function existingAgentWorkspace(project, runId, agent) {
594
594
  }
595
595
  return fs.existsSync(root) ? describeWorkspace(project, root, agent, meta) : undefined;
596
596
  }
597
+ /**
598
+ * The git dir recorded in an agent's worktree metadata, read-only (never completes or rewrites legacy metadata). undefined when there
599
+ * is no readable metadata or it predates recording the git dir.
600
+ */
601
+ export function recordedGitDir(project, runId, agent) {
602
+ try {
603
+ const meta = JSON.parse(fs.readFileSync(`${workspaceRoot(project, runId, agent)}.json`, "utf8"));
604
+ return typeof meta.gitDir === "string" && meta.gitDir ? meta.gitDir : undefined;
605
+ }
606
+ catch {
607
+ return undefined;
608
+ }
609
+ }
597
610
  /** What is in an agent's worktree that exists nowhere else: uncommitted changes, and commits whose files are not in the main repo. */
598
611
  export function inspectWorkspace(project, runId, agent, target) {
599
612
  const root = workspaceRoot(project, runId, agent);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.25.0",
3
+ "version": "0.26.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",