@kylecheng3146/agent-ops 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -257,6 +257,8 @@ Init and update pre-authorize `agent-ops run` for Claude Code and Codex.
257
257
 
258
258
  ```bash
259
259
  agent-ops init --scope project --harness codex,claude --profile run --yes
260
+ # an existing installation: add it, or later remove it (loop stays)
261
+ agent-ops update --auto-run on
260
262
  ```
261
263
 
262
264
  ## Quick start from a source checkout
@@ -95,6 +95,7 @@ export function parseArgs(argv) {
95
95
  const checkAuthTargets = [];
96
96
  let completionGate;
97
97
  let office;
98
+ let autoRun;
98
99
  let dryRun = false;
99
100
  let json = false;
100
101
  let yes = false;
@@ -328,6 +329,16 @@ export function parseArgs(argv) {
328
329
  index += 1;
329
330
  break;
330
331
  }
332
+ case "--auto-run": {
333
+ if (autoRun !== undefined)
334
+ duplicate(token);
335
+ const value = readOptionValue(argv, index, token);
336
+ if (value !== "on" && value !== "off")
337
+ invalidValue(token, value);
338
+ autoRun = value;
339
+ index += 1;
340
+ break;
341
+ }
331
342
  case "--worktree": {
332
343
  if (worktree !== undefined) {
333
344
  duplicate(token);
@@ -523,6 +534,7 @@ export function parseArgs(argv) {
523
534
  reviewTargets.length > 0 ||
524
535
  completionGate !== undefined ||
525
536
  office !== undefined ||
537
+ autoRun !== undefined ||
526
538
  sessionId !== undefined ||
527
539
  base !== undefined ||
528
540
  parentBase !== undefined ||
@@ -616,6 +628,9 @@ export function parseArgs(argv) {
616
628
  if (office !== undefined && command !== "init" && command !== "update") {
617
629
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--office may be used only with init or update.");
618
630
  }
631
+ if (autoRun !== undefined && command !== "update") {
632
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--auto-run may be used only with update; init selects the run profile.");
633
+ }
619
634
  if (worktree !== undefined && command !== "init" && command !== "update") {
620
635
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--worktree may be used only with init or update.");
621
636
  }
@@ -793,6 +808,7 @@ export function parseArgs(argv) {
793
808
  ...(reviewTargets.length === 0 ? {} : { reviewTargets }),
794
809
  ...(completionGate === undefined ? {} : { completionGate }),
795
810
  ...(office === undefined ? {} : { office }),
811
+ ...(autoRun === undefined ? {} : { autoRun }),
796
812
  ...(criteria.length === 0 ? {} : { criteria }),
797
813
  ...(criterionFiles.length === 0 ? {} : { criterionFiles }),
798
814
  ...(criteriaFile === undefined ? {} : { criteriaFile }),
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { productionRunContext, runPhaseObserver } from "./run-deps.js";
2
+ import { advancePhaseObserver, productionRunContext } from "./run-deps.js";
3
3
  import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
4
4
  import { recordRunVerification } from "../../../runtime/src/run/verification.js";
5
5
  import { runOwnedLocalProof } from "./owned-run-step.js";
6
6
  import { runRunCommand } from "./commands/run.js";
7
7
  import { runOfficeCommand } from "./commands/office.js";
8
- import { ensureBackgroundOffice, observeOfficeSession, openOfficeBrowser, serveOffice } from "./office-entry.js";
8
+ import { ensureBackgroundOffice, observeOfficeSession, officePhaseReporter, openOfficeBrowser, serveOffice } from "./office-entry.js";
9
9
  import { readFile, readdir, stat } from "node:fs/promises";
10
10
  import { randomUUID } from "node:crypto";
11
11
  import { homedir } from "node:os";
@@ -42,6 +42,7 @@ import { hasFreshVerification, runReviewCommand } from "./commands/review.js";
42
42
  import { runBatchCommand } from "./commands/batch.js";
43
43
  import { createSourceGuard } from "../../../runtime/src/review/batch-guard.js";
44
44
  import { withReviewSlot } from "../../../runtime/src/review/slots.js";
45
+ import { withActivity } from "../../../runtime/src/office/activity.js";
45
46
  import { memoizePreflight } from "../../../runtime/src/review/batch.js";
46
47
  import { createReviewExecutor, ReviewInterruptedError } from "../../../runtime/src/review/execute.js";
47
48
  import { probeReviewTargetDetailed } from "../../../runtime/src/review/probe.js";
@@ -60,6 +61,16 @@ import { runWorktreeCommand } from "./commands/parallel.js";
60
61
  import { ensureSessionWorktree, readWorktreeRecord, resolveCheckouts } from "../../../runtime/src/parallel/service.js";
61
62
  import { listWorktrees, branchLockDoctorResult, worktreeDoctorResult } from "../../../runtime/src/parallel/manage.js";
62
63
  import { detectGhostFiles, ghostFilesDoctorResult } from "../../../runtime/src/install/doctor.js";
64
+ /** The repository's common Git directory, where display-only office records live; undefined when git cannot say. */
65
+ async function officeCommonDir(dir) {
66
+ try {
67
+ const result = await gitRunner(dir).run(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
68
+ return result.exitCode === 0 ? new TextDecoder().decode(result.stdout).trim() || undefined : undefined;
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ }
63
74
  const HOOK_RUNTIME_PATH = fileURLToPath(new URL("./hook-entry.js", import.meta.url));
64
75
  async function readOptionalText(path) {
65
76
  try {
@@ -413,6 +424,7 @@ else {
413
424
  calculateTrustBinding: async (config) => await plannedTrustBinding(root, config),
414
425
  confirm: async (plan, trust) => await confirmPlan(formatUpdatePlan(plan, trust)),
415
426
  promptOffice: async (message, enabled) => await selectYesNo(message, { input: process.stdin, output: process.stdout }, enabled),
427
+ promptAutoRun: async (message, enabled) => await selectYesNo(message, { input: process.stdin, output: process.stdout }, enabled),
416
428
  promptWorktree: async (message) => await selectYesNo(message, { input: process.stdin, output: process.stdout }, true),
417
429
  ...(updateArgs.targetVersion === undefined
418
430
  ? {}
@@ -450,13 +462,13 @@ else {
450
462
  ? await resolveCommandSessionId(root)
451
463
  : sessionIdFromEnvironment() ?? await readRecordedSessionId(root));
452
464
  if (args.action === "advance") {
465
+ const advanceSession = sessionId ?? await resolveCommandSessionId(root);
453
466
  return await runAdvanceCommand({
454
467
  cwd: root,
455
- sessionId: sessionId ?? await resolveCommandSessionId(root),
468
+ sessionId: advanceSession,
456
469
  parentTaskId: args.taskId,
457
470
  deps: worktreeDependencies(),
458
- ...(process.env.AGENT_OPS_RUN_ID === undefined || process.env.AGENT_OPS_WORKER_ID === undefined ? {} :
459
- { onPhase: await runPhaseObserver(root, process.env.AGENT_OPS_RUN_ID, process.env.AGENT_OPS_WORKER_ID) })
471
+ onPhase: await advancePhaseObserver(root, advanceSession)
460
472
  });
461
473
  }
462
474
  const createConfig = args.action === "create"
@@ -521,6 +533,18 @@ else {
521
533
  const slotDir = commonDirResult.exitCode === 0
522
534
  ? new TextDecoder().decode(commonDirResult.stdout).trim()
523
535
  : undefined;
536
+ let reviewActivity;
537
+ const inActivity = (executor) => async (request) => await withActivity(slotDir, { kind: "review", root,
538
+ ...(reviewArgs.taskId === undefined ? {} : { taskId: reviewArgs.taskId }),
539
+ ...(reviewSessionId === undefined ? {} : { sessionId: reviewSessionId }) }, async (handle) => {
540
+ reviewActivity = handle;
541
+ try {
542
+ return await executor(request);
543
+ }
544
+ finally {
545
+ reviewActivity = undefined;
546
+ }
547
+ });
524
548
  const inSlot = (executor) => slotDir === undefined
525
549
  ? executor
526
550
  : async (request) => await withReviewSlot({
@@ -549,7 +573,7 @@ else {
549
573
  currentPolicyConfigHash: async () => calculateConfigHash((await loadEffectiveConfig(root, reviewArgs.scope === "user" ? "user" : "project")).config),
550
574
  config: reviewConfig,
551
575
  evidenceStore: new FileEvidenceStore(root, root),
552
- execute: inSlot(createReviewExecutor({
576
+ execute: inActivity(inSlot(createReviewExecutor({
553
577
  runner: registeredRunProofRunner(),
554
578
  targets: configuredReviewTargets,
555
579
  cwd: root,
@@ -575,8 +599,11 @@ else {
575
599
  signal,
576
600
  onProgress: (line) => {
577
601
  process.stderr.write(`${progressPrefix}${line}\n`);
602
+ },
603
+ onRound: (round, target, targets) => {
604
+ void reviewActivity?.update({ round, target, targets });
578
605
  }
579
- }))
606
+ })))
580
607
  };
581
608
  };
582
609
  if (args.command === "review") {
@@ -662,7 +689,10 @@ else {
662
689
  signal: controller.signal,
663
690
  onProgress: (line) => {
664
691
  process.stderr.write(`batch: ${line}\n`);
665
- }
692
+ },
693
+ // The batch's tasks name the sessions whose people follow its steps.
694
+ phaseReporter: async (taskIds) => await officePhaseReporter(root, (await new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root).read()).sessions
695
+ .filter(({ taskId }) => taskIds.includes(taskId)).map(({ sessionId }) => sessionId))
666
696
  });
667
697
  if (interruptedBy !== undefined) {
668
698
  process.exit(interruptedBy === "SIGINT" ? 130 : 143);
@@ -681,9 +711,11 @@ else {
681
711
  const merged = await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project");
682
712
  const config = merged.config;
683
713
  const trustStatus = await repositoryTrust(root, config, CLI_VERSION);
714
+ const verifySessionId = sessionIdFromEnvironment();
684
715
  return await runVerifyCommand({
685
716
  args,
686
717
  taskService,
718
+ activity: { commonDir: await officeCommonDir(root), root, ...(verifySessionId === undefined ? {} : { sessionId: verifySessionId }) },
687
719
  service: { verify: async (taskId) => {
688
720
  const run = await runPolicyContext(root);
689
721
  const report = await new VerificationService({
@@ -45,6 +45,7 @@ Options:
45
45
  --review-target <codex|agy|claude> Repeatable init option; review pair order
46
46
  --completion-gate Init only: enable the project-loop completion gate
47
47
  --office <on|off> Init/update: enable or disable Office (Preview)
48
+ --auto-run <on|off> Update: add or remove the run profile (loop stays)
48
49
  --worktree <auto|off> Init/update: configure session worktree isolation
49
50
  --check-auth Doctor only: probe selected review targets'
50
51
  authentication with one real call
@@ -152,6 +153,7 @@ Options:
152
153
  --harness <all|both|agy|claude|codex|opencode|comma-separated>
153
154
  --target-version <version> Offline-capable update target
154
155
  --office <on|off> Office (Preview); preserve choice unless supplied
156
+ --auto-run <on|off> Add or remove the run profile (loop stays); preserve unless supplied
155
157
  --worktree <auto|off> Configure session worktree isolation
156
158
  --dry-run
157
159
  --json
@@ -66,6 +66,7 @@ export async function runBatchCommand(options) {
66
66
  };
67
67
  }
68
68
  const width = args.width ?? DEFAULT_BATCH_WIDTH;
69
+ const report = await options.phaseReporter?.(plan.map((item) => item.record.task.id)).catch(() => undefined);
69
70
  const outcomes = await runBatch(plan.map((item) => item.record.task.id), {
70
71
  isVerified: (id) => options.isVerified(id, base(id)),
71
72
  verify: (id, signal) => options.verify(id, base(id), signal),
@@ -74,7 +75,8 @@ export async function runBatchCommand(options) {
74
75
  }, {
75
76
  width,
76
77
  ...(options.signal === undefined ? {} : { signal: options.signal }),
77
- ...(options.onProgress === undefined ? {} : { onProgress: options.onProgress })
78
+ ...(options.onProgress === undefined ? {} : { onProgress: options.onProgress }),
79
+ ...(report === undefined ? {} : { onPhase: (phase) => void report(phase).catch(() => { }) })
78
80
  });
79
81
  const byId = new Map(outcomes.map((outcome) => [outcome.id, outcome]));
80
82
  const tasks = plan.map((item) => {
@@ -1,6 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { loadConfigFile } from "../../../../runtime/src/config/load.js";
4
+ import { parseInstallManifest } from "../../../../runtime/src/fs/manifest.js";
4
5
  import { detectWorktreeSetup } from "../../../../runtime/src/install/parallel-setup.js";
5
6
  import { applyUpdatePlan, createUpdatePlan } from "../../../../runtime/src/install/update.js";
6
7
  import { okEnvelope } from "../output.js";
@@ -17,6 +18,7 @@ export function formatUpdatePlan(plan, trust) {
17
18
  metadata: [
18
19
  `Target version: ${plan.targetVersion}`,
19
20
  `Office (Preview): ${plan.installation.config.features.office?.enabled === true ? "enabled" : "disabled"}`,
21
+ `Auto-run: ${plan.installation.config.profiles.includes("run") ? "enabled" : "disabled"}`,
20
22
  `Harness: ${plan.installation.harness.join(", ")}`,
21
23
  ...(plan.installation.config.worktree === undefined
22
24
  ? []
@@ -73,6 +75,14 @@ async function existingConfigHasWorktree(root) {
73
75
  return false;
74
76
  }
75
77
  }
78
+ async function installedHarness(root) {
79
+ try {
80
+ return parseInstallManifest(await readFile(join(root, ".agent-ops", "manifest.json"), "utf8")).harness;
81
+ }
82
+ catch {
83
+ return [];
84
+ }
85
+ }
76
86
  export async function runUpdateCommand(options) {
77
87
  let worktree;
78
88
  if (options.args.worktree === "auto") {
@@ -102,10 +112,18 @@ export async function runUpdateCommand(options) {
102
112
  const { config } = await loadConfigFile(join(options.root, ".agent-ops", "config.json"));
103
113
  officeEnabled = await options.promptOffice("Enable Office (Preview)?", config.features.office?.enabled === true);
104
114
  }
115
+ let autoRun = options.args.autoRun === undefined ? undefined : options.args.autoRun === "on";
116
+ if (autoRun === undefined && options.promptAutoRun !== undefined && options.isTTY && !options.args.yes &&
117
+ (options.platform ?? process.platform) === "darwin" &&
118
+ (options.args.harness ?? await installedHarness(options.root)).some((harness) => harness === "claude" || harness === "codex")) {
119
+ const { config } = await loadConfigFile(join(options.root, ".agent-ops", "config.json"));
120
+ autoRun = await options.promptAutoRun("Enable auto-run (hand changes needing more than five acceptance criteria to agent-ops run)?", config.profiles.includes("run"));
121
+ }
105
122
  const plan = await createUpdatePlan({
106
123
  root: options.root,
107
124
  adapters: options.adapters,
108
125
  ...(officeEnabled === undefined ? {} : { officeEnabled }),
126
+ ...(autoRun === undefined ? {} : { autoRun }),
109
127
  ...(options.args.harness === undefined
110
128
  ? {}
111
129
  : { harness: options.args.harness }),
@@ -1,3 +1,4 @@
1
+ import { withActivity } from "../../../../runtime/src/office/activity.js";
1
2
  import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
2
3
  import { redactSecrets } from "../../../../runtime/src/security/redact.js";
3
4
  import { safeTaskText } from "../../../../runtime/src/task/render.js";
@@ -90,7 +91,8 @@ async function resolveTaskId(options) {
90
91
  }
91
92
  export async function runVerifyCommand(options) {
92
93
  try {
93
- const report = publicReport(await options.service.verify(await resolveTaskId(options)));
94
+ const taskId = await resolveTaskId(options), activity = options.activity;
95
+ const report = publicReport(await withActivity(activity?.commonDir, { kind: "verify", root: activity?.root ?? "", taskId, ...(activity?.sessionId === undefined ? {} : { sessionId: activity.sessionId }) }, async () => await options.service.verify(taskId)));
94
96
  const text = formatReport(report);
95
97
  if (report.status === "PASS") {
96
98
  return okEnvelope("VERIFICATION_PASSED", {
@@ -20,6 +20,8 @@ import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
20
20
  import { readWorktreeRecord } from "../../../runtime/src/parallel/service.js";
21
21
  import { FileRunRepository } from "../../../runtime/src/run/service.js";
22
22
  import { validateWorkerStopHandoff } from "../../../runtime/src/run/worker-gate.js";
23
+ import { evaluateGuardrail } from "../../../runtime/src/guardrails/evaluate.js";
24
+ import { resolveCapabilities } from "../../../runtime/src/install/profiles.js";
23
25
  import { STOP_VERIFICATION_ENV, StopVerificationService } from "../../../runtime/src/hooks/stop-service.js";
24
26
  import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
25
27
  import { normalizeHookInput, runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
@@ -266,10 +268,56 @@ async function closeOfficeSession(io, dependencies) {
266
268
  }
267
269
  return 0;
268
270
  }
271
+ /**
272
+ * A prompt submitted outside the project loop: Office records the turn's work
273
+ * starting, and command policy refuses a literal credential the way the loop
274
+ * does. Every runtime failure lets the prompt through.
275
+ */
276
+ async function promptSubmitted(harness, io, dependencies) {
277
+ try {
278
+ if (process.env.AGENT_OPS_DISABLE === "1")
279
+ return 0;
280
+ const input = parseInput(await readStdin(io.stdin));
281
+ if (typeof input !== "object" || input === null || Array.isArray(input))
282
+ return 0;
283
+ const fields = input;
284
+ const root = dependencies.root ?? (typeof fields.cwd === "string" ? fields.cwd : process.cwd());
285
+ const outcome = await hookConfigOutcome(root, dependencies.loadConfig);
286
+ if (outcome.kind === "invalid")
287
+ return 0;
288
+ const config = outcome.config;
289
+ if (dependencies.office !== undefined && config.features.office?.enabled === true) {
290
+ await dependencies.office({
291
+ root, harness, event: "UserPromptSubmit", input, validated: true,
292
+ ...(typeof fields.session_id === "string" ? { sessionId: fields.session_id } : {})
293
+ }).catch(() => undefined);
294
+ }
295
+ if (config.profiles.length === 0 || !resolveCapabilities(config).capabilities.includes("command-policy"))
296
+ return 0;
297
+ // ponytail: the loop's 64 KiB prompt bound; a larger prompt goes unchecked.
298
+ const prompt = fields.prompt;
299
+ if (typeof prompt !== "string" || prompt.length === 0 || prompt.length > 64 * 1024 || prompt.includes("\0"))
300
+ return 0;
301
+ if (evaluateGuardrail({ kind: "content", content: prompt, scope: root }).action !== "block")
302
+ return 0;
303
+ const reason = "agent-ops blocked a suspected secret.";
304
+ if (harness === "claude") {
305
+ io.writeStdout(JSON.stringify({ decision: "block", reason }));
306
+ return 0;
307
+ }
308
+ io.writeStderr(reason + "\n");
309
+ return 2;
310
+ }
311
+ catch {
312
+ return 0;
313
+ }
314
+ }
269
315
  export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
270
316
  const [harness, event] = argv;
271
317
  if (harness === "claude" && event === "SessionEnd")
272
318
  return await closeOfficeSession(io, dependencies);
319
+ if ((harness === "claude" || harness === "codex") && event === "UserPromptSubmit")
320
+ return await promptSubmitted(harness, io, dependencies);
273
321
  // Both hosts whose Stop hook can actually refuse a stop. codex never fires
274
322
  // Stop under `codex exec` and rejects `permissionDecision:ask`, so its
275
323
  // escape hatch could not be user-approved; opencode can only deny a tool
@@ -7,7 +7,7 @@ import { spawn } from "node:child_process";
7
7
  import { collectOfficeBuilding } from "../../../runtime/src/office/collect.js";
8
8
  import { claimOffice, createOfficeServer, ensureOffice, legacyOfficeHome, officeRecordPath, readLiveOffice, officeUrl, releaseOffice, userOfficeHome } from "../../../runtime/src/office/server.js";
9
9
  import { registerOfficeRepo } from "../../../runtime/src/office/repos.js";
10
- import { recordOfficeSession } from "../../../runtime/src/office/sessions.js";
10
+ import { recordOfficeSession, recordOfficeSessionPhase } from "../../../runtime/src/office/sessions.js";
11
11
  import { resolveCheckouts } from "../../../runtime/src/parallel/service.js";
12
12
  import { createLaunchdDescriptor, LaunchdController } from "../../../runtime/src/run/macOS.js";
13
13
  import { ensurePrivateDirectory, readPrivateFile } from "../../../runtime/src/security/permissions.js";
@@ -47,8 +47,11 @@ function sessionIdentity(options) {
47
47
  const nested = recordValue(options.input, "input");
48
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);
49
49
  }
50
- function officeEvent(event) {
51
- return event === "SessionStart" ? "start" : event === "Stop" ? "stop" : event === "SessionEnd" ? "end" : "activity";
50
+ /** agy's SessionStart is its native PreInvocation, sent as each invocation's work begins. */
51
+ export function officeEvent(harness, event) {
52
+ if (event === "SessionStart")
53
+ return harness === "agy" ? "activity" : "start";
54
+ return event === "Stop" ? "stop" : event === "SessionEnd" ? "end" : "activity";
52
55
  }
53
56
  /** Phase hints come only from normalized native event kinds and exact agent-ops argv. */
54
57
  export function officePhaseHint(event) {
@@ -64,13 +67,14 @@ export function officePhaseHint(event) {
64
67
  if (basename(command.command) !== "agent-ops")
65
68
  continue;
66
69
  const action = command.args[0];
67
- const next = action === "verify"
70
+ // batch and advance report their later steps themselves; this is their first.
71
+ const next = action === "verify" || action === "batch"
68
72
  ? "verifying"
69
73
  : action === "review"
70
74
  ? "reviewing"
71
75
  : action === "worktree" && command.args[1] === "finish"
72
76
  ? "integrating"
73
- : action === "task" && command.args[1] === "complete"
77
+ : action === "task" && (command.args[1] === "complete" || command.args[1] === "advance")
74
78
  ? "integrating"
75
79
  : action === "task"
76
80
  ? "planning"
@@ -117,7 +121,7 @@ export async function observeOfficeSession(options) {
117
121
  harness: options.harness,
118
122
  projectRoot: mainRoot,
119
123
  commonDir,
120
- event: officeEvent(options.event),
124
+ event: officeEvent(options.harness, options.event),
121
125
  ...(options.agentId === undefined ? {} : { agentId: options.agentId }),
122
126
  ...(process.env.AGENT_OPS_RUN_ID === undefined ? {} : { runId: process.env.AGENT_OPS_RUN_ID }),
123
127
  ...(process.env.AGENT_OPS_WORKER_ID === undefined ? {} : { workerId: process.env.AGENT_OPS_WORKER_ID }),
@@ -136,6 +140,30 @@ export async function observeOfficeSession(options) {
136
140
  // Office is display-only; a missing Git checkout, lock or launcher never blocks a host hook.
137
141
  }
138
142
  }
143
+ /**
144
+ * Moves these sessions' people as a batch or advance reaches each step.
145
+ * Display only: a disabled Office, an unknown session or a failed write records nothing.
146
+ */
147
+ export async function officePhaseReporter(cwd, sessionIds) {
148
+ const none = async () => { };
149
+ try {
150
+ if (sessionIds.length === 0 || !(await officeEnabled(cwd)))
151
+ return none;
152
+ const { commonDir } = await checkouts(cwd);
153
+ // One write at a time, in order, so the latest step is the one that stays.
154
+ let chain = Promise.resolve();
155
+ return async (phase) => {
156
+ chain = chain.then(async () => {
157
+ for (const sessionId of sessionIds)
158
+ await recordOfficeSessionPhase(commonDir, sessionId, phase).catch(() => { });
159
+ });
160
+ await chain;
161
+ };
162
+ }
163
+ catch {
164
+ return none;
165
+ }
166
+ }
139
167
  async function checkouts(cwd) {
140
168
  return await resolveCheckouts(worktreeDependencies(), cwd);
141
169
  }
@@ -13,6 +13,7 @@ import { FileRunRepository, RunService } from "../../../runtime/src/run/service.
13
13
  import { readPrivateFile, writePrivateFile } from "../../../runtime/src/security/permissions.js";
14
14
  import { collectChangeSurface } from "../../../runtime/src/verify/change-surface.js";
15
15
  import { worktreeDependencies, gitRunner, trustStore } from "./parallel-deps.js";
16
+ import { officePhaseReporter } from "./office-entry.js";
16
17
  import { NativeRunTransport } from "../../../runtime/src/run/transport.js";
17
18
  import { ClaudeGoalHost } from "../../../runtime/src/run/hosts/claude.js";
18
19
  import { CodexGoalHost } from "../../../runtime/src/run/hosts/codex.js";
@@ -145,6 +146,13 @@ export async function runPhaseObserver(cwd, runId, workerId) {
145
146
  const repository = new FileRunRepository(join(commonDir, "agent-ops", "runs"), commonDir);
146
147
  return async (phase, progress) => await recordRunPhase(repository, runId, workerId, phase, progress);
147
148
  }
149
+ /** A run worker's advance reports to its run; an ordinary session's moves its own person in Office. */
150
+ export async function advancePhaseObserver(cwd, sessionId, env = process.env) {
151
+ if (env.AGENT_OPS_RUN_ID !== undefined && env.AGENT_OPS_WORKER_ID !== undefined)
152
+ return await runPhaseObserver(cwd, env.AGENT_OPS_RUN_ID, env.AGENT_OPS_WORKER_ID);
153
+ const office = await officePhaseReporter(cwd, sessionId === undefined ? [] : [sessionId]);
154
+ return async (phase) => await office(phase);
155
+ }
148
156
  export const runEntry = fileURLToPath(new URL("./run-entry.js", import.meta.url));
149
157
  export function runDescriptor(state) {
150
158
  return createLaunchdDescriptor({ runId: state.runId, workerId: "supervisor", privateDirectory: join(state.commonDir, "agent-ops", "runs", state.runId),
@@ -113,7 +113,10 @@ export function buildClaudeHookSettings(capabilities, runtimePath, platform = pr
113
113
  if (capabilities.includes("lifecycle-summary") || capabilities.includes("office-presence")) {
114
114
  hooks.SessionStart = [matcherGroup("SessionStart", runtimePath)];
115
115
  }
116
+ // The loop has its own prompt hook. Outside it, a prompt is both the
117
+ // turn's start for Office and content for command policy.
116
118
  if (capabilities.includes("command-policy") || capabilities.includes("office-presence")) {
119
+ hooks.UserPromptSubmit = [matcherGroup("UserPromptSubmit", runtimePath)];
117
120
  hooks.PreToolUse = [matcherGroup("PreToolUse", runtimePath)];
118
121
  }
119
122
  }
@@ -82,7 +82,10 @@ export function buildCodexHookConfig(capabilities, runtimePath) {
82
82
  if (capabilities.includes("lifecycle-summary") || capabilities.includes("office-presence")) {
83
83
  hooks.SessionStart = [matcherGroup("SessionStart", runtimePath)];
84
84
  }
85
+ // The loop has its own prompt hook. Outside it, a prompt is both the
86
+ // turn's start for Office and content for command policy.
85
87
  if (capabilities.includes("command-policy") || capabilities.includes("office-presence")) {
88
+ hooks.UserPromptSubmit = [matcherGroup("UserPromptSubmit", runtimePath)];
86
89
  hooks.PreToolUse = [matcherGroup("PreToolUse", runtimePath)];
87
90
  }
88
91
  }
@@ -106,11 +106,19 @@ export async function createUpdatePlan(options) {
106
106
  configPreview.steps.length === 0) {
107
107
  throw new AgentOpsError("UPDATE_INSTALLATION_INVALID", "Update requires a passing config doctor check.");
108
108
  }
109
+ const current = configPreview.migrated.profiles;
110
+ // `run` implies `loop`; turning run off keeps the loop it brought, since
111
+ // the config cannot tell an implied loop from a chosen one.
112
+ const profiles = options.autoRun === true
113
+ ? current.includes("run") ? current : [...current, "run"]
114
+ : options.autoRun === false && current.includes("run")
115
+ ? [...current.filter((profile) => profile !== "run"), ...(current.includes("loop") ? [] : ["loop"])]
116
+ : current;
109
117
  const installation = await createInstallPlan({
110
118
  root: options.root,
111
119
  scope: report.manifest.scope,
112
120
  harness: options.harness ?? report.manifest.harness,
113
- profiles: configPreview.migrated.profiles,
121
+ profiles,
114
122
  adapters: options.adapters,
115
123
  toolkitVersion: options.toolkitVersion ?? targetVersion,
116
124
  allowHarnessChange: true,
@@ -122,7 +130,7 @@ export async function createUpdatePlan(options) {
122
130
  ? {}
123
131
  : { hookTargets: options.hookTargets }),
124
132
  existingConfig: {
125
- value: configPreview.migrated,
133
+ value: profiles === current ? configPreview.migrated : { ...configPreview.migrated, profiles },
126
134
  sourceHash: configPreview.sourceHash
127
135
  },
128
136
  ...(options.codexHome === undefined ? {} : { codexHome: options.codexHome }),
@@ -0,0 +1,105 @@
1
+ import { readdir, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
4
+ const KINDS = new Set(["verify", "review", "finish"]);
5
+ const ID = /^[A-Za-z0-9._-]{1,128}$/u;
6
+ const TARGET = /^[a-z][a-z0-9-]{0,31}$/u;
7
+ const FILE = /^(verify|review|finish)-(\d{1,10})-(\d{1,10})\.json$/u;
8
+ const NOOP = { update: async () => { }, end: async () => { } };
9
+ export function activityDirectory(commonDir) {
10
+ return join(commonDir, "agent-ops", "office-activity");
11
+ }
12
+ /** Keeps only the allowlisted, well-formed fields; null when the record cannot stand. */
13
+ export function activityValue(value) {
14
+ if (typeof value !== "object" || value === null || Array.isArray(value))
15
+ return null;
16
+ const item = value;
17
+ if (!KINDS.has(item.kind) || typeof item.pid !== "number" || !Number.isInteger(item.pid) || item.pid <= 0 ||
18
+ typeof item.root !== "string" || item.root.length === 0 || item.root.length > 4096 ||
19
+ typeof item.startedAt !== "string" || Number.isNaN(Date.parse(item.startedAt)))
20
+ return null;
21
+ const id = (field) => typeof field === "string" && ID.test(field) ? field : undefined;
22
+ const targets = Array.isArray(item.targets) ? item.targets.filter((t) => typeof t === "string" && TARGET.test(t)).slice(0, 2) : undefined;
23
+ const round = item.round === 1 || item.round === 2 ? item.round : undefined;
24
+ const target = typeof item.target === "string" && TARGET.test(item.target) ? item.target : undefined;
25
+ const taskId = id(item.taskId), sessionId = id(item.sessionId), worktree = id(item.worktree);
26
+ return { kind: item.kind, pid: item.pid, root: item.root, startedAt: item.startedAt,
27
+ ...(taskId === undefined ? {} : { taskId }), ...(sessionId === undefined ? {} : { sessionId }), ...(worktree === undefined ? {} : { worktree }),
28
+ ...(targets === undefined || targets.length === 0 ? {} : { targets }), ...(round === undefined ? {} : { round }), ...(target === undefined ? {} : { target }) };
29
+ }
30
+ let sequence = 0;
31
+ /** Starts a record; returns a handle whose writes never throw. No common dir means no record. */
32
+ export async function beginActivity(commonDir, start, options = {}) {
33
+ if (commonDir === undefined || commonDir.length === 0)
34
+ return NOOP;
35
+ const pid = options.pid ?? process.pid, startedAt = (options.now ?? (() => new Date()))().toISOString();
36
+ let current = activityValue({ ...start, pid, startedAt });
37
+ if (current === null)
38
+ return NOOP;
39
+ // One process may run several reviews at once (batch), so the name carries a sequence too.
40
+ const path = join(activityDirectory(commonDir), `${current.kind}-${pid}-${++sequence}.json`);
41
+ // Writes run one after another in call order, so a slow early write never lands over a later one,
42
+ // and none runs after end() has removed the record.
43
+ let queue = Promise.resolve(), ended = false;
44
+ const write = () => (queue = queue.then(async () => {
45
+ if (ended)
46
+ return;
47
+ try {
48
+ await writePrivateFile(path, JSON.stringify(current) + "\n", commonDir);
49
+ }
50
+ catch { /* display only */ }
51
+ }));
52
+ await write();
53
+ return {
54
+ update: async (patch) => { const next = activityValue({ ...current, ...patch }); if (next !== null) {
55
+ current = next;
56
+ await write();
57
+ } },
58
+ end: async () => { ended = true; await queue; try {
59
+ await rm(path, { force: true });
60
+ }
61
+ catch { /* display only */ } }
62
+ };
63
+ }
64
+ /** Runs `work` with a record present; the record goes when the work ends, however it ends. */
65
+ export async function withActivity(commonDir, start, work) {
66
+ const handle = await beginActivity(commonDir, start).catch(() => NOOP);
67
+ try {
68
+ return await work(handle);
69
+ }
70
+ finally {
71
+ await handle.end().catch(() => undefined);
72
+ }
73
+ }
74
+ function processAlive(pid) {
75
+ try {
76
+ process.kill(pid, 0);
77
+ return true;
78
+ }
79
+ catch (error) {
80
+ return error.code === "EPERM";
81
+ }
82
+ }
83
+ /** Live records only: malformed files, files whose name disagrees with their content and dead processes are skipped. */
84
+ export async function readActivities(commonDir, alive = processAlive) {
85
+ const directory = activityDirectory(commonDir);
86
+ const names = (await readdir(directory).catch(() => [])).filter(name => FILE.test(name)).sort().slice(0, 200);
87
+ const records = [];
88
+ for (const name of names) {
89
+ const match = FILE.exec(name);
90
+ const source = await readPrivateFile(join(directory, name), commonDir).catch(() => null);
91
+ if (source === null || source.length > 16 * 1024)
92
+ continue;
93
+ let value;
94
+ try {
95
+ value = activityValue(JSON.parse(source));
96
+ }
97
+ catch {
98
+ continue;
99
+ }
100
+ if (value === null || value.kind !== match[1] || value.pid !== Number(match[2]) || !alive(value.pid))
101
+ continue;
102
+ records.push(value);
103
+ }
104
+ return records;
105
+ }