@kylecheng3146/agent-ops 0.7.3 → 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 +2 -0
- package/dist/packages/cli/src/args.js +16 -0
- package/dist/packages/cli/src/bin.js +40 -8
- package/dist/packages/cli/src/cli.js +2 -0
- package/dist/packages/cli/src/commands/batch.js +3 -1
- package/dist/packages/cli/src/commands/update.js +18 -0
- package/dist/packages/cli/src/commands/verify.js +3 -1
- package/dist/packages/cli/src/hook-process.js +48 -0
- package/dist/packages/cli/src/office-entry.js +34 -6
- package/dist/packages/cli/src/run-deps.js +8 -0
- package/dist/runtime/src/adapters/claude/config.js +3 -0
- package/dist/runtime/src/adapters/codex/config.js +3 -0
- package/dist/runtime/src/install/update.js +10 -2
- package/dist/runtime/src/office/activity.js +105 -0
- package/dist/runtime/src/office/art.js +999 -0
- package/dist/runtime/src/office/collect.js +137 -10
- package/dist/runtime/src/office/layout.js +200 -0
- package/dist/runtime/src/office/page.js +760 -645
- package/dist/runtime/src/office/scene.js +27 -31
- package/dist/runtime/src/office/sessions.js +18 -1
- package/dist/runtime/src/office/snapshot.js +62 -1
- package/dist/runtime/src/parallel/finish.js +4 -2
- package/dist/runtime/src/review/batch.js +10 -0
- package/dist/runtime/src/review/execute.js +9 -0
- package/dist/runtime/src/run/hosts/codex.js +1 -1
- package/docs/en/guides/configuration.md +7 -0
- package/docs/en/spec/harness-adapters.md +16 -2
- package/docs/zh-TW/guides/configuration.md +6 -0
- package/docs/zh-TW/spec/harness-adapters.md +12 -2
- package/package.json +1 -1
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 {
|
|
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:
|
|
468
|
+
sessionId: advanceSession,
|
|
456
469
|
parentTaskId: args.taskId,
|
|
457
470
|
deps: worktreeDependencies(),
|
|
458
|
-
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|