@kylecheng3146/agent-ops 0.7.0 → 0.7.2
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 +8 -3
- package/dist/packages/cli/src/commands/review.js +10 -4
- package/dist/packages/cli/src/context.js +6 -5
- package/dist/packages/cli/src/hook-process.js +28 -0
- package/dist/packages/cli/src/office-entry.js +69 -38
- package/dist/packages/cli/src/parallel-deps.js +4 -2
- package/dist/runtime/src/adapters/claude/config.js +5 -0
- package/dist/runtime/src/adapters/claude/events.js +2 -1
- package/dist/runtime/src/fs/transaction.js +2 -1
- package/dist/runtime/src/hooks/completion-gate.js +38 -3
- package/dist/runtime/src/office/collect.js +36 -1
- package/dist/runtime/src/office/page.js +25 -9
- package/dist/runtime/src/office/repos.js +43 -0
- package/dist/runtime/src/office/scene.js +26 -13
- package/dist/runtime/src/office/server.js +22 -18
- package/dist/runtime/src/office/sessions.js +19 -2
- package/dist/runtime/src/office/snapshot.js +12 -2
- package/dist/runtime/src/parallel/finish.js +3 -0
- package/dist/runtime/src/run/hosts/codex.js +1 -1
- package/dist/runtime/src/schema/validate.js +2 -1
- package/dist/runtime/src/security/permissions.js +6 -0
- package/dist/runtime/src/verify/source-fingerprint.js +4 -0
- package/docs/en/spec/harness-adapters.md +19 -5
- package/docs/zh-TW/spec/harness-adapters.md +14 -4
- package/package.json +1 -1
- package/schemas/manifest.schema.json +2 -1
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.
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
...(
|
|
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;
|
|
@@ -240,8 +240,36 @@ function completionGateFor(root, config, gitRunner, harness) {
|
|
|
240
240
|
* Runs one hook invocation. Exit code stays zero because native JSON carries
|
|
241
241
|
* decisions; only an explicitly installed agy completion gate fails closed.
|
|
242
242
|
*/
|
|
243
|
+
/**
|
|
244
|
+
* Claude's SessionEnd: the conversation closed, so its Office room closes too.
|
|
245
|
+
* Display-only and fail-open: it writes no output and never fails the host.
|
|
246
|
+
*/
|
|
247
|
+
async function closeOfficeSession(io, dependencies) {
|
|
248
|
+
try {
|
|
249
|
+
if (process.env.AGENT_OPS_DISABLE === "1" || dependencies.office === undefined)
|
|
250
|
+
return 0;
|
|
251
|
+
const input = parseInput(await readStdin(io.stdin));
|
|
252
|
+
if (typeof input !== "object" || input === null || Array.isArray(input))
|
|
253
|
+
return 0;
|
|
254
|
+
const fields = input;
|
|
255
|
+
const root = dependencies.root ?? (typeof fields.cwd === "string" ? fields.cwd : process.cwd());
|
|
256
|
+
const outcome = await hookConfigOutcome(root, dependencies.loadConfig);
|
|
257
|
+
if (outcome.kind === "invalid" || outcome.config.features.office?.enabled !== true)
|
|
258
|
+
return 0;
|
|
259
|
+
await dependencies.office({
|
|
260
|
+
root, harness: "claude", event: "SessionEnd", input, validated: true,
|
|
261
|
+
...(typeof fields.session_id === "string" ? { sessionId: fields.session_id } : {})
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// Office never blocks a session, least of all one that is ending.
|
|
266
|
+
}
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
243
269
|
export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
244
270
|
const [harness, event] = argv;
|
|
271
|
+
if (harness === "claude" && event === "SessionEnd")
|
|
272
|
+
return await closeOfficeSession(io, dependencies);
|
|
245
273
|
// Both hosts whose Stop hook can actually refuse a stop. codex never fires
|
|
246
274
|
// Stop under `codex exec` and rejects `permissionDecision:ask`, so its
|
|
247
275
|
// escape hatch could not be user-approved; opencode can only deny a tool
|
|
@@ -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 {
|
|
6
|
-
import { claimOffice, createOfficeServer, ensureOffice, readLiveOffice, officeUrl, releaseOffice } from "../../../runtime/src/office/server.js";
|
|
7
|
-
import {
|
|
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";
|
|
@@ -46,7 +48,7 @@ function sessionIdentity(options) {
|
|
|
46
48
|
return firstText(options.sessionId, recordValue(options.input, "session_id"), recordValue(options.input, "sessionId"), recordValue(options.input, "conversationId"), recordValue(options.input, "thread_id"), recordValue(options.input, "threadId"), recordValue(nested, "sessionID"), recordValue(nested, "session_id"), process.env.AGENT_OPS_SESSION_ID, process.env.CODEX_THREAD_ID);
|
|
47
49
|
}
|
|
48
50
|
function officeEvent(event) {
|
|
49
|
-
return event === "SessionStart" ? "start" : event === "Stop" ? "stop" : "activity";
|
|
51
|
+
return event === "SessionStart" ? "start" : event === "Stop" ? "stop" : event === "SessionEnd" ? "end" : "activity";
|
|
50
52
|
}
|
|
51
53
|
/** Phase hints come only from normalized native event kinds and exact agent-ops argv. */
|
|
52
54
|
export function officePhaseHint(event) {
|
|
@@ -110,25 +112,24 @@ 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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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
|
-
if (options.event !== "Stop")
|
|
132
|
+
if (options.event !== "Stop" && options.event !== "SessionEnd")
|
|
132
133
|
await ensureBackgroundOffice(options.root, undefined, openOfficeBrowser);
|
|
133
134
|
}
|
|
134
135
|
catch {
|
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
|
174
|
+
const building = () => {
|
|
148
175
|
if (cached === null || Date.now() - cached.at > 1000)
|
|
149
|
-
cached = { at: Date.now(), value:
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
226
|
+
const privateDirectory = join(home.dir, "launchd");
|
|
196
227
|
const descriptor = createLaunchdDescriptor({
|
|
197
|
-
runId:
|
|
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(
|
|
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,
|
|
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
|
|
@@ -125,6 +125,11 @@ export function buildClaudeHookSettings(capabilities, runtimePath, platform = pr
|
|
|
125
125
|
else if (capabilities.includes("optional-stop-verify") || capabilities.includes("office-presence")) {
|
|
126
126
|
hooks.Stop = [matcherGroup("Stop", runtimePath)];
|
|
127
127
|
}
|
|
128
|
+
// Stop only ends a turn. SessionEnd is the one signal that the conversation
|
|
129
|
+
// closed, so a session that never had a task can still leave the Office.
|
|
130
|
+
if (capabilities.includes("office-presence")) {
|
|
131
|
+
hooks.SessionEnd = [matcherGroup("SessionEnd", runtimePath)];
|
|
132
|
+
}
|
|
128
133
|
return { hooks };
|
|
129
134
|
}
|
|
130
135
|
function isRecord(value) {
|
|
@@ -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
|
-
|
|
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", noTask:"No task", noTaskHint:"Nothing to verify or review", 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:"所有專案", noTask:"無任務", noTaskHint:"沒有需要驗證或審查的工作", 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){
|
|
@@ -462,9 +472,11 @@ function drawRoomBase(floor){
|
|
|
462
472
|
var b=floor.board,board=progressBoardGeometry(canvas.width,canvas.height),bx=board.x,by=board.y,bw=board.width,bh=board.height;
|
|
463
473
|
ctx.fillStyle=PALETTE[9];ctx.fillRect(bx,by,bw,bh);ctx.fillStyle=PALETTE[15];ctx.fillRect(bx+2*unit,by+2*unit,bw-4*unit,bh-4*unit);
|
|
464
474
|
fillText('▣ '+t('clickWork'),bx+6*unit,by+5*unit,8,PALETTE[1]);
|
|
465
|
-
|
|
475
|
+
// A room without a task has nothing to verify or review; saying "pending" would claim unfinished work.
|
|
476
|
+
if(b.taskId==='unassigned'&&!b.total){fillText(t('noTask'),bx+6*unit,by+16*unit,10,PALETTE[1]);fillText(t('noTaskHint'),bx+6*unit,by+40*unit,8,PALETTE[4]);}
|
|
477
|
+
else{fillText(t('progress')+' '+b.passed+'/'+(b.total||'?'),bx+6*unit,by+16*unit,10,PALETTE[1]);
|
|
466
478
|
ctx.fillStyle=PALETTE[3];ctx.fillRect(bx+6*unit,by+29*unit,bw-12*unit,5*unit);ctx.fillStyle=PALETTE[2];ctx.fillRect(bx+6*unit,by+29*unit,(bw-12*unit)*Math.min(1,b.total?b.passed/b.total:0),5*unit);
|
|
467
|
-
fillText(t('verify')+' '+outcomeText(b.verify)+' '+t('review')+' '+outcomeText(b.review),bx+6*unit,by+40*unit,8,PALETTE[4]);
|
|
479
|
+
fillText(t('verify')+' '+outcomeText(b.verify)+' '+t('review')+' '+outcomeText(b.review),bx+6*unit,by+40*unit,8,PALETTE[4]);}
|
|
468
480
|
var pendingRow=pendingBoardGeometry(canvas.width,canvas.height);
|
|
469
481
|
if(b.pending)fillText('! '+t('pending')+' '+b.pending,pendingRow.x+2*unit,pendingRow.y+3*unit,8,PALETTE[13]);
|
|
470
482
|
var oldS=S;S=Math.max(1,Math.round(unit));var cx=52*rx,cy=ry;draw('clock',cx,cy);
|
|
@@ -595,7 +607,9 @@ function drawOverviewRoom(floor){
|
|
|
595
607
|
ctx.save();ctx.beginPath();ctx.rect(surface.x,surface.y,surface.width,surface.height);ctx.clip();
|
|
596
608
|
var image=renderRoomImage(floor,surface.width,surface.height);if(image)ctx.drawImage(image,surface.x,surface.y);
|
|
597
609
|
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);}
|
|
610
|
+
if(floor.completedAt){ctx.fillStyle='rgba(53,90,75,.18)';ctx.fillRect(surface.x,surface.y,surface.width,surface.height);}
|
|
611
|
+
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]);}
|
|
612
|
+
ctx.restore();
|
|
599
613
|
}
|
|
600
614
|
function render(){
|
|
601
615
|
if(!model)return;chooseRenderScale();var floor=activeRoom();
|
|
@@ -620,6 +634,7 @@ function renderRoomNav(rooms){
|
|
|
620
634
|
function updateHeader(){
|
|
621
635
|
if(document.documentElement)document.documentElement.lang=lang==='zh'?'zh-Hant':'en';
|
|
622
636
|
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'));
|
|
637
|
+
renderRepoFilter();
|
|
623
638
|
var rooms = visibleRooms(), people = rooms.reduce(function(n, f){ return n + f.actors.length; }, 0);
|
|
624
639
|
crumb.textContent = mode === "room" && activeRoom() ? activeRoom().title : t("overview") + " · " + rooms.length + " " + t("rooms") + " · " + people + " " + t("people");
|
|
625
640
|
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 +861,7 @@ canvas.addEventListener("keydown", function(event){
|
|
|
846
861
|
if (event.key === "Enter" && mode === "overview") { event.preventDefault(); enterRoom(); }
|
|
847
862
|
if (event.key === "Enter" && mode === "room") { event.preventDefault(); openSelectedDetail(); }
|
|
848
863
|
});
|
|
864
|
+
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
865
|
backButton.addEventListener("click", goOverview); recentButton.addEventListener("click", function(){ showRecent = !showRecent; if (!showRecent && activeRoom() && activeRoom().completedAt) mode = "overview"; updateHeader(); render(); });
|
|
850
866
|
languageButton.addEventListener("click", changeLanguage); statusBox.addEventListener("keydown", function(event){
|
|
851
867
|
if (event.key === "Escape") { event.preventDefault(); closeDialog(); return; }
|
|
@@ -867,7 +883,7 @@ export function officePage(nonce) {
|
|
|
867
883
|
return `<!doctype html>
|
|
868
884
|
<html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
869
885
|
<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>
|
|
886
|
+
<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
887
|
<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
888
|
<section id="status" role="dialog" aria-modal="true" aria-labelledby="status-title" tabindex="-1" hidden></section><script nonce="${nonce}">${script}</script></body></html>`;
|
|
873
889
|
}
|
|
@@ -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
|
|
77
|
+
const repo = repoOf(run);
|
|
78
|
+
const key = scoped(repo, run.runId);
|
|
79
|
+
const props = makeProps(key);
|
|
71
80
|
const actors = [];
|
|
72
|
-
const papers = { [
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
11
|
-
const
|
|
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(
|
|
87
|
-
const source = await readPrivateFile(officeRecordPath(
|
|
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
|
|
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(
|
|
107
|
-
return await withPrivateFileLock(officeRecordPath(
|
|
108
|
-
if (await readLiveOffice(
|
|
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(
|
|
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(
|
|
117
|
-
return await withPrivateFileLock(officeStartLockPath(
|
|
118
|
-
const live = await readLiveOffice(
|
|
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(
|
|
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(
|
|
134
|
-
await withPrivateFileLock(officeRecordPath(
|
|
135
|
-
const source = await readPrivateFile(officeRecordPath(
|
|
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(
|
|
141
|
+
await rm(officeRecordPath(home), { force: true });
|
|
138
142
|
});
|
|
139
143
|
}
|
|
@@ -100,16 +100,33 @@ export async function recordOfficeSession(options) {
|
|
|
100
100
|
root: options.projectRoot,
|
|
101
101
|
firstSeenAt: previous?.firstSeenAt ?? at,
|
|
102
102
|
lastSeenAt: at,
|
|
103
|
-
status: options.event === "stop" ? "idle" : "active",
|
|
103
|
+
status: options.event === "stop" || options.event === "end" ? "idle" : "active",
|
|
104
104
|
...(options.agentId ?? previous?.agentId ? { agentId: options.agentId ?? previous?.agentId } : {}),
|
|
105
105
|
...(options.runId ?? previous?.runId ? { runId: options.runId ?? previous?.runId } : {}),
|
|
106
106
|
...(options.workerId ?? previous?.workerId ? { workerId: options.workerId ?? previous?.workerId } : {}),
|
|
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
|
-
|
|
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 === "end"
|
|
113
|
+
? { completedAt: previous?.completedAt ?? at }
|
|
114
|
+
: options.event !== "start" && previous?.completedAt !== undefined ? { completedAt: previous.completedAt } : {})
|
|
111
115
|
};
|
|
112
116
|
const without = records.filter(record => record.sessionId !== options.sessionId);
|
|
113
117
|
await writePrivateFile(path, JSON.stringify({ schemaVersion: 1, sessions: [...without, next].slice(-MAX_SESSIONS) }) + "\n", options.commonDir);
|
|
114
118
|
});
|
|
115
119
|
}
|
|
120
|
+
/** Closes a session's room once `worktree finish` merged its work. Unknown sessions stay unknown. */
|
|
121
|
+
export async function markOfficeSessionCompleted(commonDir, sessionId, now = Date.now()) {
|
|
122
|
+
const path = officeSessionsPath(commonDir);
|
|
123
|
+
await withPrivateFileLock(path, commonDir, async () => {
|
|
124
|
+
const records = prune(await readRecords(commonDir), now);
|
|
125
|
+
if (!records.some(record => record.sessionId === sessionId))
|
|
126
|
+
return;
|
|
127
|
+
const at = new Date(now).toISOString();
|
|
128
|
+
const next = records.map(record => record.sessionId === sessionId
|
|
129
|
+
? { ...record, status: "idle", phase: "integrating", lastSeenAt: at, completedAt: at } : record);
|
|
130
|
+
await writePrivateFile(path, JSON.stringify({ schemaVersion: OFFICE_SESSION_SCHEMA_VERSION, sessions: next }) + "\n", commonDir);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
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) {
|
|
@@ -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,25 @@ 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
|
-
|
|
209
|
-
Ordinary session hooks also start or reuse it,
|
|
210
|
-
worktree exists. Only the successful new-server
|
|
211
|
-
|
|
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. With Office enabled,
|
|
221
|
+
Claude Code also gets a managed `SessionEnd` hook: when the conversation
|
|
222
|
+
closes, its room closes too, even without a task. It writes no output and
|
|
223
|
+
never fails the host. Other hosts publish no end event and rely on the
|
|
224
|
+
activity timeout. A room without a task shows a no-task board rather than
|
|
225
|
+
pending verification and review. Session metadata contains bounded
|
|
212
226
|
identifiers, known work state and activity times, never native transcripts,
|
|
213
227
|
prompts or tool/file contents. Startup and observation failures are advisory.
|
|
214
228
|
The server binds
|
|
@@ -177,10 +177,20 @@ 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 重用記錄於
|
|
181
|
-
|
|
182
|
-
Office,包含尚未建立 task 或
|
|
183
|
-
|
|
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 會重新開啟。啟用 Office 時,Claude Code
|
|
191
|
+
另有受管理的 `SessionEnd` hook:對話關閉時房間也隨之關閉,即使沒有 task。它不輸出
|
|
192
|
+
任何內容,也絕不讓 host 失敗。其他 host 沒有結束事件,依活動逾時處理。沒有 task 的
|
|
193
|
+
房間白板顯示「無任務」,而非待驗證與待審查。Session metadata
|
|
184
194
|
只記錄有界限的識別資訊、已知工作狀態與活動時間,不包含 native transcript、
|
|
185
195
|
prompt、tool 或檔案內容。啟動與活動觀察失敗均為 advisory。
|
|
186
196
|
Server 綁定 127.0.0.1 的隨機 port,要求 URL 中不可猜測的 token,拒絕該位址與
|
package/package.json
CHANGED