@kylecheng3146/agent-ops 0.7.3 → 0.8.3

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.
Files changed (65) hide show
  1. package/README.md +2 -0
  2. package/dist/packages/cli/src/args.js +72 -1
  3. package/dist/packages/cli/src/bin.js +69 -10
  4. package/dist/packages/cli/src/cli.js +11 -3
  5. package/dist/packages/cli/src/codex-loop-process.js +6 -0
  6. package/dist/packages/cli/src/commands/batch.js +3 -1
  7. package/dist/packages/cli/src/commands/hook.js +4 -1
  8. package/dist/packages/cli/src/commands/review.js +9 -3
  9. package/dist/packages/cli/src/commands/task.js +44 -2
  10. package/dist/packages/cli/src/commands/update.js +18 -0
  11. package/dist/packages/cli/src/commands/verify.js +3 -1
  12. package/dist/packages/cli/src/debug-intake.js +245 -0
  13. package/dist/packages/cli/src/hook-process.js +155 -34
  14. package/dist/packages/cli/src/office-entry.js +34 -6
  15. package/dist/packages/cli/src/run-deps.js +8 -0
  16. package/dist/runtime/src/adapters/agy/config.js +3 -2
  17. package/dist/runtime/src/adapters/claude/config.js +7 -3
  18. package/dist/runtime/src/adapters/codex/config.js +10 -3
  19. package/dist/runtime/src/adapters/codex/events.js +2 -1
  20. package/dist/runtime/src/adapters/codex/input.js +20 -2
  21. package/dist/runtime/src/adapters/codex/output.js +4 -1
  22. package/dist/runtime/src/config/merge.js +1 -1
  23. package/dist/runtime/src/hooks/completion-gate.js +15 -0
  24. package/dist/runtime/src/install/harness.js +2 -0
  25. package/dist/runtime/src/install/plan.js +3 -3
  26. package/dist/runtime/src/install/profiles.js +2 -0
  27. package/dist/runtime/src/install/update.js +10 -2
  28. package/dist/runtime/src/office/activity.js +105 -0
  29. package/dist/runtime/src/office/art.js +1000 -0
  30. package/dist/runtime/src/office/collect.js +137 -10
  31. package/dist/runtime/src/office/layout.js +203 -0
  32. package/dist/runtime/src/office/page.js +883 -644
  33. package/dist/runtime/src/office/scene.js +38 -33
  34. package/dist/runtime/src/office/sessions.js +18 -1
  35. package/dist/runtime/src/office/snapshot.js +74 -1
  36. package/dist/runtime/src/parallel/finish.js +4 -2
  37. package/dist/runtime/src/parallel/manage.js +19 -2
  38. package/dist/runtime/src/review/attestation.js +4 -1
  39. package/dist/runtime/src/review/batch.js +10 -0
  40. package/dist/runtime/src/review/execute.js +10 -1
  41. package/dist/runtime/src/review/slots.js +59 -44
  42. package/dist/runtime/src/run/hosts/codex.js +1 -1
  43. package/dist/runtime/src/run/transport.js +3 -0
  44. package/dist/runtime/src/schema/validate.js +8 -2
  45. package/dist/runtime/src/security/permissions.js +3 -1
  46. package/dist/runtime/src/task/completion.js +20 -1
  47. package/dist/runtime/src/task/contract.js +1 -1
  48. package/dist/runtime/src/task/debug-intake.js +12 -0
  49. package/dist/runtime/src/task/debug-state.js +130 -0
  50. package/dist/runtime/src/task/debug-user-event.js +93 -0
  51. package/dist/runtime/src/task/debug.js +586 -0
  52. package/dist/runtime/src/task/service.js +46 -0
  53. package/dist/runtime/src/task/store.js +8 -1
  54. package/dist/runtime/src/verify/acceptance-replay.js +9 -4
  55. package/dist/runtime/src/verify/service.js +21 -2
  56. package/docs/en/guides/configuration.md +7 -0
  57. package/docs/en/guides/task-debug.md +111 -0
  58. package/docs/en/spec/harness-adapters.md +31 -4
  59. package/docs/zh-TW/guides/configuration.md +6 -0
  60. package/docs/zh-TW/guides/task-debug.md +66 -0
  61. package/docs/zh-TW/spec/harness-adapters.md +23 -4
  62. package/package.json +1 -1
  63. package/schemas/config.schema.json +30 -2
  64. package/schemas/manifest.schema.json +1 -0
  65. package/schemas/task.schema.json +62 -2
@@ -35,7 +35,7 @@ export function sceneModel(snapshot) {
35
35
  return { passed: typeof p.passed === "number" ? p.passed : 0, total: typeof p.total === "number" ? p.total : 0,
36
36
  verify: p.verify === "PASS" || p.verify === "FAIL" ? p.verify : null, review: p.review === "PASS" || p.review === "FAIL" ? p.review : null };
37
37
  };
38
- const short = (value, limit = 18) => value.length > limit ? value.slice(0, limit - 1) + "…" : value;
38
+ const short = (value, limit = 18) => value.length > limit ? value.slice(0, limit - 6) + "…" + value.slice(-5) : value;
39
39
  const optional = (value) => value !== null && typeof value === "object" ? value : {};
40
40
  const boardFor = (progress, status, taskId, pending) => ({
41
41
  passed: progress?.passed ?? 0, total: progress?.total ?? 0,
@@ -49,7 +49,7 @@ export function sceneModel(snapshot) {
49
49
  return typeof repo === "string" && repo.length > 0 ? repo : null;
50
50
  };
51
51
  const scoped = (repo, key) => repo === null ? key : "repo:" + repo + "|" + key;
52
- const titled = (repo, title) => short(repo === null ? title : repo + " · " + title, 34);
52
+ const titled = (repo, title) => repo === null ? title : repo + " · " + title;
53
53
  const makeAreas = () => PHASES.map(phase => {
54
54
  const area = areas[phase];
55
55
  return { phase, x: area.x, y: area.y, width: area.width, height: area.height, prop: area.prop, label: labels[phase] };
@@ -70,7 +70,7 @@ export function sceneModel(snapshot) {
70
70
  { kind: "plant", x: 57, y: 34, key: key + ":plant-bottom" },
71
71
  { kind: "door", x: 65, y: 33, key: key + ":door" }
72
72
  ];
73
- const makeRun = (run) => {
73
+ const makeRun = (run, index) => {
74
74
  const extra = optional(run);
75
75
  const completedAt = typeof extra.completedAt === "string" ? extra.completedAt : null;
76
76
  const questions = run.questions;
@@ -95,34 +95,43 @@ export function sceneModel(snapshot) {
95
95
  total += p?.total ?? 0;
96
96
  if (p?.verify === "FAIL")
97
97
  verify = "FAIL";
98
- else if (p?.verify === "PASS" && verify === null)
99
- verify = "PASS";
100
98
  if (p?.review === "FAIL")
101
99
  review = "FAIL";
102
- else if (p?.review === "PASS" && review === null)
103
- review = "PASS";
104
100
  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,
105
101
  label: short(agent.id), status: agent.status, phase, taskId: agent.taskId || "unassigned", progress: p,
106
102
  narration: agent.narration, questionCount: agent.role === "coordinator" ? questions.length : 0, host: "unknown" });
107
103
  dialogue.push(`${run.title}: ${agent.id} ${agent.narration}.`);
108
104
  });
109
- run.reviewers.forEach((reviewer, index) => {
110
- const reviewerIndex = actors.filter(actor => actor.phase === "reviewing").length;
111
- const reviewerCount = run.agents.filter(agent => agent.phase === "reviewing").length + run.reviewers.length;
112
- const point = position("reviewing", reviewerIndex, reviewerCount);
113
- actors.push({ key: key + ":review:" + reviewer.slot, id: "reviewer " + reviewer.slot, kind: "reviewer", x: point.x, y: point.y, alert: false,
114
- label: "reviewer " + reviewer.slot, status: "reviewing", phase: "reviewing", taskId: reviewer.taskId ?? "unassigned",
115
- progress: null, narration: "reviewing", questionCount: 0, host: "unknown" });
105
+ if (verify === null && run.agents.length && run.agents.every(agent => agent.progress?.verify === "PASS"))
106
+ verify = "PASS";
107
+ if (review === null && run.agents.length && run.agents.every(agent => agent.progress?.review === "PASS"))
108
+ review = "PASS";
109
+ const rootProgress = Object.prototype.hasOwnProperty.call(extra, "progress") ? progressOf(extra.progress) : { passed, total, verify, review };
110
+ const criteria = (Array.isArray(extra.criteria) ? extra.criteria : []).map(value => {
111
+ const item = optional(value);
112
+ return { id: typeof item.id === "string" ? item.id : "unknown", description: typeof item.description === "string" ? item.description : "",
113
+ status: item.status === "PASS" || item.status === "FAIL" || item.status === "UNKNOWN" ? item.status : null,
114
+ finishedAt: typeof item.finishedAt === "string" ? item.finishedAt : null,
115
+ failureClass: typeof item.failureClass === "string" ? item.failureClass : null,
116
+ exitCode: typeof item.exitCode === "number" ? item.exitCode : null, output: typeof item.output === "string" ? item.output : null };
116
117
  });
117
- const rootProgress = { passed, total, verify, review };
118
- const taskId = run.agents.find(agent => agent.role === "coordinator")?.taskId ?? "unassigned";
118
+ const taskId = typeof extra.taskId === "string" && extra.taskId.length > 0
119
+ ? extra.taskId : run.agents.find(agent => agent.role === "coordinator")?.taskId ?? "unassigned";
119
120
  if (questions.length > 0)
120
121
  dialogue.push(`${run.title}: waiting for your answer — ${questions[0].prompt}`);
121
- return { key, sourceIndex: null, kind: "run", title: titled(repo, run.title), top: 0, props, actors, phaseAreas: makeAreas(), questions,
122
- board: boardFor(rootProgress, run.status, taskId, questions.length), papers, books: { lit: passed, total },
122
+ return { key, sourceIndex: index, kind: "run", title: titled(repo, run.title), top: 0, props, actors, phaseAreas: makeAreas(), questions, criteria,
123
+ activities: activitiesOf(optional(run).activities),
124
+ board: boardFor(rootProgress, run.status, taskId, questions.length), papers, books: { lit: rootProgress?.passed ?? 0, total: rootProgress?.total ?? 0 },
123
125
  clock: run.budget.limitMs > 0 ? run.budget.remainingMs / run.budget.limitMs : 0, phase: run.phase, status: run.status,
124
126
  completedAt, repo, overview: { x: 0, y: 0, width: 0, height: 0 } };
125
127
  };
128
+ const activitiesOf = (value) => (Array.isArray(value) ? value : []).map(raw => {
129
+ const item = optional(raw);
130
+ return { kind: item.kind === "verify" || item.kind === "review" || item.kind === "finish" ? item.kind : "verify",
131
+ startedAt: typeof item.startedAt === "string" ? item.startedAt : "",
132
+ targets: Array.isArray(item.targets) ? item.targets.filter((t) => typeof t === "string").slice(0, 2) : [],
133
+ round: item.round === 1 || item.round === 2 ? item.round : null, target: typeof item.target === "string" ? item.target : null };
134
+ });
126
135
  const makeDesk = (desk, index) => {
127
136
  const extra = optional(desk);
128
137
  const phase = extra.phase === "planning" || extra.phase === "implementing" || extra.phase === "verifying" || extra.phase === "reviewing" || extra.phase === "integrating" ? extra.phase : "unknown";
@@ -135,6 +144,15 @@ export function sceneModel(snapshot) {
135
144
  return { questionId: typeof item.questionId === "string" ? item.questionId : "unknown", prompt: typeof item.prompt === "string" ? item.prompt : "" };
136
145
  });
137
146
  const completedAt = typeof extra.completedAt === "string" ? extra.completedAt : null;
147
+ // Inline checks, no named helper: the page embeds sceneModel via toString, so it cannot carry bundler helpers.
148
+ const criteria = (Array.isArray(extra.criteria) ? extra.criteria : []).map(value => {
149
+ const item = optional(value);
150
+ return { id: typeof item.id === "string" ? item.id : "unknown", description: typeof item.description === "string" ? item.description : "",
151
+ status: item.status === "PASS" || item.status === "FAIL" || item.status === "UNKNOWN" ? item.status : null,
152
+ finishedAt: typeof item.finishedAt === "string" ? item.finishedAt : null,
153
+ failureClass: typeof item.failureClass === "string" ? item.failureClass : null,
154
+ exitCode: typeof item.exitCode === "number" ? item.exitCode : null, output: typeof item.output === "string" ? item.output : null };
155
+ });
138
156
  const repo = repoOf(desk);
139
157
  const key = scoped(repo, "session:" + [desk.sessionId || "unknown", desk.branch || "unknown", desk.name || "session"].join(":"));
140
158
  const props = makeProps(key);
@@ -146,24 +164,11 @@ export function sceneModel(snapshot) {
146
164
  dialogue.push(`${desk.name}: waiting for your answer.`);
147
165
  dialogue.push(`${desk.name}: ${desk.narration}.`);
148
166
  return { key, sourceIndex: index, kind: "desk", title: titled(repo, desk.name), top: 0, props, actors: [actor], phaseAreas: makeAreas(),
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 },
167
+ board: boardFor(p, status, taskId, questions.length), questions, criteria, activities: activitiesOf(extra.activities), papers: { [key + ":implementing"]: Math.min(8, desk.diff.files) }, books: { lit: p?.passed ?? 0, total: p?.total ?? 0 },
150
168
  clock: 1, phase, status, completedAt, repo, overview: { x: 0, y: 0, width: 0, height: 0 } };
151
169
  };
152
- snapshot.runs.forEach(run => floors.push(makeRun(run)));
170
+ snapshot.runs.forEach((run, index) => floors.push(makeRun(run, index)));
153
171
  snapshot.lobby.forEach((desk, index) => floors.push(makeDesk(desk, index)));
154
- snapshot.reviews.forEach((review, index) => {
155
- const repo = repoOf(review);
156
- const key = scoped(repo, "review:" + review.slot + ":" + index);
157
- const props = makeProps(key);
158
- const point = position("reviewing", 0);
159
- const actor = { key: key + ":actor", id: "reviewer " + review.slot, kind: "reviewer", x: point.x, y: point.y, alert: false, label: "reviewer " + review.slot,
160
- status: "reviewing", phase: "reviewing", taskId: review.taskId ?? "unassigned", progress: null,
161
- narration: "reviewing", questionCount: 0, host: "unknown" };
162
- dialogue.push(`Review slot ${review.slot}: ${review.taskId ?? "unassigned"}.`);
163
- floors.push({ key, sourceIndex: index, kind: "review", title: titled(repo, "Review slot " + review.slot), top: 0, props, actors: [actor], phaseAreas: makeAreas(), questions: [],
164
- board: boardFor(null, "reviewing", review.taskId ?? "unassigned", 0), papers: {}, books: { lit: 0, total: 0 }, clock: 1,
165
- phase: "reviewing", status: "reviewing", completedAt: null, repo, overview: { x: 0, y: 0, width: 0, height: 0 } });
166
- });
167
172
  // Rooms of one repository sit together; the sort is stable inside each.
168
173
  floors.sort((a, b) => (a.repo ?? "").localeCompare(b.repo ?? ""));
169
174
  if (dialogue.length === 0)
@@ -99,7 +99,8 @@ export async function recordOfficeSession(options) {
99
99
  root: options.projectRoot,
100
100
  firstSeenAt: previous?.firstSeenAt ?? at,
101
101
  lastSeenAt: at,
102
- status: options.event === "stop" || options.event === "end" ? "idle" : "active",
102
+ // A session that only started is waiting at its prompt; work begins with activity.
103
+ status: options.event === "activity" ? "active" : "idle",
103
104
  ...(options.agentId ?? previous?.agentId ? { agentId: options.agentId ?? previous?.agentId } : {}),
104
105
  ...(options.runId ?? previous?.runId ? { runId: options.runId ?? previous?.runId } : {}),
105
106
  ...(options.workerId ?? previous?.workerId ? { workerId: options.workerId ?? previous?.workerId } : {}),
@@ -116,6 +117,22 @@ export async function recordOfficeSession(options) {
116
117
  await writePrivateFile(path, JSON.stringify({ schemaVersion: 1, sessions: [...without, next].slice(-MAX_SESSIONS) }) + "\n", options.commonDir);
117
118
  });
118
119
  }
120
+ /**
121
+ * A long command's later step (batch, advance) on the session that runs it,
122
+ * which hooks only see start. Unknown sessions stay unknown.
123
+ */
124
+ export async function recordOfficeSessionPhase(commonDir, sessionId, phase, now = Date.now()) {
125
+ const path = officeSessionsPath(commonDir);
126
+ await withPrivateFileLock(path, commonDir, async () => {
127
+ const records = prune(await readRecords(commonDir), now);
128
+ if (!records.some(record => record.sessionId === sessionId))
129
+ return;
130
+ const at = new Date(now).toISOString();
131
+ const next = records.map(record => record.sessionId === sessionId
132
+ ? { ...record, status: "active", phase, lastSeenAt: at } : record);
133
+ await writePrivateFile(path, JSON.stringify({ schemaVersion: OFFICE_SESSION_SCHEMA_VERSION, sessions: next }) + "\n", commonDir);
134
+ });
135
+ }
119
136
  /** Closes a session's room once `worktree finish` merged its work. Unknown sessions stay unknown. */
120
137
  export async function markOfficeSessionCompleted(commonDir, sessionId, now = Date.now()) {
121
138
  const path = officeSessionsPath(commonDir);
@@ -29,6 +29,13 @@ export function buildOfficeSnapshot(input) {
29
29
  const reviewers = input.reviews.filter(r => r.root !== null && roots.has(r.root)).slice(0, 2);
30
30
  reviewers.forEach(r => claimed.add(r));
31
31
  const usedMs = activeWallTimeMs(run.budget.activeIntervals, input.now);
32
+ const coordinator = run.workers.find(worker => worker.workerId === run.coordinatorId);
33
+ // rootTaskId is the persisted run authority. Only legacy runs without it
34
+ // fall back to the coordinator's task id.
35
+ const rootTaskId = run.rootTaskId ?? (coordinator?.taskId || null);
36
+ const rootTask = rootTaskId === null ? undefined : run.tasks.find(task => task.taskId === rootTaskId);
37
+ const rootTree = rootTaskId === null ? undefined : input.worktrees.find(tree => tree.taskId === rootTaskId &&
38
+ (tree.runId === run.runId || tree.path === coordinator?.worktree));
32
39
  const agents = run.workers.filter(w => w.workerId === run.coordinatorId || LIVE.has(w.status)).map((w) => {
33
40
  const tree = w.worktree === null ? undefined : byPath.get(w.worktree);
34
41
  const phase = w.phase ?? "unknown";
@@ -39,6 +46,11 @@ export function buildOfficeSnapshot(input) {
39
46
  });
40
47
  const questions = run.questions.filter(q => q.answeredAt === null).map(q => ({ questionId: q.questionId, prompt: q.prompt }));
41
48
  return { runId: run.runId, title: run.rootTaskId ?? run.runId,
49
+ ...(rootTaskId === null ? {} : { taskId: rootTaskId }),
50
+ // The root task is the only source of run-level proof. A
51
+ // missing proof stays null; worker progress must never become root proof.
52
+ ...(rootTaskId === null ? {} : { progress: rootTree?.progress ?? rootTask?.progress ?? null,
53
+ ...(rootTree?.criteria === undefined ? {} : { criteria: rootTree.criteria }) }),
42
54
  status: run.status, phase: run.phase ?? "unknown",
43
55
  budget: { limitMs: run.budget.limitMs, usedMs, remainingMs: Math.max(0, run.budget.limitMs - usedMs) },
44
56
  questions, agents, reviewers,
@@ -57,6 +69,7 @@ export function buildOfficeSnapshot(input) {
57
69
  runSessionIds.add(worker.nativeSessionId);
58
70
  });
59
71
  const lobby = [];
72
+ const deskPaths = new Map();
60
73
  const represented = new Set();
61
74
  for (const worktree of input.worktrees) {
62
75
  if (worktree.runId !== undefined)
@@ -78,9 +91,15 @@ export function buildOfficeSnapshot(input) {
78
91
  ...(worktree.status === undefined && session?.taskStatus === undefined && session?.status === undefined ? {} : { status: worktree.status ?? session?.taskStatus ?? session?.status }),
79
92
  ...(worktree.taskId === undefined && session?.taskId === undefined ? {} : { taskId: worktree.taskId ?? session?.taskId ?? null }),
80
93
  ...(worktree.progress === undefined && session?.progress === undefined ? {} : { progress: worktree.progress ?? session?.progress ?? null }),
94
+ ...(worktree.title === undefined && session?.title === undefined ? {} : { title: worktree.title ?? session?.title }),
95
+ ...(worktree.criteria === undefined && session?.criteria === undefined ? {} : { criteria: worktree.criteria ?? session?.criteria ?? [] }),
96
+ ...(worktree.review === undefined && session?.review === undefined ? {} : { review: worktree.review ?? session?.review ?? null }),
97
+ ...(worktree.base === undefined ? {} : { base: worktree.base }),
98
+ ...(worktree.ahead === undefined ? {} : { ahead: worktree.ahead }),
81
99
  ...(completedAt === null && session?.completedAt === undefined ? {} : { completedAt }),
82
100
  ...(worktree.host === undefined && session?.host === undefined ? {} : { host: worktree.host ?? session?.host }),
83
101
  });
102
+ deskPaths.set(lobby.length - 1, worktree.path);
84
103
  represented.add(worktree.sessionId);
85
104
  }
86
105
  for (const session of sessions) {
@@ -100,11 +119,16 @@ export function buildOfficeSnapshot(input) {
100
119
  status: session.taskStatus ?? session.status,
101
120
  ...(session.taskId === undefined ? {} : { taskId: session.taskId }),
102
121
  ...(session.progress === undefined ? {} : { progress: session.progress }),
122
+ ...(session.title === undefined ? {} : { title: session.title }),
123
+ ...(session.criteria === undefined ? {} : { criteria: session.criteria }),
124
+ ...(session.review === undefined ? {} : { review: session.review }),
103
125
  ...(completedAt === null ? {} : { completedAt }),
104
126
  ...(session.host === undefined ? {} : { host: session.host })
105
127
  });
106
128
  }
107
- return { generatedAt: new Date(input.now).toISOString(), runs, lobby, reviews: input.reviews.filter(r => !claimed.has(r)) };
129
+ attachActivities(input, runs, lobby, deskPaths);
130
+ // Review slots no longer make rooms: a running review shows up as reviewers in its own room.
131
+ return { generatedAt: new Date(input.now).toISOString(), runs, lobby, reviews: [] };
108
132
  }
109
133
  /** One building from several repositories' snapshots; every room names its repository. */
110
134
  export function mergeOfficeSnapshots(parts, now) {
@@ -115,3 +139,52 @@ export function mergeOfficeSnapshots(parts, now) {
115
139
  reviews: parts.flatMap(({ repo, snapshot }) => snapshot.reviews.map(review => ({ ...review, repo })))
116
140
  };
117
141
  }
142
+ const ACTIVITY_PHASE = { verify: "verifying", review: "reviewing", finish: "integrating" };
143
+ function activityView(activity) {
144
+ return { kind: activity.kind, startedAt: activity.startedAt, ...(activity.targets === undefined ? {} : { targets: activity.targets }),
145
+ ...(activity.round === undefined ? {} : { round: activity.round }), ...(activity.target === undefined ? {} : { target: activity.target }) };
146
+ }
147
+ /**
148
+ * Puts each live activity in its room: the same session, else the same task,
149
+ * else the worktree it runs in. One that matches no room gets a room of its
150
+ * own for as long as it runs, so nothing that is running goes unseen.
151
+ */
152
+ function attachActivities(input, runs, lobby, deskPaths) {
153
+ const runSessions = (run) => new Set(run.workers.flatMap(worker => [worker.ownerSessionId, worker.nativeSessionId].filter((id) => id !== null)));
154
+ for (const activity of input.activities ?? []) {
155
+ const view = activityView(activity);
156
+ const toDesk = (index) => { const desk = lobby[index]; lobby[index] = { ...desk, activities: [...(desk.activities ?? []), view] }; };
157
+ const toRun = (index) => { const run = runs[index]; runs[index] = { ...run, activities: [...(run.activities ?? []), view] }; };
158
+ const sourceRun = (run) => input.runs.find(state => state.runId === run.runId);
159
+ const matchers = [
160
+ [desk => activity.sessionId !== undefined && desk.sessionId === activity.sessionId,
161
+ run => activity.sessionId !== undefined && runSessions(sourceRun(run)).has(activity.sessionId)],
162
+ [desk => activity.taskId !== undefined && desk.taskId === activity.taskId,
163
+ run => activity.taskId !== undefined && run.agents.some(agent => agent.taskId === activity.taskId)],
164
+ [(_, index) => deskPaths.get(index) === activity.root,
165
+ run => sourceRun(run).workers.some(worker => worker.worktree === activity.root)]
166
+ ];
167
+ let placed = false;
168
+ for (const [deskMatch, runMatch] of matchers) {
169
+ const desk = lobby.findIndex(deskMatch);
170
+ if (desk >= 0) {
171
+ toDesk(desk);
172
+ placed = true;
173
+ break;
174
+ }
175
+ const run = runs.findIndex(candidate => sourceRun(candidate) !== undefined && runMatch(candidate));
176
+ if (run >= 0) {
177
+ toRun(run);
178
+ placed = true;
179
+ break;
180
+ }
181
+ }
182
+ if (placed)
183
+ continue;
184
+ const name = activity.taskId ?? activity.worktree ?? activity.kind;
185
+ lobby.push({ name, branch: "(no worktree)", sessionId: activity.sessionId ?? `activity-${activity.kind}-${activity.pid}`, sessionActive: true,
186
+ diff: { files: 0, insertions: 0, deletions: 0, paths: [], recent: null }, narration: ACTIVITY_PHASE[activity.kind],
187
+ commands: activity.taskId === undefined ? [] : [`agent-ops task status --task ${quote(activity.taskId)}`],
188
+ phase: ACTIVITY_PHASE[activity.kind], status: "active", ...(activity.taskId === undefined ? {} : { taskId: activity.taskId }), activities: [view] });
189
+ }
190
+ }
@@ -1,3 +1,4 @@
1
+ import { withActivity } from "../office/activity.js";
1
2
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { join } from "node:path";
@@ -233,7 +234,8 @@ export async function finishWorktree(deps, options) {
233
234
  if (record === null) {
234
235
  throw finishError("WORKTREE_NOT_FOUND", `No agent-ops worktree named ${name}; see agent-ops worktree list.`);
235
236
  }
236
- return await withFinishLock(deps, commonDir, async () => {
237
+ // Display only: the integrator is at work while the finish runs.
238
+ return await withActivity(commonDir, { kind: "finish", root: record.path, sessionId: record.sessionId, worktree: name }, async () => await withFinishLock(deps, commonDir, async () => {
237
239
  const sessionChildren = (await listWorktrees(deps, mainRoot))
238
240
  .map(({ record: child }) => child)
239
241
  .filter((child) => child.path !== record.path && child.sessionId === record.sessionId &&
@@ -507,5 +509,5 @@ export async function finishWorktree(deps, options) {
507
509
  await markRunIntegration(commonDir, record, "cleaned", "coordinator");
508
510
  return { record, mergedHead, rebased: !fastForward, taskIds: tree.map(({ task }) => task.task.id), warnings,
509
511
  ...(receipt === undefined ? {} : { receipt: receipt.path }) };
510
- });
512
+ }));
511
513
  }
@@ -3,6 +3,7 @@ import { join, resolve } from "node:path";
3
3
  import { sha256 } from "../fs/hash.js";
4
4
  import { AgentOpsError } from "../fs/paths.js";
5
5
  import { collectChangeSurface } from "../verify/change-surface.js";
6
+ import { FileRunRepository } from "../run/service.js";
6
7
  import { assertSessionId, assertWorktreeName, bindSession, insideWorktreeDirectory, parseWorktreeListPorcelain, readWorktreeRecord, assertRunWorktreeOwnership, removeCheckout, resolveCheckouts, worktreePath, writeWorktreeRecord } from "./service.js";
7
8
  /**
8
9
  * A baseline no source state hashes to. A resumed session inherits whatever
@@ -141,8 +142,11 @@ export async function resumeWorktree(deps, options) {
141
142
  const sessionId = assertSessionId(options.sessionId);
142
143
  const mainRoot = await mainCheckout(deps, options.cwd);
143
144
  const previous = await requireRecord(mainRoot, name);
144
- const tasks = deps.tasks(previous.path);
145
+ // A base gives the service repository context, which debug resume needs.
146
+ const tasks = deps.tasks(previous.path, previous.base);
145
147
  const attached = await tasks.status({ sessionId: previous.sessionId }).catch(() => null);
148
+ if (attached?.task.debug !== undefined)
149
+ await tasks.debugService().resume(attached.task.id, sessionId);
146
150
  if (attached !== null)
147
151
  await tasks.attach(sessionId, attached.task.id);
148
152
  const record = { ...previous, sessionId };
@@ -206,6 +210,15 @@ export async function removeWorktree(deps, options) {
206
210
  const name = assertWorktreeName(options.name);
207
211
  const mainRoot = await mainCheckout(deps, options.cwd);
208
212
  const status = await describe(deps, await requireRecord(mainRoot, name));
213
+ // A running run still writes here; --force discards work, it does not stop the run.
214
+ const runId = status.record.runId;
215
+ if (runId !== undefined) {
216
+ const commonDir = join(mainRoot, ".git");
217
+ const run = await new FileRunRepository(join(commonDir, "agent-ops", "runs"), commonDir).read(runId).catch(() => null);
218
+ if (run !== null && ["active", "awaiting-input", "stopping"].includes(run.status)) {
219
+ throw manageError("WORKTREE_RUN_ACTIVE", `${name} belongs to run ${runId}, which is ${run.status}. Stop it first: agent-ops run stop ${runId}.`);
220
+ }
221
+ }
209
222
  if ((status.dirty || status.ahead > 0) && !options.force) {
210
223
  throw manageError("WORKTREE_UNMERGED", `${name} has ${status.dirty ? "uncommitted changes" : ""}${status.dirty && status.ahead > 0 ? " and " : ""}${status.ahead > 0 ? `${status.ahead} commit(s) not in ${status.record.targetBranch}` : ""}. Finish it, or remove it with --force to discard that work (the user is asked).`);
211
224
  }
@@ -217,7 +230,11 @@ export async function removeWorktree(deps, options) {
217
230
  ? mainGate.redirect(record.sessionId, null)
218
231
  : mainGate.removeRoot(record.sessionId, record.path));
219
232
  }
220
- await deps.trust.revoke(record.path, await deps.loadConfig(record.path)).catch(() => undefined);
233
+ // A paused or stopped run's worktree has no loadable policy config; its trust
234
+ // was never this checkout's to revoke, so removal must not depend on it.
235
+ const config = await deps.loadConfig(record.path).catch(() => null);
236
+ if (config !== null)
237
+ await deps.trust.revoke(record.path, config).catch(() => undefined);
221
238
  await removeCheckout(deps, record, true);
222
239
  return status;
223
240
  }
@@ -17,7 +17,9 @@ const SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/u;
17
17
  const REPORT_ARTIFACT_MAX_BYTES = 512 * 1024;
18
18
  function validContractBinding(value) {
19
19
  const hashes = [value.taskContractHash, value.goalHash, value.coverageDigest];
20
- return hashes.every(v => typeof v === "string" && DIGEST_PATTERN.test(v)) &&
20
+ return (value.debugProofs === undefined || typeof value.debugProofs === "object" && value.debugProofs !== null && !Array.isArray(value.debugProofs) &&
21
+ Object.entries(value.debugProofs).every(([id, hash]) => TASK_ID_PATTERN.test(id) && typeof hash === "string" && DIGEST_PATTERN.test(hash))) &&
22
+ hashes.every(v => typeof v === "string" && DIGEST_PATTERN.test(v)) &&
21
23
  (value.treeContractHash === undefined || typeof value.treeContractHash === "string" && DIGEST_PATTERN.test(value.treeContractHash)) &&
22
24
  typeof value.candidateCommit === "string" && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(value.candidateCommit) &&
23
25
  typeof value.taskContracts === "object" && value.taskContracts !== null && !Array.isArray(value.taskContracts) &&
@@ -26,6 +28,7 @@ function validContractBinding(value) {
26
28
  }
27
29
  function contractBinding(value) {
28
30
  return validContractBinding(value) ? {
31
+ ...(value.debugProofs === undefined ? {} : { debugProofs: value.debugProofs }),
29
32
  taskContractHash: value.taskContractHash, taskContracts: value.taskContracts,
30
33
  ...(value.treeContractHash === undefined ? {} : { treeContractHash: value.treeContractHash }),
31
34
  goalHash: value.goalHash, candidateCommit: value.candidateCommit, coverageDigest: value.coverageDigest
@@ -25,6 +25,14 @@ export async function runBatch(ids, deps, options) {
25
25
  throw new AgentOpsError("BATCH_INVALID_WIDTH", "Batch width must be a positive integer.");
26
26
  }
27
27
  const progress = options.onProgress ?? (() => { });
28
+ const phase = (value) => {
29
+ try {
30
+ options.onPhase?.(value);
31
+ }
32
+ catch {
33
+ // Display only.
34
+ }
35
+ };
28
36
  const controller = new AbortController();
29
37
  const outer = options.signal;
30
38
  if (outer?.aborted === true) {
@@ -71,6 +79,7 @@ export async function runBatch(ids, deps, options) {
71
79
  entry.retried = true;
72
80
  }
73
81
  let result;
82
+ phase("reviewing");
74
83
  try {
75
84
  result = await deps.review(id, controller.signal);
76
85
  }
@@ -127,6 +136,7 @@ export async function runBatch(ids, deps, options) {
127
136
  break;
128
137
  }
129
138
  let status;
139
+ phase("verifying");
130
140
  try {
131
141
  status = await deps.verify(id, controller.signal);
132
142
  }
@@ -690,6 +690,13 @@ async function attemptTargetWithRetry(request, options) {
690
690
  return attemptTarget({ ...request, resumeLostTurn: false }, options);
691
691
  }
692
692
  /** Builds the `execute` callback with one necessary and one adversarial review. */
693
+ /** Display only: a failing listener never touches the review. */
694
+ function announceRound(options, round, target, targets) {
695
+ try {
696
+ options.onRound?.(round, target, targets);
697
+ }
698
+ catch { /* display only */ }
699
+ }
693
700
  export function createReviewExecutor(options) {
694
701
  const report = options.onProgress ?? (() => { });
695
702
  const host = detectHostTarget(options.env ?? process.env);
@@ -698,7 +705,7 @@ export function createReviewExecutor(options) {
698
705
  const clock = options.now ?? (() => Date.now());
699
706
  return async (request) => {
700
707
  const deadlineAt = clock() +
701
- (options.chainTimeoutMs ?? DEFAULT_REVIEW_CHAIN_TIMEOUT_MS);
708
+ Math.min(options.chainTimeoutMs ?? DEFAULT_REVIEW_CHAIN_TIMEOUT_MS, await options.remainingBudgetMs?.() ?? Infinity);
702
709
  const remainingMs = () => deadlineAt - clock();
703
710
  /**
704
711
  * The options one stage runs under: its own timeout, or what is left of
@@ -802,6 +809,7 @@ export function createReviewExecutor(options) {
802
809
  report("chain: review deadline reached before the first round");
803
810
  return { status: "NOT_RUN", reason: "timeout", attempts: [] };
804
811
  }
812
+ announceRound(options, 1, primaryTarget, plannedTargets);
805
813
  const primaryOutcome = await attemptTargetWithRetry({
806
814
  ...shared,
807
815
  target: primaryTarget,
@@ -911,6 +919,7 @@ export function createReviewExecutor(options) {
911
919
  };
912
920
  }
913
921
  // The second round draws on what is left, never a fresh full budget.
922
+ announceRound(options, 2, adversarialTarget, plannedTargets);
914
923
  const adversarialOutcome = await attemptTargetWithRetry({
915
924
  ...shared,
916
925
  target: adversarialTarget,
@@ -48,62 +48,77 @@ export async function withReviewSlot(options, action) {
48
48
  }
49
49
  const sleep = options.sleep ?? (async (ms) => await new Promise((resolve) => setTimeout(resolve, ms)));
50
50
  let waiting = false;
51
+ let waitEnded = false;
51
52
  let held;
52
53
  const token = randomUUID();
53
- while (held === undefined) {
54
- if (options.signal?.aborted === true)
55
- throw new ReviewInterruptedError(String(options.signal.reason ?? ""));
56
- for (let index = 0; index < width && held === undefined; index += 1) {
57
- const path = join(options.dir, `agent-ops-review-slot-${index}.lock`);
58
- try {
59
- const claimed = await withPrivateFileLock(path + ".state", options.dir, async () => {
60
- if (options.signal?.aborted === true)
61
- throw new ReviewInterruptedError(String(options.signal.reason ?? ""));
62
- for (;;) {
63
- try {
64
- await mkdir(path);
65
- await writeFile(join(path, "owner.json"), JSON.stringify({ ...options.holder, pid: process.pid, at: Date.now(), token }));
66
- return true;
67
- }
68
- catch (error) {
69
- if (error.code !== "EEXIST")
70
- throw error;
71
- if (!await stale(path, options.staleMs ?? STALE_MS))
72
- return false;
73
- await rm(path, { recursive: true, force: true });
54
+ try {
55
+ while (held === undefined) {
56
+ if (options.signal?.aborted === true)
57
+ throw new ReviewInterruptedError(String(options.signal.reason ?? ""));
58
+ for (let index = 0; index < width && held === undefined; index += 1) {
59
+ const path = join(options.dir, `agent-ops-review-slot-${index}.lock`);
60
+ try {
61
+ const claimed = await withPrivateFileLock(path + ".state", options.dir, async () => {
62
+ if (options.signal?.aborted === true)
63
+ throw new ReviewInterruptedError(String(options.signal.reason ?? ""));
64
+ for (;;) {
65
+ try {
66
+ await mkdir(path);
67
+ await writeFile(join(path, "owner.json"), JSON.stringify({ ...options.holder, pid: process.pid, at: Date.now(), token }));
68
+ return true;
69
+ }
70
+ catch (error) {
71
+ if (error.code !== "EEXIST")
72
+ throw error;
73
+ if (!await stale(path, options.staleMs ?? STALE_MS))
74
+ return false;
75
+ await rm(path, { recursive: true, force: true });
76
+ }
74
77
  }
75
- }
76
- });
77
- if (claimed)
78
- held = path;
78
+ });
79
+ if (claimed)
80
+ held = path;
81
+ }
82
+ catch (error) {
83
+ if (!(error instanceof AgentOpsError) || error.code !== "PRIVATE_STATE_LOCK_TIMEOUT")
84
+ throw error;
85
+ }
79
86
  }
80
- catch (error) {
81
- if (!(error instanceof AgentOpsError) || error.code !== "PRIVATE_STATE_LOCK_TIMEOUT")
82
- throw error;
87
+ if (held === undefined) {
88
+ if (!waiting) {
89
+ waiting = true;
90
+ await options.onWaitStart?.();
91
+ options.onWait?.(`review: waiting for one of ${String(width)} shared review slots`);
92
+ }
93
+ await sleep(options.pollMs ?? POLL_MS);
83
94
  }
84
95
  }
85
- if (held === undefined) {
86
- if (!waiting) {
87
- waiting = true;
88
- options.onWait?.(`review: waiting for one of ${String(width)} shared review slots`);
89
- }
90
- await sleep(options.pollMs ?? POLL_MS);
96
+ if (waiting) {
97
+ waitEnded = true;
98
+ await options.onWaitEnd?.(true);
91
99
  }
92
- }
93
- try {
94
100
  if (options.signal?.aborted === true)
95
101
  throw new ReviewInterruptedError(String(options.signal.reason ?? ""));
96
102
  return await action();
97
103
  }
98
104
  finally {
99
- await withPrivateFileLock(held + ".state", options.dir, async () => {
100
- const owner = await readFile(join(held, "owner.json"), "utf8").catch(error => {
101
- if (error.code === "ENOENT")
102
- return null;
103
- throw error;
105
+ if (waiting && !waitEnded) {
106
+ waitEnded = true;
107
+ try {
108
+ await options.onWaitEnd?.(false);
109
+ }
110
+ catch { /* preserve the original wait/abort failure */ }
111
+ }
112
+ if (held !== undefined) {
113
+ await withPrivateFileLock(held + ".state", options.dir, async () => {
114
+ const owner = await readFile(join(held, "owner.json"), "utf8").catch(error => {
115
+ if (error.code === "ENOENT")
116
+ return null;
117
+ throw error;
118
+ });
119
+ if (owner !== null && JSON.parse(owner)?.token === token)
120
+ await rm(held, { recursive: true, force: true });
104
121
  });
105
- if (owner !== null && JSON.parse(owner)?.token === token)
106
- await rm(held, { recursive: true, force: true });
107
- });
122
+ }
108
123
  }
109
124
  }
@@ -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.7.3"
239
+ version: "0.8.2"
240
240
  }
241
241
  });
242
242
  session.notify("initialized", {});
@@ -2,10 +2,13 @@ import { nativeProcessGroupAlive, signalNativeProcessGroup } from "./hosts/util.
2
2
  import { execFile } from "node:child_process";
3
3
  import { promisify } from "node:util";
4
4
  import { AgentOpsError } from "../fs/paths.js";
5
+ import { readWindowsProcessIdentity } from "../security/permissions.js";
5
6
  const exec = promisify(execFile);
6
7
  export async function nativeProcessIdentity(pid) {
7
8
  if (!Number.isSafeInteger(pid) || pid < 1)
8
9
  return null;
10
+ if (process.platform === "win32")
11
+ return await readWindowsProcessIdentity(pid);
9
12
  try {
10
13
  const { stdout } = await exec("ps", ["-p", String(pid), "-o", "lstart="], { maxBuffer: 8192 });
11
14
  return stdout.trim() || null;