@kylecheng3146/agent-ops 0.7.0 → 0.7.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 CHANGED
@@ -81,9 +81,14 @@ header; the viewer badge reads “You” in English and “你” in Chinese. Th
81
81
  Supervisor movement and character animation run locally without model requests.
82
82
  Completed rooms move to Recently completed and remain for at most two hours.
83
83
  The page reads agent-ops state and `git diff` paths only, never transcripts or file
84
- contents. When enabled, managed ordinary sessions start the shared repository server
85
- automatically; a newly started Office opens the browser once, and subsequent
86
- sessions reuse it. The server exits after ten minutes without active runs or
84
+ contents. One Office serves every repository you work in: each Office-enabled
85
+ repository registers itself when its sessions report, rooms wear their
86
+ repository's nameplate, and a header menu filters the building to one
87
+ repository. Repositories that are removed, opt out, or stay quiet for two hours
88
+ leave the building. When enabled, managed ordinary sessions start your one
89
+ server automatically; a newly started Office opens the browser once, and
90
+ subsequent sessions in any repository reuse it. A finished worktree's room
91
+ moves to Recently completed as soon as `worktree finish` merges it. The server exits after ten minutes without active runs or
87
92
  ordinary sessions. Ordinary-session presence follows hook activity; a crashed
88
93
  or silent session expires after 30 minutes without an event. Office failures
89
94
  remain advisory and never block session work.
@@ -203,17 +203,23 @@ async function preflightReview(options, context, sourceFingerprint) {
203
203
  options.gitRunner === undefined) {
204
204
  return { ok: false, reason: "stale-verification" };
205
205
  }
206
- if (context.criteria.some((criterion) => !options.config.verification.commands.some(({ id, required }) => required && (criterion.verifierIds ?? []).includes(id)))) {
206
+ const typed = (criterion) => {
207
+ const owner = context.criterionOwners?.get(criterion.id);
208
+ return context.records.find(r => r.task.id === (owner?.taskId ?? context.taskId))?.task.criteria
209
+ .find(c => c.id === (owner?.criterionId ?? criterion.id))?.acceptance !== undefined;
210
+ };
211
+ // A typed criterion (review-only included) is held to every required
212
+ // repository command below, so only a legacy one must name its own.
213
+ if (context.criteria.some((criterion) => !typed(criterion) && !options.config.verification.commands.some(({ id, required }) => required && (criterion.verifierIds ?? []).includes(id)))) {
207
214
  return { ok: false, reason: "missing-verification-evidence" };
208
215
  }
209
216
  const configHash = calculateConfigHash(options.config);
210
217
  const commands = [];
211
218
  for (const criterion of context.criteria) {
212
219
  const owner = context.criterionOwners?.get(criterion.id);
213
- const typed = context.records.find(r => r.task.id === (owner?.taskId ?? context.taskId))?.task.criteria
214
- .find(c => c.id === (owner?.criterionId ?? criterion.id))?.acceptance !== undefined;
220
+ const isTyped = typed(criterion);
215
221
  for (const commandId of new Set([...(criterion.verifierIds ?? []),
216
- ...(typed ? options.config.verification.commands.filter(c => c.required).map(c => c.id) : [])])) {
222
+ ...(isTyped ? options.config.verification.commands.filter(c => c.required).map(c => c.id) : [])])) {
217
223
  const command = options.config.verification.commands.find((candidate) => candidate.id === commandId);
218
224
  if (command === undefined) {
219
225
  return { ok: false, reason: "missing-verification-evidence" };
@@ -36,7 +36,7 @@ export const DEFAULT_CONFIG = {
36
36
  */
37
37
  export function projectConfigRoot(root) {
38
38
  try {
39
- const common = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
39
+ const common = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }).trim();
40
40
  if (basename(common) !== ".git") {
41
41
  return root;
42
42
  }
@@ -184,7 +184,8 @@ export function repositoryRemoteUrl(root) {
184
184
  try {
185
185
  return execFileSync("git", ["config", "--get", "remote.origin.url"], {
186
186
  cwd: root,
187
- encoding: "utf8"
187
+ encoding: "utf8",
188
+ windowsHide: true
188
189
  }).trim();
189
190
  }
190
191
  catch {
@@ -214,7 +215,7 @@ export async function repositoryTrust(root, config, cliVersion) {
214
215
  export async function runPolicyContext(root) {
215
216
  let common;
216
217
  try {
217
- common = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
218
+ common = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }).trim();
218
219
  }
219
220
  catch {
220
221
  return null;
@@ -234,8 +235,8 @@ export async function runPolicyContext(root) {
234
235
  let proof = false;
235
236
  if (proofProcess != null && String(proofProcess.processId) === process.env.AGENT_OPS_RUN_PROOF_PID) {
236
237
  try {
237
- proof = execFileSync("ps", ["-o", "pgid=", "-p", String(process.pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() === String(proofProcess.processId) &&
238
- execFileSync("ps", ["-o", "lstart=", "-p", String(proofProcess.processId)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() === proofProcess.processIdentity;
238
+ proof = execFileSync("ps", ["-o", "pgid=", "-p", String(process.pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }).trim() === String(proofProcess.processId) &&
239
+ execFileSync("ps", ["-o", "lstart=", "-p", String(proofProcess.processId)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }).trim() === proofProcess.processIdentity;
239
240
  }
240
241
  catch {
241
242
  proof = false;
@@ -1,14 +1,16 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { basename, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { rm } from "node:fs/promises";
3
5
  import { createHash } from "node:crypto";
4
6
  import { spawn } from "node:child_process";
5
- import { collectOfficeInput } from "../../../runtime/src/office/collect.js";
6
- import { claimOffice, createOfficeServer, ensureOffice, readLiveOffice, officeUrl, releaseOffice } from "../../../runtime/src/office/server.js";
7
- import { buildOfficeSnapshot } from "../../../runtime/src/office/snapshot.js";
7
+ import { collectOfficeBuilding } from "../../../runtime/src/office/collect.js";
8
+ import { claimOffice, createOfficeServer, ensureOffice, legacyOfficeHome, officeRecordPath, readLiveOffice, officeUrl, releaseOffice, userOfficeHome } from "../../../runtime/src/office/server.js";
9
+ import { registerOfficeRepo } from "../../../runtime/src/office/repos.js";
8
10
  import { recordOfficeSession } from "../../../runtime/src/office/sessions.js";
9
11
  import { resolveCheckouts } from "../../../runtime/src/parallel/service.js";
10
12
  import { createLaunchdDescriptor, LaunchdController } from "../../../runtime/src/run/macOS.js";
11
- import { ensurePrivateDirectory } from "../../../runtime/src/security/permissions.js";
13
+ import { ensurePrivateDirectory, readPrivateFile } from "../../../runtime/src/security/permissions.js";
12
14
  import { worktreeDependencies } from "./parallel-deps.js";
13
15
  import { normalizeShellHookEvent } from "../../../runtime/src/hooks/shell.js";
14
16
  import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
@@ -110,23 +112,22 @@ export async function observeOfficeSession(options) {
110
112
  if (!(await officeEnabled(options.root)))
111
113
  return;
112
114
  const { mainRoot, commonDir } = await checkouts(options.root);
113
- if (identity !== undefined) {
114
- await recordOfficeSession({
115
- sessionId: identity,
116
- harness: options.harness,
117
- projectRoot: mainRoot,
118
- commonDir,
119
- event: officeEvent(options.event),
120
- ...(options.agentId === undefined ? {} : { agentId: options.agentId }),
121
- ...(process.env.AGENT_OPS_RUN_ID === undefined ? {} : { runId: process.env.AGENT_OPS_RUN_ID }),
122
- ...(process.env.AGENT_OPS_WORKER_ID === undefined ? {} : { workerId: process.env.AGENT_OPS_WORKER_ID }),
123
- ...(process.env.AGENT_OPS_TASK_ID === undefined ? {} : { taskId: process.env.AGENT_OPS_TASK_ID }),
124
- ...(options.phase === undefined ? {} : { phase: options.phase }),
125
- host: options.harness
126
- });
127
- }
115
+ await recordOfficeSession({
116
+ sessionId: identity,
117
+ harness: options.harness,
118
+ projectRoot: mainRoot,
119
+ commonDir,
120
+ event: officeEvent(options.event),
121
+ ...(options.agentId === undefined ? {} : { agentId: options.agentId }),
122
+ ...(process.env.AGENT_OPS_RUN_ID === undefined ? {} : { runId: process.env.AGENT_OPS_RUN_ID }),
123
+ ...(process.env.AGENT_OPS_WORKER_ID === undefined ? {} : { workerId: process.env.AGENT_OPS_WORKER_ID }),
124
+ ...(process.env.AGENT_OPS_TASK_ID === undefined ? {} : { taskId: process.env.AGENT_OPS_TASK_ID }),
125
+ ...(options.phase === undefined ? {} : { phase: options.phase }),
126
+ host: options.harness
127
+ });
128
+ await registerOfficeRepo(officeHome(), { mainRoot, commonDir });
128
129
  // A resumed session may outlive the ten-minute idle shutdown. Reusing the
129
- // same ensure path revives that repository server without creating a tab
130
+ // same ensure path revives the user's server without creating a tab
130
131
  // unless this call actually wins a new-server start claim.
131
132
  if (options.event !== "Stop")
132
133
  await ensureBackgroundOffice(options.root, undefined, openOfficeBrowser);
@@ -138,23 +139,49 @@ export async function observeOfficeSession(options) {
138
139
  async function checkouts(cwd) {
139
140
  return await resolveCheckouts(worktreeDependencies(), cwd);
140
141
  }
141
- /** Serve the office until no run has been active for the idle window. */
142
+ /** The user's one Office; AGENT_OPS_HOME moves it with the rest of agent-ops' user state. */
143
+ export function officeHome() {
144
+ return userOfficeHome(process.env.AGENT_OPS_HOME ?? homedir());
145
+ }
146
+ const officeLabel = (key) => "office-" + createHash("sha256").update(key).digest("hex").slice(0, 12);
147
+ /**
148
+ * Stops and removes the server 0.7 ran for this one repository. Best effort
149
+ * and once: without its record there is nothing left to retire. Its pid is
150
+ * never signalled, since the record is only a file in the checkout; where
151
+ * launchd is absent the old server simply idles out.
152
+ */
153
+ export async function retireLegacyOffice(mainRoot, commonDir, launchd = new LaunchdController()) {
154
+ const legacy = legacyOfficeHome(commonDir);
155
+ if (await readPrivateFile(officeRecordPath(legacy), commonDir).catch(() => null) === null)
156
+ return;
157
+ if (launchd.supported) {
158
+ await launchd.bootout(createLaunchdDescriptor({
159
+ runId: officeLabel(commonDir), workerId: "server", privateDirectory: join(legacy.dir, "office"),
160
+ command: process.execPath, args: [entry, mainRoot], cwd: mainRoot, pathEnvironment: process.env.PATH
161
+ })).catch(() => { });
162
+ }
163
+ await rm(officeRecordPath(legacy), { force: true });
164
+ await rm(join(legacy.dir, "office"), { recursive: true, force: true });
165
+ }
166
+ /** Serve the user's office until no run or session has been active for the idle window. */
142
167
  export async function serveOffice(cwd, onUrl = () => { }, openBrowser) {
143
168
  await requireOffice(cwd);
144
169
  const { mainRoot, commonDir } = await checkouts(cwd);
145
- // ponytail: one shared 1s snapshot so several polling tabs cost one git scan
170
+ const home = officeHome();
171
+ await registerOfficeRepo(home, { mainRoot, commonDir });
172
+ // ponytail: one shared 1s building so several polling tabs cost one git scan per repository
146
173
  let cached = null;
147
- const snapshot = () => {
174
+ const building = () => {
148
175
  if (cached === null || Date.now() - cached.at > 1000)
149
- cached = { at: Date.now(), value: collectOfficeInput(mainRoot, commonDir).then(buildOfficeSnapshot) };
176
+ cached = { at: Date.now(), value: collectOfficeBuilding(home, officeEnabled) };
150
177
  return cached.value;
151
178
  };
152
179
  let finish = () => { };
153
180
  const idle = new Promise((resolve) => { finish = resolve; });
154
- const office = createOfficeServer({ snapshot, onIdle: () => finish() });
155
- const record = await claimOffice(commonDir, office);
181
+ const office = createOfficeServer({ snapshot: async () => (await building()).snapshot, onIdle: () => finish() });
182
+ const record = await claimOffice(home, office);
156
183
  if (record === null) {
157
- const live = await readLiveOffice(commonDir);
184
+ const live = await readLiveOffice(home);
158
185
  if (live !== null)
159
186
  onUrl(officeUrl(live));
160
187
  return;
@@ -163,24 +190,28 @@ export async function serveOffice(cwd, onUrl = () => { }, openBrowser) {
163
190
  if (openBrowser !== undefined)
164
191
  void openBrowser(officeUrl(record)).catch(() => undefined);
165
192
  const timer = setInterval(() => {
166
- void officeEnabled(cwd).then(enabled => enabled ? office.tick() : finish());
193
+ cached = null;
194
+ void building().then(current => current.repos > 0 ? office.tick() : finish(), () => office.tick());
167
195
  }, 15_000);
168
196
  await idle;
169
197
  clearInterval(timer);
170
- await releaseOffice(commonDir, record);
198
+ await releaseOffice(home, record);
171
199
  await office.close();
172
200
  }
173
- /** Reuse the live server or start one under launchd; null where launchd is unavailable. */
201
+ /** Reuse the user's live server or start one under launchd; null where launchd is unavailable. */
174
202
  export async function ensureBackgroundOffice(cwd, launchd = new LaunchdController(), openBrowser) {
175
203
  await requireOffice(cwd);
176
204
  const { mainRoot, commonDir } = await checkouts(cwd);
177
- const live = await readLiveOffice(commonDir);
205
+ const home = officeHome();
206
+ await registerOfficeRepo(home, { mainRoot, commonDir });
207
+ await retireLegacyOffice(mainRoot, commonDir, launchd).catch(() => undefined);
208
+ const live = await readLiveOffice(home);
178
209
  if (live !== null)
179
210
  return officeUrl(live);
180
211
  let started = false;
181
212
  const args = [entry, mainRoot];
182
213
  if (!launchd.supported) {
183
- const url = await ensureOffice(commonDir, async () => {
214
+ const url = await ensureOffice(home, async () => {
184
215
  started = true;
185
216
  await new Promise((resolve, reject) => {
186
217
  const child = spawn(process.execPath, args, { cwd: mainRoot, detached: true, stdio: "ignore", windowsHide: true });
@@ -192,16 +223,16 @@ export async function ensureBackgroundOffice(cwd, launchd = new LaunchdControlle
192
223
  void openBrowser(url).catch(() => undefined);
193
224
  return url;
194
225
  }
195
- const privateDirectory = join(commonDir, "agent-ops", "office");
226
+ const privateDirectory = join(home.dir, "launchd");
196
227
  const descriptor = createLaunchdDescriptor({
197
- runId: "office-" + createHash("sha256").update(commonDir).digest("hex").slice(0, 12), workerId: "server",
228
+ runId: officeLabel(home.dir), workerId: "server",
198
229
  privateDirectory, command: process.execPath, args,
199
230
  cwd: mainRoot, pathEnvironment: process.env.PATH
200
231
  });
201
- const url = await ensureOffice(commonDir, async () => {
232
+ const url = await ensureOffice(home, async () => {
202
233
  started = true;
203
234
  // The descriptor write anchors on this directory, so it must exist first.
204
- await ensurePrivateDirectory(privateDirectory, commonDir);
235
+ await ensurePrivateDirectory(privateDirectory, home.anchor);
205
236
  await launchd.writeDescriptor(descriptor);
206
237
  // A server that exited idle stays loaded in launchd; reload it with the current descriptor.
207
238
  await launchd.bootout(descriptor).catch(() => { });
@@ -23,7 +23,8 @@ export function gitRunner(root) {
23
23
  stdout: execFileSync("git", [...gitArgs], {
24
24
  cwd: root,
25
25
  encoding: "buffer",
26
- stdio: ["ignore", "pipe", "ignore"]
26
+ stdio: ["ignore", "pipe", "ignore"],
27
+ windowsHide: true
27
28
  })
28
29
  };
29
30
  }
@@ -51,7 +52,7 @@ export function worktreeDependencies() {
51
52
  });
52
53
  return {
53
54
  git: async (cwd, gitArgs) => await new Promise((resolve) => {
54
- execFile("git", [...gitArgs], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => resolve({
55
+ execFile("git", [...gitArgs], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => resolve({
55
56
  exitCode: error === null ? 0 : typeof error.code === "number" ? error.code : 1,
56
57
  stdout,
57
58
  stderr
@@ -90,6 +91,7 @@ export function worktreeDependencies() {
90
91
  encoding: "utf8",
91
92
  timeout: step.timeoutMs,
92
93
  maxBuffer: 16 * 1024 * 1024,
94
+ windowsHide: true,
93
95
  ...(process.platform === "win32" ? { shell: true } : {})
94
96
  }, (error, stdout, stderr) => resolve({
95
97
  exitCode: error === null
@@ -58,7 +58,8 @@ async function runAnchoredMutation(targetPath, action, expectedHash, content, mo
58
58
  const child = spawn(process.execPath, [MUTATION_WORKER_PATH, encodedRequest], {
59
59
  cwd: parent,
60
60
  shell: false,
61
- stdio: ["pipe", "ignore", "pipe"]
61
+ stdio: ["pipe", "ignore", "pipe"],
62
+ windowsHide: true
62
63
  });
63
64
  let standardError = "";
64
65
  let settled = false;
@@ -5,9 +5,9 @@ import { AgentOpsError } from "../fs/paths.js";
5
5
  import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
6
6
  import { checkTaskCompletionEvidence, findIncompleteSubtask } from "../task/completion.js";
7
7
  import { collectBaseChangePaths, collectChangeSurface } from "../verify/change-surface.js";
8
- import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
8
+ import { calculateSourceFingerprint, worktreeFingerprint } from "../verify/source-fingerprint.js";
9
9
  import { resolveReviewScope } from "../review/scope.js";
10
- import { NOTES_REF, noteSessionLine, readWorktreeRecord } from "../parallel/service.js";
10
+ import { NOTES_REF, noteSessionLine, readWorktreeRecord, sessionWorktreeName } from "../parallel/service.js";
11
11
  const FINGERPRINT = /^[a-f0-9]{64}$/u;
12
12
  const SESSION = /^[^\0\r\n]{1,256}$/u;
13
13
  function gateDirectory(root) {
@@ -352,6 +352,37 @@ export class CompletionGateService {
352
352
  const line = noteSessionLine(sessionId);
353
353
  return new TextDecoder().decode(log.stdout).split("\0").some((note) => note.split("\n").includes(line));
354
354
  }
355
+ /**
356
+ * Whether a clean checkout moved since the baseline only by fast-forward
357
+ * merges of other sessions' worktree branches. `worktree finish` rebases
358
+ * open sessions itself; a merge run by hand does not, and would otherwise
359
+ * read as this session's change. The branch reflog is walked back to the
360
+ * entry whose clean fingerprint is the baseline; any other move counts.
361
+ */
362
+ async #onlyOtherSessionsMerged(baseline, sessionId) {
363
+ const runner = this.#options.gitRunner;
364
+ if ((await collectChangeSurface(runner)).paths.length > 0)
365
+ return false;
366
+ const branch = await runner.run(["symbolic-ref", "-q", "HEAD"]);
367
+ if (branch.exitCode !== 0)
368
+ return false;
369
+ // ponytail: newest 200 branch moves only; an older baseline needs a one-time allow-stop
370
+ const log = await runner.run(["reflog", "--format=%H%x00%gs", "-n", "200",
371
+ new TextDecoder().decode(branch.stdout).trim(), "--"]);
372
+ if (log.exitCode !== 0)
373
+ return false;
374
+ const own = `agent-ops/${sessionWorktreeName(sessionId)}`;
375
+ const entries = new TextDecoder().decode(log.stdout).split("\n").filter((line) => line !== "");
376
+ for (const [index, entry] of entries.entries()) {
377
+ const [head = "", subject = ""] = entry.split("\0");
378
+ if (index > 0 && worktreeFingerprint(head) === baseline)
379
+ return true;
380
+ const merged = /^merge (agent-ops\/session-[a-z0-9-]+): Fast-forward$/u.exec(subject)?.[1];
381
+ if (merged === undefined || merged === own || merged.startsWith(`${own}-`))
382
+ return false;
383
+ }
384
+ return false;
385
+ }
355
386
  async #validateTask(sessionId) {
356
387
  let stored;
357
388
  try {
@@ -430,7 +461,11 @@ export class CompletionGateService {
430
461
  ? "The session baseline is unavailable; continue once so PreInvocation can initialize it."
431
462
  : "The session baseline is unavailable: its SessionStart never reached this checkout. The next tool call records one; if this repeats, run agent-ops doctor.");
432
463
  }
433
- const changed = state.baselineFingerprint !== fingerprint && state.permitFingerprint !== fingerprint;
464
+ let changed = state.baselineFingerprint !== fingerprint && state.permitFingerprint !== fingerprint;
465
+ if (changed && await this.#onlyOtherSessionsMerged(state.baselineFingerprint, sessionId)) {
466
+ await this.#store.mutate(sessionId, (current) => ({ ...(current ?? state), baselineFingerprint: fingerprint }));
467
+ changed = false;
468
+ }
434
469
  const delegated = this.#delegatedRoots(state);
435
470
  if (delegated.length > 0) {
436
471
  // This checkout only has to stay untouched; the work, its task and its
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import { readdir, readFile, realpath, stat } from "node:fs/promises";
3
4
  import { basename, join, resolve } from "node:path";
4
5
  import { promisify } from "node:util";
@@ -10,10 +11,12 @@ import { findReviewAttestation } from "../review/attestation.js";
10
11
  import { FileEvidenceStore } from "../verify/evidence.js";
11
12
  import { readPrivateFile } from "../security/permissions.js";
12
13
  import { OFFICE_SESSION_RETENTION_MS, readOfficeSessions } from "./sessions.js";
14
+ import { buildOfficeSnapshot, mergeOfficeSnapshots } from "./snapshot.js";
15
+ import { forgetOfficeRepos, readOfficeRepos } from "./repos.js";
13
16
  const run = promisify(execFile);
14
17
  export const officeGit = async (cwd, args) => {
15
18
  try {
16
- return { exitCode: 0, stdout: (await run("git", [...args], { cwd, maxBuffer: 8 * 1024 * 1024 })).stdout };
19
+ return { exitCode: 0, stdout: (await run("git", [...args], { cwd, maxBuffer: 8 * 1024 * 1024, windowsHide: true })).stdout };
17
20
  }
18
21
  catch {
19
22
  return { exitCode: 1, stdout: "" };
@@ -231,3 +234,35 @@ export async function collectOfficeInput(mainRoot, commonDir, git = officeGit, n
231
234
  }
232
235
  return { runs: await readRuns(commonDir), worktrees, sessions, reviews: await readReviewSlots(commonDir), now };
233
236
  }
237
+ /**
238
+ * Every registered repository that still exists and still opts in, as one
239
+ * building. One that vanished or opted out is forgotten, and so is one that
240
+ * stayed quiet past the retention window with nothing left to show.
241
+ */
242
+ export async function collectOfficeBuilding(home, enabled, git = officeGit, now = Date.now()) {
243
+ const parts = [];
244
+ const forget = [];
245
+ const names = new Set();
246
+ for (const repo of await readOfficeRepos(home)) {
247
+ if (!await stat(repo.commonDir).then(item => item.isDirectory(), () => false) || !await enabled(repo.mainRoot).catch(() => false)) {
248
+ forget.push(repo.commonDir);
249
+ continue;
250
+ }
251
+ // One unreadable repository must not hide the rest of the building.
252
+ const snapshot = await collectOfficeInput(repo.mainRoot, repo.commonDir, git, now).then(buildOfficeSnapshot, () => null);
253
+ if (snapshot === null)
254
+ continue;
255
+ if (snapshot.runs.length + snapshot.lobby.length + snapshot.reviews.length === 0 &&
256
+ now - Date.parse(repo.lastSeenAt) >= OFFICE_SESSION_RETENTION_MS) {
257
+ forget.push(repo.commonDir);
258
+ continue;
259
+ }
260
+ let name = basename(repo.mainRoot);
261
+ if (names.has(name))
262
+ name += "~" + createHash("sha256").update(repo.commonDir).digest("hex").slice(0, 4);
263
+ names.add(name);
264
+ parts.push({ repo: name, snapshot });
265
+ }
266
+ await forgetOfficeRepos(home, forget).catch(() => undefined);
267
+ return { snapshot: mergeOfficeSnapshots(parts, now), repos: parts.length };
268
+ }
@@ -259,7 +259,9 @@ button{font:inherit;color:inherit}
259
259
  #crumb{flex:1;overflow:hidden;text-overflow:ellipsis;color:#f2e5c9}
260
260
  #header button{border:2px solid #f2e5c9;background:#6d9275;color:#fff7e6;padding:5px 9px;cursor:pointer;box-shadow:2px 2px 0 #000}
261
261
  #header button:hover,#header button:focus-visible{background:#adc39a;color:#2b241f;outline:3px solid #f2c95c;outline-offset:2px}
262
- #header button[hidden]{display:none}
262
+ #header button[hidden],#header select[hidden]{display:none}
263
+ #header select{border:2px solid #f2e5c9;background:#355a4b;color:#fff7e6;padding:4px 6px;font:inherit;box-shadow:2px 2px 0 #000}
264
+ #header select:focus-visible{outline:3px solid #f2c95c;outline-offset:2px}
263
265
  #live{color:#adc39a}
264
266
  #wrap{min-height:0;flex:1;display:flex;flex-direction:column;align-items:center;justify-content:space-between;gap:4px;padding:4px 8px;overflow:hidden;background:#ead8b8}
265
267
  #office{display:block;max-width:100%;max-height:none;width:auto;height:auto;image-rendering:pixelated;image-rendering:crisp-edges;border:4px solid #6b5d50;box-shadow:6px 6px 0 #8a5033;outline:none;cursor:pointer}
@@ -294,7 +296,7 @@ var canvas = document.getElementById("office"), ctx = canvas.getContext("2d"), a
294
296
  ctx.imageSmoothingEnabled = false;
295
297
  var dialogueBox = document.getElementById("dialogue"), statusBox = document.getElementById("status"), roomNav = document.getElementById("room-nav"), wrap = document.getElementById("wrap");
296
298
  var crumb = document.getElementById("crumb"), backButton = document.getElementById("back"), recentButton = document.getElementById("recent");
297
- var languageButton = document.getElementById("language"), live = document.getElementById("live");
299
+ var languageButton = document.getElementById("language"), live = document.getElementById("live"), repoSelect = document.getElementById("repo-filter"), repoFilter = "", repoSignature = "";
298
300
  var snapshot = null, model = null, positions = {}, roomCanvases = {}, frame = 0, lineIndex = 0, typed = 0, offline = false;
299
301
  var mode = "overview", selectedKey = null, showRecent = false, detailPage = 0, dialogClose = null, detailPreviousFocus = null, detailTarget = null;
300
302
  var detailSignature = "";
@@ -303,8 +305,8 @@ var supervisor = {x:43, y:19, targetX:43, targetY:19}, supervisorReady = false,
303
305
  var roomSupervisor = {x:32, y:34, targetX:32, targetY:34};
304
306
  var reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
305
307
  var STRINGS = {
306
- en: {viewer:"You", overview:"Office overview", rooms:"rooms", people:"people", recent:"Recently completed", recentOn:"Hide completed", back:"Back to overview", connected:"● Connected", offline:"○ Reconnecting", quiet:"The office is quiet. No agent is at work.", waiting:"waiting for your answer", enter:"Enter room", close:"Close", previous:"Previous", next:"Next", page:"Page", details:"Details", progress:"Progress", verify:"Verify", review:"Review", pending:"Awaiting answer", task:"Task", status:"Status", phase:"Phase", host:"Host", now:"Now", owner:"Owner", currentWork:"Current work", question:"Pending question", questions:"Questions", workList:"Work list", clickWork:"Click for work", keyboardWork:"Press L for work list", keyboardPhase:"Press 1-5 for phase work", keyboardQuestions:"Press Q for questions", noPeople:"No known work is in this phase", noQuestions:"No pending questions", files:"Changed files", commands:"Commands", copy:"Copy", copied:"Copied", unknown:"unknown", unassigned:"unassigned", phases:{planning:"Planning", implementing:"Implementing", verifying:"Verifying", reviewing:"Reviewing", integrating:"Integrating", unknown:"Unassigned"}, statuses:{active:"active", idle:"idle", running:"running", reviewing:"reviewing", verifying:"verifying", blocked:"blocked", delivered:"delivered", complete:"complete", unknown:"unknown", unassigned:"unassigned", pending:"pending"}},
307
- zh: {viewer:"你", overview:"辦公室總覽", rooms:"個房間", people:"位成員", recent:"最近完成", recentOn:"隱藏已完成", back:"返回總覽", connected:"● 已連線", offline:"○ 重新連線中", quiet:"辦公室很安靜,目前沒有成員工作。", waiting:"等待你的回覆", enter:"進入房間", close:"關閉", previous:"上一頁", next:"下一頁", page:"頁", details:"詳細資料", progress:"進度", verify:"驗證", review:"審查", pending:"待回覆", task:"任務", status:"狀態", phase:"階段", host:"主機", now:"目前", owner:"負責人", currentWork:"目前工作", question:"待回覆問題", questions:"問題", workList:"工作清單", clickWork:"點擊查看工作", keyboardWork:"按 L 開啟工作清單", keyboardPhase:"按 1-5 查看階段工作", keyboardQuestions:"按 Q 查看問題", noPeople:"此階段目前沒有已知工作", noQuestions:"目前沒有待回覆問題", files:"變更檔案", commands:"指令", copy:"複製", copied:"已複製", unknown:"未知", unassigned:"未分配", phases:{planning:"規劃", implementing:"開發", verifying:"驗證", reviewing:"審查", integrating:"整合", unknown:"未分配"}, statuses:{active:"進行中", idle:"閒置", running:"工作中", reviewing:"審查中", verifying:"驗證中", blocked:"受阻", delivered:"已交付", complete:"已完成", unknown:"未知", unassigned:"未分配", pending:"待回覆"}}
308
+ en: {viewer:"You", overview:"Office overview", rooms:"rooms", people:"people", recent:"Recently completed", recentOn:"Hide completed", repos:"Repository", allRepos:"All repositories", back:"Back to overview", connected:"● Connected", offline:"○ Reconnecting", quiet:"The office is quiet. No agent is at work.", waiting:"waiting for your answer", enter:"Enter room", close:"Close", previous:"Previous", next:"Next", page:"Page", details:"Details", progress:"Progress", verify:"Verify", review:"Review", pending:"Awaiting answer", task:"Task", status:"Status", phase:"Phase", host:"Host", now:"Now", owner:"Owner", currentWork:"Current work", question:"Pending question", questions:"Questions", workList:"Work list", clickWork:"Click for work", keyboardWork:"Press L for work list", keyboardPhase:"Press 1-5 for phase work", keyboardQuestions:"Press Q for questions", noPeople:"No known work is in this phase", noQuestions:"No pending questions", files:"Changed files", commands:"Commands", copy:"Copy", copied:"Copied", unknown:"unknown", unassigned:"unassigned", phases:{planning:"Planning", implementing:"Implementing", verifying:"Verifying", reviewing:"Reviewing", integrating:"Integrating", unknown:"Unassigned"}, statuses:{active:"active", idle:"idle", running:"running", reviewing:"reviewing", verifying:"verifying", blocked:"blocked", delivered:"delivered", complete:"complete", unknown:"unknown", unassigned:"unassigned", pending:"pending"}},
309
+ zh: {viewer:"你", overview:"辦公室總覽", rooms:"個房間", people:"位成員", recent:"最近完成", recentOn:"隱藏已完成", repos:"專案", allRepos:"所有專案", back:"返回總覽", connected:"● 已連線", offline:"○ 重新連線中", quiet:"辦公室很安靜,目前沒有成員工作。", waiting:"等待你的回覆", enter:"進入房間", close:"關閉", previous:"上一頁", next:"下一頁", page:"頁", details:"詳細資料", progress:"進度", verify:"驗證", review:"審查", pending:"待回覆", task:"任務", status:"狀態", phase:"階段", host:"主機", now:"目前", owner:"負責人", currentWork:"目前工作", question:"待回覆問題", questions:"問題", workList:"工作清單", clickWork:"點擊查看工作", keyboardWork:"按 L 開啟工作清單", keyboardPhase:"按 1-5 查看階段工作", keyboardQuestions:"按 Q 查看問題", noPeople:"此階段目前沒有已知工作", noQuestions:"目前沒有待回覆問題", files:"變更檔案", commands:"指令", copy:"複製", copied:"已複製", unknown:"未知", unassigned:"未分配", phases:{planning:"規劃", implementing:"開發", verifying:"驗證", reviewing:"審查", integrating:"整合", unknown:"未分配"}, statuses:{active:"進行中", idle:"閒置", running:"工作中", reviewing:"審查中", verifying:"驗證中", blocked:"受阻", delivered:"已交付", complete:"已完成", unknown:"未知", unassigned:"未分配", pending:"待回覆"}}
308
310
  };
309
311
  function getLanguage(){
310
312
  try { var cookie = document.cookie.split(";").map(function(part){ return part.trim().split("="); }).find(function(pair){ return pair[0] === "agent-office-language"; }); if (cookie && (cookie[1] === "en" || cookie[1] === "zh")) return cookie[1]; } catch (_) {}
@@ -377,7 +379,15 @@ function canvasPoint(event){var rect=canvas.getBoundingClientRect();return {x:(e
377
379
  function inside(point,rect){return point.x>=rect.x&&point.x<rect.x+rect.width&&point.y>=rect.y&&point.y<rect.y+rect.height;}
378
380
  function fillText(value, x, y, size, color){ ctx.fillStyle = color || PALETTE[0]; ctx.font = "bold " + Math.max(5, Math.round((size || 10) * U)) + "px monospace"; ctx.textBaseline = "top"; ctx.fillText(short(value, 36), x, y); }
379
381
  function roomByKey(key){ if (!model) return null; for (var i = 0; i < model.floors.length; i++) if (model.floors[i].key === key) return model.floors[i]; return null; }
380
- function visibleRooms(){ if (!model) return []; return model.floors.filter(function(f){ return showRecent || !f.completedAt; }); }
382
+ function visibleRooms(){ if (!model) return []; return model.floors.filter(function(f){ return (showRecent || !f.completedAt) && (!repoFilter || f.repo === repoFilter); }); }
383
+ function repoNames(){ var names = []; if (model) model.floors.forEach(function(f){ if (f.repo && names.indexOf(f.repo) < 0) names.push(f.repo); }); return names; }
384
+ function repoColor(name){ var hash = 0; for (var i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0; return PALETTE[[1, 4, 9, 13, 0, 8][hash % 6]] || PALETTE[1]; }
385
+ function renderRepoFilter(){
386
+ var names = repoNames(); if (repoFilter && names.indexOf(repoFilter) < 0) repoFilter = "";
387
+ repoSelect.hidden = names.length < 2; repoSelect.setAttribute("aria-label", t("repos"));
388
+ var signature = lang + "|" + repoFilter + "|" + names.join("\n"); if (signature === repoSignature) return; repoSignature = signature;
389
+ repoSelect.textContent = ""; [""].concat(names).forEach(function(name){ var option = document.createElement("option"); option.value = name; option.textContent = name || t("allRepos"); option.selected = name === repoFilter; repoSelect.appendChild(option); });
390
+ }
381
391
  function activeRoom(){ return selectedKey ? roomByKey(selectedKey) : null; }
382
392
  function hasRecent(){ return !!(model && model.floors.some(function(f){ return !!f.completedAt; })); }
383
393
  function setCanvasSize(width, height){
@@ -595,7 +605,9 @@ function drawOverviewRoom(floor){
595
605
  ctx.save();ctx.beginPath();ctx.rect(surface.x,surface.y,surface.width,surface.height);ctx.clip();
596
606
  var image=renderRoomImage(floor,surface.width,surface.height);if(image)ctx.drawImage(image,surface.x,surface.y);
597
607
  floor.actors.forEach(function(actor){drawActor(floor,actor,surface);});
598
- if(floor.completedAt){ctx.fillStyle='rgba(53,90,75,.18)';ctx.fillRect(surface.x,surface.y,surface.width,surface.height);}ctx.restore();
608
+ if(floor.completedAt){ctx.fillStyle='rgba(53,90,75,.18)';ctx.fillRect(surface.x,surface.y,surface.width,surface.height);}
609
+ if(floor.repo){var plate=short(floor.repo,16),plateWidth=(plate.length*6+8)*U;ctx.fillStyle=repoColor(floor.repo);ctx.fillRect(surface.x+2*U,surface.y+2*U,plateWidth,13*U);ctx.strokeStyle=PALETTE[0];ctx.lineWidth=U;ctx.strokeRect(surface.x+2*U,surface.y+2*U,plateWidth,13*U);fillText(plate,surface.x+6*U,surface.y+4*U,8,PALETTE[15]);}
610
+ ctx.restore();
599
611
  }
600
612
  function render(){
601
613
  if(!model)return;chooseRenderScale();var floor=activeRoom();
@@ -620,6 +632,7 @@ function renderRoomNav(rooms){
620
632
  function updateHeader(){
621
633
  if(document.documentElement)document.documentElement.lang=lang==='zh'?'zh-Hant':'en';
622
634
  canvas.setAttribute('aria-label',(lang==='zh'?'方向鍵或 WASD 移動 Supervisor;Enter 進入房間或查看細節;Esc 返回。':'Arrow keys or WASD move Supervisor; Enter opens rooms or details; Escape returns.')+' '+t('keyboardWork')+';'+t('keyboardPhase')+';'+t('keyboardQuestions'));
635
+ renderRepoFilter();
623
636
  var rooms = visibleRooms(), people = rooms.reduce(function(n, f){ return n + f.actors.length; }, 0);
624
637
  crumb.textContent = mode === "room" && activeRoom() ? activeRoom().title : t("overview") + " · " + rooms.length + " " + t("rooms") + " · " + people + " " + t("people");
625
638
  backButton.hidden = mode !== "room"; backButton.textContent = "← " + t("back"); recentButton.textContent = (showRecent ? "✓ " + t("recentOn") : "▣ " + t("recent")) + (hasRecent() ? " (" + model.floors.filter(function(f){ return !!f.completedAt; }).length + ")" : "");
@@ -846,6 +859,7 @@ canvas.addEventListener("keydown", function(event){
846
859
  if (event.key === "Enter" && mode === "overview") { event.preventDefault(); enterRoom(); }
847
860
  if (event.key === "Enter" && mode === "room") { event.preventDefault(); openSelectedDetail(); }
848
861
  });
862
+ repoSelect.addEventListener("change", function(){ repoFilter = repoSelect.value; navPage = 0; if (activeRoom() && repoFilter && activeRoom().repo !== repoFilter) mode = "overview"; if (!selectedKey || visibleRooms().every(function(f){ return f.key !== selectedKey; })) selectedKey = visibleRooms()[0] && visibleRooms()[0].key; updateHeader(); render(); });
849
863
  backButton.addEventListener("click", goOverview); recentButton.addEventListener("click", function(){ showRecent = !showRecent; if (!showRecent && activeRoom() && activeRoom().completedAt) mode = "overview"; updateHeader(); render(); });
850
864
  languageButton.addEventListener("click", changeLanguage); statusBox.addEventListener("keydown", function(event){
851
865
  if (event.key === "Escape") { event.preventDefault(); closeDialog(); return; }
@@ -867,7 +881,7 @@ export function officePage(nonce) {
867
881
  return `<!doctype html>
868
882
  <html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
869
883
  <meta name="referrer" content="no-referrer"><title>agent-ops Office · Preview</title><style nonce="${nonce}">${STYLE}</style></head>
870
- <body><div id="app"><header id="header"><div id="brand">agent-ops Office · Preview</div><div id="crumb">Office overview</div><button id="back" type="button" hidden>← Back to overview</button><button id="recent" type="button">▣ Recently completed</button><button id="language" type="button">繁中 / EN</button><span id="live" aria-live="polite">● Connected</span></header>
884
+ <body><div id="app"><header id="header"><div id="brand">agent-ops Office · Preview</div><div id="crumb">Office overview</div><button id="back" type="button" hidden>← Back to overview</button><button id="recent" type="button">▣ Recently completed</button><select id="repo-filter" hidden></select><button id="language" type="button">繁中 / EN</button><span id="live" aria-live="polite">● Connected</span></header>
871
885
  <main id="wrap"><canvas id="office" tabindex="0" width="576" height="304" aria-label="Office rooms; use arrow keys and Enter to explore"></canvas><div id="dialogue" role="status" aria-live="polite"></div><nav id="room-nav" aria-label="Office rooms"></nav></main></div>
872
886
  <section id="status" role="dialog" aria-modal="true" aria-labelledby="status-title" tabindex="-1" hidden></section><script nonce="${nonce}">${script}</script></body></html>`;
873
887
  }
@@ -0,0 +1,43 @@
1
+ import { join } from "node:path";
2
+ import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
3
+ const MAX_REPOS = 64;
4
+ const MAX_ROOT = 4_096;
5
+ export const officeReposPath = (home) => join(home.dir, "repos.json");
6
+ function parse(value) {
7
+ if (typeof value !== "object" || value === null || Array.isArray(value))
8
+ return null;
9
+ const item = value;
10
+ const root = (text) => typeof text === "string" && text.length > 0 && text.length <= MAX_ROOT && !/[\0\r\n]/u.test(text);
11
+ if (!root(item.mainRoot) || !root(item.commonDir) || typeof item.lastSeenAt !== "string" || !Number.isFinite(Date.parse(item.lastSeenAt)))
12
+ return null;
13
+ return { mainRoot: item.mainRoot, commonDir: item.commonDir, lastSeenAt: item.lastSeenAt };
14
+ }
15
+ export async function readOfficeRepos(home) {
16
+ const source = await readPrivateFile(officeReposPath(home), home.anchor).catch(() => null);
17
+ if (source === null)
18
+ return [];
19
+ try {
20
+ const repos = JSON.parse(source).repos;
21
+ return Array.isArray(repos) ? repos.slice(0, MAX_REPOS).map(parse).filter((repo) => repo !== null) : [];
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ }
27
+ async function update(home, change) {
28
+ const path = officeReposPath(home);
29
+ await withPrivateFileLock(path, home.anchor, async () => {
30
+ const repos = change(await readOfficeRepos(home)).slice(-MAX_REPOS);
31
+ await writePrivateFile(path, JSON.stringify({ schemaVersion: 1, repos }) + "\n", home.anchor);
32
+ });
33
+ }
34
+ /** Adds or refreshes one repository; the newest stays last. */
35
+ export async function registerOfficeRepo(home, repo, now = Date.now()) {
36
+ await update(home, repos => [...repos.filter(item => item.commonDir !== repo.commonDir),
37
+ { mainRoot: repo.mainRoot, commonDir: repo.commonDir, lastSeenAt: new Date(now).toISOString() }]);
38
+ }
39
+ export async function forgetOfficeRepos(home, commonDirs) {
40
+ if (commonDirs.length === 0)
41
+ return;
42
+ await update(home, repos => repos.filter(item => !commonDirs.includes(item.commonDir)));
43
+ }
@@ -43,6 +43,13 @@ export function sceneModel(snapshot) {
43
43
  });
44
44
  const floors = [];
45
45
  const dialogue = [];
46
+ // Two repositories may hold rooms with the same name; the repo keeps their keys apart.
47
+ const repoOf = (value) => {
48
+ const repo = optional(value).repo;
49
+ return typeof repo === "string" && repo.length > 0 ? repo : null;
50
+ };
51
+ const scoped = (repo, key) => repo === null ? key : "repo:" + repo + "|" + key;
52
+ const titled = (repo, title) => short(repo === null ? title : repo + " · " + title, 34);
46
53
  const makeAreas = () => PHASES.map(phase => {
47
54
  const area = areas[phase];
48
55
  return { phase, x: area.x, y: area.y, width: area.width, height: area.height, prop: area.prop, label: labels[phase] };
@@ -67,9 +74,11 @@ export function sceneModel(snapshot) {
67
74
  const extra = optional(run);
68
75
  const completedAt = typeof extra.completedAt === "string" ? extra.completedAt : null;
69
76
  const questions = run.questions;
70
- const props = makeProps(run.runId);
77
+ const repo = repoOf(run);
78
+ const key = scoped(repo, run.runId);
79
+ const props = makeProps(key);
71
80
  const actors = [];
72
- const papers = { [run.runId + ":implementing"]: Math.min(8, run.agents.reduce((sum, agent) => sum + (agent.diff?.files ?? 0), 0)) };
81
+ const papers = { [key + ":implementing"]: Math.min(8, run.agents.reduce((sum, agent) => sum + (agent.diff?.files ?? 0), 0)) };
73
82
  let passed = 0, total = 0, verify = null, review = null;
74
83
  run.agents.forEach((agent, index) => {
75
84
  const phase = agent.phase;
@@ -78,7 +87,7 @@ export function sceneModel(snapshot) {
78
87
  const phaseCount = run.agents.filter(candidate => candidate.phase === phase).length;
79
88
  const point = position(phase, phaseIndex, phaseCount);
80
89
  if ((phase === "implementing" || phase === "unknown") && phaseIndex > 0) {
81
- const deskKey = run.runId + ":desk:" + agent.id;
90
+ const deskKey = key + ":desk:" + agent.id;
82
91
  props.push({ kind: "desk", x: point.x - 2, y: point.y - 5, key: deskKey, phase: "implementing", label: short(agent.id) });
83
92
  papers[deskKey] = Math.min(8, agent.diff?.files ?? 0);
84
93
  }
@@ -92,7 +101,7 @@ export function sceneModel(snapshot) {
92
101
  review = "FAIL";
93
102
  else if (p?.review === "PASS" && review === null)
94
103
  review = "PASS";
95
- actors.push({ key: run.runId + ":" + agent.id, id: agent.id, kind: agent.role, x: point.x, y: point.y, alert: agent.role === "coordinator" && questions.length > 0,
104
+ actors.push({ key: key + ":" + agent.id, id: agent.id, kind: agent.role, x: point.x, y: point.y, alert: agent.role === "coordinator" && questions.length > 0,
96
105
  label: short(agent.id), status: agent.status, phase, taskId: agent.taskId || "unassigned", progress: p,
97
106
  narration: agent.narration, questionCount: agent.role === "coordinator" ? questions.length : 0, host: "unknown" });
98
107
  dialogue.push(`${run.title}: ${agent.id} ${agent.narration}.`);
@@ -101,7 +110,7 @@ export function sceneModel(snapshot) {
101
110
  const reviewerIndex = actors.filter(actor => actor.phase === "reviewing").length;
102
111
  const reviewerCount = run.agents.filter(agent => agent.phase === "reviewing").length + run.reviewers.length;
103
112
  const point = position("reviewing", reviewerIndex, reviewerCount);
104
- actors.push({ key: run.runId + ":review:" + reviewer.slot, id: "reviewer " + reviewer.slot, kind: "reviewer", x: point.x, y: point.y, alert: false,
113
+ actors.push({ key: key + ":review:" + reviewer.slot, id: "reviewer " + reviewer.slot, kind: "reviewer", x: point.x, y: point.y, alert: false,
105
114
  label: "reviewer " + reviewer.slot, status: "reviewing", phase: "reviewing", taskId: reviewer.taskId ?? "unassigned",
106
115
  progress: null, narration: "reviewing", questionCount: 0, host: "unknown" });
107
116
  });
@@ -109,10 +118,10 @@ export function sceneModel(snapshot) {
109
118
  const taskId = run.agents.find(agent => agent.role === "coordinator")?.taskId ?? "unassigned";
110
119
  if (questions.length > 0)
111
120
  dialogue.push(`${run.title}: waiting for your answer — ${questions[0].prompt}`);
112
- return { key: run.runId, sourceIndex: null, kind: "run", title: short(run.title, 34), top: 0, props, actors, phaseAreas: makeAreas(), questions,
121
+ return { key, sourceIndex: null, kind: "run", title: titled(repo, run.title), top: 0, props, actors, phaseAreas: makeAreas(), questions,
113
122
  board: boardFor(rootProgress, run.status, taskId, questions.length), papers, books: { lit: passed, total },
114
123
  clock: run.budget.limitMs > 0 ? run.budget.remainingMs / run.budget.limitMs : 0, phase: run.phase, status: run.status,
115
- completedAt, overview: { x: 0, y: 0, width: 0, height: 0 } };
124
+ completedAt, repo, overview: { x: 0, y: 0, width: 0, height: 0 } };
116
125
  };
117
126
  const makeDesk = (desk, index) => {
118
127
  const extra = optional(desk);
@@ -126,7 +135,8 @@ export function sceneModel(snapshot) {
126
135
  return { questionId: typeof item.questionId === "string" ? item.questionId : "unknown", prompt: typeof item.prompt === "string" ? item.prompt : "" };
127
136
  });
128
137
  const completedAt = typeof extra.completedAt === "string" ? extra.completedAt : null;
129
- const key = "session:" + [desk.sessionId || "unknown", desk.branch || "unknown", desk.name || "session"].join(":");
138
+ const repo = repoOf(desk);
139
+ const key = scoped(repo, "session:" + [desk.sessionId || "unknown", desk.branch || "unknown", desk.name || "session"].join(":"));
130
140
  const props = makeProps(key);
131
141
  const point = position(phase, 0, 1);
132
142
  const host = typeof extra.host === "string" ? extra.host : "unknown";
@@ -135,24 +145,27 @@ export function sceneModel(snapshot) {
135
145
  if (questions.length > 0)
136
146
  dialogue.push(`${desk.name}: waiting for your answer.`);
137
147
  dialogue.push(`${desk.name}: ${desk.narration}.`);
138
- return { key, sourceIndex: index, kind: "desk", title: short(desk.name, 34), top: 0, props, actors: [actor], phaseAreas: makeAreas(),
148
+ return { key, sourceIndex: index, kind: "desk", title: titled(repo, desk.name), top: 0, props, actors: [actor], phaseAreas: makeAreas(),
139
149
  board: boardFor(p, status, taskId, questions.length), questions, papers: { [key + ":implementing"]: Math.min(8, desk.diff.files) }, books: { lit: p?.passed ?? 0, total: p?.total ?? 0 },
140
- clock: 1, phase, status, completedAt, overview: { x: 0, y: 0, width: 0, height: 0 } };
150
+ clock: 1, phase, status, completedAt, repo, overview: { x: 0, y: 0, width: 0, height: 0 } };
141
151
  };
142
152
  snapshot.runs.forEach(run => floors.push(makeRun(run)));
143
153
  snapshot.lobby.forEach((desk, index) => floors.push(makeDesk(desk, index)));
144
154
  snapshot.reviews.forEach((review, index) => {
145
- const key = "review:" + review.slot + ":" + index;
155
+ const repo = repoOf(review);
156
+ const key = scoped(repo, "review:" + review.slot + ":" + index);
146
157
  const props = makeProps(key);
147
158
  const point = position("reviewing", 0);
148
159
  const actor = { key: key + ":actor", id: "reviewer " + review.slot, kind: "reviewer", x: point.x, y: point.y, alert: false, label: "reviewer " + review.slot,
149
160
  status: "reviewing", phase: "reviewing", taskId: review.taskId ?? "unassigned", progress: null,
150
161
  narration: "reviewing", questionCount: 0, host: "unknown" };
151
162
  dialogue.push(`Review slot ${review.slot}: ${review.taskId ?? "unassigned"}.`);
152
- floors.push({ key, sourceIndex: index, kind: "review", title: "Review slot " + review.slot, top: 0, props, actors: [actor], phaseAreas: makeAreas(), questions: [],
163
+ floors.push({ key, sourceIndex: index, kind: "review", title: titled(repo, "Review slot " + review.slot), top: 0, props, actors: [actor], phaseAreas: makeAreas(), questions: [],
153
164
  board: boardFor(null, "reviewing", review.taskId ?? "unassigned", 0), papers: {}, books: { lit: 0, total: 0 }, clock: 1,
154
- phase: "reviewing", status: "reviewing", completedAt: null, overview: { x: 0, y: 0, width: 0, height: 0 } });
165
+ phase: "reviewing", status: "reviewing", completedAt: null, repo, overview: { x: 0, y: 0, width: 0, height: 0 } });
155
166
  });
167
+ // Rooms of one repository sit together; the sort is stable inside each.
168
+ floors.sort((a, b) => (a.repo ?? "").localeCompare(b.repo ?? ""));
156
169
  if (dialogue.length === 0)
157
170
  dialogue.push("The office is quiet. No agent is at work.");
158
171
  const columns = floors.length === 0 ? 1 : Math.min(floors.length, Math.max(1, Math.ceil(Math.sqrt(floors.length * OVERVIEW_COLS / OVERVIEW_ROWS))));
@@ -3,12 +3,16 @@ import { createServer } from "node:http";
3
3
  import { rm } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { AgentOpsError } from "../fs/paths.js";
6
- import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
6
+ import { localStatePaths, readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
7
7
  import { officePage } from "./page.js";
8
8
  export const OFFICE_IDLE_MS = 10 * 60 * 1000;
9
9
  const ACTIVE = new Set(["active", "awaiting-input", "stopping"]);
10
- export const officeRecordPath = (commonDir) => join(commonDir, "agent-ops", "office.json");
11
- const officeStartLockPath = (commonDir) => join(commonDir, "agent-ops", "office-start.lock");
10
+ /** The user's one Office, beside the other per-user agent-ops state. */
11
+ export const userOfficeHome = (homeDirectory) => ({ anchor: homeDirectory, dir: join(localStatePaths(homeDirectory).root, "office") });
12
+ /** Where 0.7 kept one Office per repository. */
13
+ export const legacyOfficeHome = (commonDir) => ({ anchor: commonDir, dir: join(commonDir, "agent-ops") });
14
+ export const officeRecordPath = (home) => join(home.dir, "office.json");
15
+ const officeStartLockPath = (home) => join(home.dir, "office-start.lock");
12
16
  export const officeUrl = (record) => `http://127.0.0.1:${record.port}/?token=${record.token}`;
13
17
  function same(a, b) {
14
18
  const left = Buffer.from(a), right = Buffer.from(b);
@@ -83,8 +87,8 @@ function alive(pid) {
83
87
  }
84
88
  }
85
89
  /** A recorded server counts only if its process lives and it answers with its own token. */
86
- export async function readLiveOffice(commonDir) {
87
- const source = await readPrivateFile(officeRecordPath(commonDir), commonDir).catch(() => null);
90
+ export async function readLiveOffice(home) {
91
+ const source = await readPrivateFile(officeRecordPath(home), home.anchor).catch(() => null);
88
92
  if (source === null)
89
93
  return null;
90
94
  try {
@@ -100,29 +104,29 @@ export async function readLiveOffice(commonDir) {
100
104
  }
101
105
  }
102
106
  /**
103
- * Starts serving unless another live server already owns the repository.
107
+ * Starts serving unless another live server already owns this home.
104
108
  * Returns null when this process should exit because one is already running.
105
109
  */
106
- export async function claimOffice(commonDir, office, pid = process.pid) {
107
- return await withPrivateFileLock(officeRecordPath(commonDir), commonDir, async () => {
108
- if (await readLiveOffice(commonDir) !== null)
110
+ export async function claimOffice(home, office, pid = process.pid) {
111
+ return await withPrivateFileLock(officeRecordPath(home), home.anchor, async () => {
112
+ if (await readLiveOffice(home) !== null)
109
113
  return null;
110
114
  const record = { pid, port: await office.listen(), token: office.token, startedAt: new Date().toISOString() };
111
- await writePrivateFile(officeRecordPath(commonDir), JSON.stringify(record) + "\n", commonDir);
115
+ await writePrivateFile(officeRecordPath(home), JSON.stringify(record) + "\n", home.anchor);
112
116
  return record;
113
117
  });
114
118
  }
115
119
  /** Reuse the live server, or start one and wait for it to record itself. */
116
- export async function ensureOffice(commonDir, start, options = {}) {
117
- return await withPrivateFileLock(officeStartLockPath(commonDir), commonDir, async () => {
118
- const live = await readLiveOffice(commonDir);
120
+ export async function ensureOffice(home, start, options = {}) {
121
+ return await withPrivateFileLock(officeStartLockPath(home), home.anchor, async () => {
122
+ const live = await readLiveOffice(home);
119
123
  if (live !== null)
120
124
  return officeUrl(live);
121
125
  await start();
122
126
  const deadline = Date.now() + (options.timeoutMs ?? 10_000);
123
127
  while (Date.now() < deadline) {
124
128
  await new Promise(resolve => setTimeout(resolve, options.pollMs ?? 200));
125
- const started = await readLiveOffice(commonDir);
129
+ const started = await readLiveOffice(home);
126
130
  if (started !== null)
127
131
  return officeUrl(started);
128
132
  }
@@ -130,10 +134,10 @@ export async function ensureOffice(commonDir, start, options = {}) {
130
134
  });
131
135
  }
132
136
  /** Drop the record on exit, unless another server has replaced it. */
133
- export async function releaseOffice(commonDir, record) {
134
- await withPrivateFileLock(officeRecordPath(commonDir), commonDir, async () => {
135
- const source = await readPrivateFile(officeRecordPath(commonDir), commonDir).catch(() => null);
137
+ export async function releaseOffice(home, record) {
138
+ await withPrivateFileLock(officeRecordPath(home), home.anchor, async () => {
139
+ const source = await readPrivateFile(officeRecordPath(home), home.anchor).catch(() => null);
136
140
  if (source !== null && JSON.parse(source).token === record.token)
137
- await rm(officeRecordPath(commonDir), { force: true });
141
+ await rm(officeRecordPath(home), { force: true });
138
142
  });
139
143
  }
@@ -107,9 +107,24 @@ export async function recordOfficeSession(options) {
107
107
  ...(options.taskId ?? previous?.taskId ? { taskId: options.taskId ?? previous?.taskId } : {}),
108
108
  ...(options.phase ?? previous?.phase ? { phase: options.phase ?? previous?.phase } : {}),
109
109
  ...(options.host ?? previous?.host ? { host: options.host ?? previous?.host } : {}),
110
- ...(options.event === "stop" && previous?.completedAt !== undefined ? { completedAt: previous.completedAt } : {})
110
+ // A finished session keeps its closed room through the tool calls that
111
+ // report the result; only a new start (or resume) reopens it.
112
+ ...(options.event !== "start" && previous?.completedAt !== undefined ? { completedAt: previous.completedAt } : {})
111
113
  };
112
114
  const without = records.filter(record => record.sessionId !== options.sessionId);
113
115
  await writePrivateFile(path, JSON.stringify({ schemaVersion: 1, sessions: [...without, next].slice(-MAX_SESSIONS) }) + "\n", options.commonDir);
114
116
  });
115
117
  }
118
+ /** Closes a session's room once `worktree finish` merged its work. Unknown sessions stay unknown. */
119
+ export async function markOfficeSessionCompleted(commonDir, sessionId, now = Date.now()) {
120
+ const path = officeSessionsPath(commonDir);
121
+ await withPrivateFileLock(path, commonDir, async () => {
122
+ const records = prune(await readRecords(commonDir), now);
123
+ if (!records.some(record => record.sessionId === sessionId))
124
+ return;
125
+ const at = new Date(now).toISOString();
126
+ const next = records.map(record => record.sessionId === sessionId
127
+ ? { ...record, status: "idle", phase: "integrating", lastSeenAt: at, completedAt: at } : record);
128
+ await writePrivateFile(path, JSON.stringify({ schemaVersion: OFFICE_SESSION_SCHEMA_VERSION, sessions: next }) + "\n", commonDir);
129
+ });
130
+ }
@@ -62,7 +62,8 @@ export function buildOfficeSnapshot(input) {
62
62
  if (worktree.runId !== undefined)
63
63
  continue;
64
64
  const session = bySession.get(worktree.sessionId);
65
- const completedAt = worktree.completedAt ?? session?.completedAt ?? null;
65
+ // An active task in a live worktree is new work, whatever closed the session before.
66
+ const completedAt = worktree.completedAt ?? (worktree.status === "active" ? null : session?.completedAt ?? null);
66
67
  if (completedAt !== null && input.now - Date.parse(completedAt) >= COMPLETED_RETENTION_MS)
67
68
  continue;
68
69
  lobby.push({
@@ -77,7 +78,7 @@ export function buildOfficeSnapshot(input) {
77
78
  ...(worktree.status === undefined && session?.taskStatus === undefined && session?.status === undefined ? {} : { status: worktree.status ?? session?.taskStatus ?? session?.status }),
78
79
  ...(worktree.taskId === undefined && session?.taskId === undefined ? {} : { taskId: worktree.taskId ?? session?.taskId ?? null }),
79
80
  ...(worktree.progress === undefined && session?.progress === undefined ? {} : { progress: worktree.progress ?? session?.progress ?? null }),
80
- ...(completedAt === null && session?.completedAt === undefined ? {} : { completedAt: completedAt ?? session?.completedAt ?? null }),
81
+ ...(completedAt === null && session?.completedAt === undefined ? {} : { completedAt }),
81
82
  ...(worktree.host === undefined && session?.host === undefined ? {} : { host: worktree.host ?? session?.host }),
82
83
  });
83
84
  represented.add(worktree.sessionId);
@@ -105,3 +106,12 @@ export function buildOfficeSnapshot(input) {
105
106
  }
106
107
  return { generatedAt: new Date(input.now).toISOString(), runs, lobby, reviews: input.reviews.filter(r => !claimed.has(r)) };
107
108
  }
109
+ /** One building from several repositories' snapshots; every room names its repository. */
110
+ export function mergeOfficeSnapshots(parts, now) {
111
+ return {
112
+ generatedAt: new Date(now).toISOString(),
113
+ runs: parts.flatMap(({ repo, snapshot }) => snapshot.runs.map(run => ({ ...run, repo }))),
114
+ lobby: parts.flatMap(({ repo, snapshot }) => snapshot.lobby.map(desk => ({ ...desk, repo }))),
115
+ reviews: parts.flatMap(({ repo, snapshot }) => snapshot.reviews.map(review => ({ ...review, repo })))
116
+ };
117
+ }
@@ -11,6 +11,7 @@ import { readRunIntegrationProof, readRunIntegrationReceiptBinding, markRunInteg
11
11
  import { canonicalJson } from "../config/hash.js";
12
12
  import { listWorktrees } from "./manage.js";
13
13
  import { withPrivateFileLock } from "../security/permissions.js";
14
+ import { markOfficeSessionCompleted } from "../office/sessions.js";
14
15
  import { assertWorktreeName, NOTES_REF, noteSessionLine, parseWorktreeListPorcelain, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath } from "./service.js";
15
16
  const LOCK_NAME = "agent-ops-finish.lock";
16
17
  const LOCK_POLL_MS = 2_000;
@@ -488,6 +489,8 @@ export async function finishWorktree(deps, options) {
488
489
  }
489
490
  });
490
491
  }
492
+ // Office is display-only: its room closing never fails or partially fails a finish.
493
+ await markOfficeSessionCompleted(commonDir, record.sessionId).catch(() => undefined);
491
494
  await attempt("trust", async () => await deps.trust.revoke(record.path, worktreeConfig));
492
495
  if (options.finalProof !== undefined) {
493
496
  for (const child of sessionChildren) {
@@ -236,7 +236,7 @@ export class CodexGoalHost {
236
236
  clientInfo: {
237
237
  name: "agent-ops",
238
238
  title: "agent-ops native goal host",
239
- version: "0.6.0"
239
+ version: "0.7.1"
240
240
  }
241
241
  });
242
242
  session.notify("initialized", {});
@@ -37,6 +37,10 @@ const execFile = promisify(execFileCallback);
37
37
  const lockQueues = new Map();
38
38
  const heartbeatFailures = new Map();
39
39
  const heartbeatPaths = new Set();
40
+ // A heartbeat lives as long as the process. Its handle is held here, not only
41
+ // by the interval below, so a reset or mocked timer can never let garbage
42
+ // collection close it (an uncaught error since Node 26).
43
+ const heartbeatHandles = new Set();
40
44
  const processHeartbeats = new Map();
41
45
  const processIdentityCache = new Map();
42
46
  let heartbeatCleanupRegistered = false;
@@ -151,6 +155,7 @@ async function ensureProcessHeartbeat(parent, anchorDirectory) {
151
155
  throw error;
152
156
  }
153
157
  heartbeatPaths.add(heartbeatPath);
158
+ heartbeatHandles.add(handle);
154
159
  registerHeartbeatCleanup();
155
160
  const heartbeat = {
156
161
  device,
@@ -161,6 +166,7 @@ async function ensureProcessHeartbeat(parent, anchorDirectory) {
161
166
  void handle.utimes(new Date(), new Date()).catch((error) => {
162
167
  heartbeatFailures.set(cacheKey, error);
163
168
  clearInterval(timer);
169
+ heartbeatHandles.delete(handle);
164
170
  void handle.close().catch(() => {
165
171
  process.emitWarning("A failed private-state heartbeat handle could not be closed.");
166
172
  });
@@ -39,6 +39,10 @@ export async function calculateSourceFingerprint(root, scope, runner) {
39
39
  hash: sha256(bytes)
40
40
  });
41
41
  }
42
+ return worktreeFingerprint(head, paths);
43
+ }
44
+ /** A worktree-mode fingerprint; with no paths, that of a clean checkout at `head`. */
45
+ export function worktreeFingerprint(head, paths = []) {
42
46
  return sha256(JSON.stringify({
43
47
  domain: "agent-ops-source-v1",
44
48
  mode: "worktree",
@@ -204,11 +204,20 @@ Claude or Codex transcripts or file contents.
204
204
  disabled choice on its next 15-second lifecycle check.
205
205
  - Trigger: When enabled, a managed ordinary session starts or reports activity, or a user runs
206
206
  `agent-ops office`, `agent-ops run start` or `agent-ops run status`.
207
- - Action: The command reuses the live server recorded in
208
- `<git common dir>/agent-ops/office.json`, or starts one, and prints its URL.
209
- Ordinary session hooks also start or reuse it, including before a task or
210
- worktree exists. Only the successful new-server owner opens the browser;
211
- other sessions share the same Office. Session metadata contains bounded
207
+ - Action: The command reuses the user's one live server recorded in
208
+ `~/.agent-ops/state/office/office.json` (under `AGENT_OPS_HOME` when set), or
209
+ starts one, and prints its URL. Ordinary session hooks also start or reuse it,
210
+ including before a task or worktree exists. Only the successful new-server
211
+ owner opens the browser; sessions in every repository share the same Office.
212
+ An Office-enabled repository registers its main root and Git common directory
213
+ in `repos.json` beside that record. Each collection re-checks that the
214
+ directory exists and the repository still opts in, and forgets a repository
215
+ quiet for two hours with nothing left to show. Rooms carry their repository's
216
+ name, their keys are scoped by it, and the page filters by repository. The
217
+ first report from a repository that still has a 0.7 per-repository record
218
+ boots its launchd job out and removes that record; its pid is never signalled.
219
+ A successful `worktree finish` records the session's completion so its room
220
+ closes; only a new start or an active task reopens it. Session metadata contains bounded
212
221
  identifiers, known work state and activity times, never native transcripts,
213
222
  prompts or tool/file contents. Startup and observation failures are advisory.
214
223
  The server binds
@@ -177,10 +177,17 @@ base 的 `git diff`;旁白只使用路徑(`docs/**` 為「writing docs」、
177
177
  server MUST 在下一次 15 秒生命週期檢查觀察到關閉選擇後結束。
178
178
  - Trigger: 啟用後,Managed 一般 session 啟動或回報活動,或使用者執行
179
179
  `agent-ops office`、`agent-ops run start` 或 `agent-ops run status`。
180
- - Action: Command 重用記錄於 `<git common dir>/agent-ops/office.json` 的 live
181
- server,否則啟動一個,並印出 URL。一般 session hook 也會自動啟動或重用
182
- Office,包含尚未建立 task 或 worktree 的 session。只有成功取得新 server
183
- 所有權的 process 開啟瀏覽器,後續 session 共用同一個 Office。Session metadata
180
+ - Action: Command 重用記錄於 `~/.agent-ops/state/office/office.json`(設定
181
+ `AGENT_OPS_HOME` 時位於其下)的使用者唯一 live server,否則啟動一個,並印出
182
+ URL。一般 session hook 也會自動啟動或重用 Office,包含尚未建立 task 或
183
+ worktree 的 session。只有成功取得新 server 所有權的 process 開啟瀏覽器,所有
184
+ repository 的 session 共用同一個 Office。啟用 Office 的 repository 會把 main
185
+ root 與 Git common directory 登記在同目錄的 `repos.json`。每次收集都重新確認
186
+ 目錄存在且仍啟用,並忘記安靜超過兩小時且沒有內容可顯示的 repository。房間
187
+ 帶有 repository 名稱,key 以其區隔,頁面可依 repository 篩選。仍留有 0.7
188
+ per-repository 紀錄的 repository 第一次回報時,會 bootout 其 launchd job 並
189
+ 刪除該紀錄,絕不對其 pid 發送訊號。`worktree finish` 成功後會記錄 session
190
+ 完成,使房間關閉;只有新的啟動或 active task 會重新開啟。Session metadata
184
191
  只記錄有界限的識別資訊、已知工作狀態與活動時間,不包含 native transcript、
185
192
  prompt、tool 或檔案內容。啟動與活動觀察失敗均為 advisory。
186
193
  Server 綁定 127.0.0.1 的隨機 port,要求 URL 中不可猜測的 token,拒絕該位址與
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Evidence-driven development loops for agy, Codex, Claude Code, and opencode",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",