@kylecheng3146/agent-ops 0.7.1 → 0.7.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.
- package/dist/packages/cli/src/hook-process.js +28 -0
- package/dist/packages/cli/src/office-entry.js +2 -2
- package/dist/runtime/src/adapters/claude/config.js +5 -0
- package/dist/runtime/src/adapters/claude/events.js +2 -1
- package/dist/runtime/src/office/collect.js +23 -1
- package/dist/runtime/src/office/page.js +6 -4
- package/dist/runtime/src/office/sessions.js +8 -7
- package/dist/runtime/src/run/hosts/codex.js +1 -1
- package/dist/runtime/src/schema/validate.js +2 -1
- package/docs/en/spec/harness-adapters.md +6 -1
- package/docs/zh-TW/spec/harness-adapters.md +4 -1
- package/package.json +1 -1
- package/schemas/manifest.schema.json +2 -1
|
@@ -240,8 +240,36 @@ function completionGateFor(root, config, gitRunner, harness) {
|
|
|
240
240
|
* Runs one hook invocation. Exit code stays zero because native JSON carries
|
|
241
241
|
* decisions; only an explicitly installed agy completion gate fails closed.
|
|
242
242
|
*/
|
|
243
|
+
/**
|
|
244
|
+
* Claude's SessionEnd: the conversation closed, so its Office room closes too.
|
|
245
|
+
* Display-only and fail-open: it writes no output and never fails the host.
|
|
246
|
+
*/
|
|
247
|
+
async function closeOfficeSession(io, dependencies) {
|
|
248
|
+
try {
|
|
249
|
+
if (process.env.AGENT_OPS_DISABLE === "1" || dependencies.office === undefined)
|
|
250
|
+
return 0;
|
|
251
|
+
const input = parseInput(await readStdin(io.stdin));
|
|
252
|
+
if (typeof input !== "object" || input === null || Array.isArray(input))
|
|
253
|
+
return 0;
|
|
254
|
+
const fields = input;
|
|
255
|
+
const root = dependencies.root ?? (typeof fields.cwd === "string" ? fields.cwd : process.cwd());
|
|
256
|
+
const outcome = await hookConfigOutcome(root, dependencies.loadConfig);
|
|
257
|
+
if (outcome.kind === "invalid" || outcome.config.features.office?.enabled !== true)
|
|
258
|
+
return 0;
|
|
259
|
+
await dependencies.office({
|
|
260
|
+
root, harness: "claude", event: "SessionEnd", input, validated: true,
|
|
261
|
+
...(typeof fields.session_id === "string" ? { sessionId: fields.session_id } : {})
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// Office never blocks a session, least of all one that is ending.
|
|
266
|
+
}
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
243
269
|
export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
244
270
|
const [harness, event] = argv;
|
|
271
|
+
if (harness === "claude" && event === "SessionEnd")
|
|
272
|
+
return await closeOfficeSession(io, dependencies);
|
|
245
273
|
// Both hosts whose Stop hook can actually refuse a stop. codex never fires
|
|
246
274
|
// Stop under `codex exec` and rejects `permissionDecision:ask`, so its
|
|
247
275
|
// escape hatch could not be user-approved; opencode can only deny a tool
|
|
@@ -48,7 +48,7 @@ function sessionIdentity(options) {
|
|
|
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
50
|
function officeEvent(event) {
|
|
51
|
-
return event === "SessionStart" ? "start" : event === "Stop" ? "stop" : "activity";
|
|
51
|
+
return event === "SessionStart" ? "start" : event === "Stop" ? "stop" : event === "SessionEnd" ? "end" : "activity";
|
|
52
52
|
}
|
|
53
53
|
/** Phase hints come only from normalized native event kinds and exact agent-ops argv. */
|
|
54
54
|
export function officePhaseHint(event) {
|
|
@@ -129,7 +129,7 @@ export async function observeOfficeSession(options) {
|
|
|
129
129
|
// A resumed session may outlive the ten-minute idle shutdown. Reusing the
|
|
130
130
|
// same ensure path revives the user's server without creating a tab
|
|
131
131
|
// unless this call actually wins a new-server start claim.
|
|
132
|
-
if (options.event !== "Stop")
|
|
132
|
+
if (options.event !== "Stop" && options.event !== "SessionEnd")
|
|
133
133
|
await ensureBackgroundOffice(options.root, undefined, openOfficeBrowser);
|
|
134
134
|
}
|
|
135
135
|
catch {
|
|
@@ -125,6 +125,11 @@ export function buildClaudeHookSettings(capabilities, runtimePath, platform = pr
|
|
|
125
125
|
else if (capabilities.includes("optional-stop-verify") || capabilities.includes("office-presence")) {
|
|
126
126
|
hooks.Stop = [matcherGroup("Stop", runtimePath)];
|
|
127
127
|
}
|
|
128
|
+
// Stop only ends a turn. SessionEnd is the one signal that the conversation
|
|
129
|
+
// closed, so a session that never had a task can still leave the Office.
|
|
130
|
+
if (capabilities.includes("office-presence")) {
|
|
131
|
+
hooks.SessionEnd = [matcherGroup("SessionEnd", runtimePath)];
|
|
132
|
+
}
|
|
128
133
|
return { hooks };
|
|
129
134
|
}
|
|
130
135
|
function isRecord(value) {
|
|
@@ -141,6 +141,21 @@ export async function worktreeDiff(git, path, base) {
|
|
|
141
141
|
}
|
|
142
142
|
return { files: paths.length, insertions, deletions, paths: paths.slice(0, MAX_PATHS), recent: recent ?? paths[0] ?? null };
|
|
143
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Work already in the target branch by some route other than `worktree finish`
|
|
146
|
+
* (a merged pull request): commits past its base, all reachable from the
|
|
147
|
+
* target, and nothing uncommitted. A fresh worktree (HEAD at base) is not merged.
|
|
148
|
+
* ponytail: ancestry only, so a squash-merged branch stays visible until removed.
|
|
149
|
+
*/
|
|
150
|
+
export async function worktreeMerged(git, path, base, targetBranch) {
|
|
151
|
+
const head = (await git(path, ["rev-parse", "HEAD"])).stdout.trim();
|
|
152
|
+
if (!/^[0-9a-f]{40,64}$/u.test(head) || head === base || !/^[A-Za-z0-9._/-]{1,128}$/u.test(targetBranch))
|
|
153
|
+
return false;
|
|
154
|
+
if ((await git(path, ["merge-base", "--is-ancestor", head, `refs/heads/${targetBranch}`])).exitCode !== 0)
|
|
155
|
+
return false;
|
|
156
|
+
const status = await git(path, ["status", "--porcelain"]);
|
|
157
|
+
return status.exitCode === 0 && status.stdout.trim() === "";
|
|
158
|
+
}
|
|
144
159
|
/** Each run is read on its own: one unreadable run must not hide the building. */
|
|
145
160
|
export async function readRuns(commonDir) {
|
|
146
161
|
const directory = join(commonDir, "agent-ops", "runs");
|
|
@@ -207,14 +222,21 @@ export async function readReviewSlots(commonDir) {
|
|
|
207
222
|
export async function collectOfficeInput(mainRoot, commonDir, git = officeGit, now = Date.now()) {
|
|
208
223
|
const worktrees = [];
|
|
209
224
|
const discovered = [];
|
|
225
|
+
const merged = new Set();
|
|
210
226
|
for (const entry of parseWorktreeListPorcelain((await git(mainRoot, ["worktree", "list", "--porcelain"])).stdout)) {
|
|
211
227
|
const path = await realpath(resolve(entry.path)).catch(() => resolve(entry.path));
|
|
212
228
|
const record = await readWorktreeRecord(path).catch(() => null);
|
|
213
229
|
if (record === null)
|
|
214
230
|
continue;
|
|
231
|
+
if (record.runId === undefined && await worktreeMerged(git, path, record.base, record.targetBranch)) {
|
|
232
|
+
// A subagent's worktree shares its coordinator's session, which stays.
|
|
233
|
+
if (record.agentId === undefined)
|
|
234
|
+
merged.add(record.sessionId);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
215
237
|
discovered.push({ path, record });
|
|
216
238
|
}
|
|
217
|
-
const sessions = await readSessions(mainRoot, commonDir, now, [mainRoot, ...discovered.map(item => item.path)]);
|
|
239
|
+
const sessions = (await readSessions(mainRoot, commonDir, now, [mainRoot, ...discovered.map(item => item.path)])).filter(session => !merged.has(session.sessionId));
|
|
218
240
|
for (const { path, record } of discovered) {
|
|
219
241
|
const session = sessions.find(item => item.sessionId === record.sessionId);
|
|
220
242
|
const task = await attachedTaskView(path, record.sessionId, session?.taskId);
|
|
@@ -305,8 +305,8 @@ var supervisor = {x:43, y:19, targetX:43, targetY:19}, supervisorReady = false,
|
|
|
305
305
|
var roomSupervisor = {x:32, y:34, targetX:32, targetY:34};
|
|
306
306
|
var reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
307
307
|
var STRINGS = {
|
|
308
|
-
en: {viewer:"You", overview:"Office overview", rooms:"rooms", people:"people", recent:"Recently completed", recentOn:"Hide completed", repos:"Repository", allRepos:"All repositories", back:"Back to overview", connected:"● Connected", offline:"○ Reconnecting", quiet:"The office is quiet. No agent is at work.", waiting:"waiting for your answer", enter:"Enter room", close:"Close", previous:"Previous", next:"Next", page:"Page", details:"Details", progress:"Progress", verify:"Verify", review:"Review", pending:"Awaiting answer", task:"Task", status:"Status", phase:"Phase", host:"Host", now:"Now", owner:"Owner", currentWork:"Current work", question:"Pending question", questions:"Questions", workList:"Work list", clickWork:"Click for work", keyboardWork:"Press L for work list", keyboardPhase:"Press 1-5 for phase work", keyboardQuestions:"Press Q for questions", noPeople:"No known work is in this phase", noQuestions:"No pending questions", files:"Changed files", commands:"Commands", copy:"Copy", copied:"Copied", unknown:"unknown", unassigned:"unassigned", phases:{planning:"Planning", implementing:"Implementing", verifying:"Verifying", reviewing:"Reviewing", integrating:"Integrating", unknown:"Unassigned"}, statuses:{active:"active", idle:"idle", running:"running", reviewing:"reviewing", verifying:"verifying", blocked:"blocked", delivered:"delivered", complete:"complete", unknown:"unknown", unassigned:"unassigned", pending:"pending"}},
|
|
309
|
-
zh: {viewer:"你", overview:"辦公室總覽", rooms:"個房間", people:"位成員", recent:"最近完成", recentOn:"隱藏已完成", repos:"專案", allRepos:"所有專案", back:"返回總覽", connected:"● 已連線", offline:"○ 重新連線中", quiet:"辦公室很安靜,目前沒有成員工作。", waiting:"等待你的回覆", enter:"進入房間", close:"關閉", previous:"上一頁", next:"下一頁", page:"頁", details:"詳細資料", progress:"進度", verify:"驗證", review:"審查", pending:"待回覆", task:"任務", status:"狀態", phase:"階段", host:"主機", now:"目前", owner:"負責人", currentWork:"目前工作", question:"待回覆問題", questions:"問題", workList:"工作清單", clickWork:"點擊查看工作", keyboardWork:"按 L 開啟工作清單", keyboardPhase:"按 1-5 查看階段工作", keyboardQuestions:"按 Q 查看問題", noPeople:"此階段目前沒有已知工作", noQuestions:"目前沒有待回覆問題", files:"變更檔案", commands:"指令", copy:"複製", copied:"已複製", unknown:"未知", unassigned:"未分配", phases:{planning:"規劃", implementing:"開發", verifying:"驗證", reviewing:"審查", integrating:"整合", unknown:"未分配"}, statuses:{active:"進行中", idle:"閒置", running:"工作中", reviewing:"審查中", verifying:"驗證中", blocked:"受阻", delivered:"已交付", complete:"已完成", unknown:"未知", unassigned:"未分配", pending:"待回覆"}}
|
|
308
|
+
en: {viewer:"You", overview:"Office overview", rooms:"rooms", people:"people", recent:"Recently completed", recentOn:"Hide completed", repos:"Repository", allRepos:"All repositories", noTask:"No task", noTaskHint:"Nothing to verify or review", back:"Back to overview", connected:"● Connected", offline:"○ Reconnecting", quiet:"The office is quiet. No agent is at work.", waiting:"waiting for your answer", enter:"Enter room", close:"Close", previous:"Previous", next:"Next", page:"Page", details:"Details", progress:"Progress", verify:"Verify", review:"Review", pending:"Awaiting answer", task:"Task", status:"Status", phase:"Phase", host:"Host", now:"Now", owner:"Owner", currentWork:"Current work", question:"Pending question", questions:"Questions", workList:"Work list", clickWork:"Click for work", keyboardWork:"Press L for work list", keyboardPhase:"Press 1-5 for phase work", keyboardQuestions:"Press Q for questions", noPeople:"No known work is in this phase", noQuestions:"No pending questions", files:"Changed files", commands:"Commands", copy:"Copy", copied:"Copied", unknown:"unknown", unassigned:"unassigned", phases:{planning:"Planning", implementing:"Implementing", verifying:"Verifying", reviewing:"Reviewing", integrating:"Integrating", unknown:"Unassigned"}, statuses:{active:"active", idle:"idle", running:"running", reviewing:"reviewing", verifying:"verifying", blocked:"blocked", delivered:"delivered", complete:"complete", unknown:"unknown", unassigned:"unassigned", pending:"pending"}},
|
|
309
|
+
zh: {viewer:"你", overview:"辦公室總覽", rooms:"個房間", people:"位成員", recent:"最近完成", recentOn:"隱藏已完成", repos:"專案", allRepos:"所有專案", noTask:"無任務", noTaskHint:"沒有需要驗證或審查的工作", back:"返回總覽", connected:"● 已連線", offline:"○ 重新連線中", quiet:"辦公室很安靜,目前沒有成員工作。", waiting:"等待你的回覆", enter:"進入房間", close:"關閉", previous:"上一頁", next:"下一頁", page:"頁", details:"詳細資料", progress:"進度", verify:"驗證", review:"審查", pending:"待回覆", task:"任務", status:"狀態", phase:"階段", host:"主機", now:"目前", owner:"負責人", currentWork:"目前工作", question:"待回覆問題", questions:"問題", workList:"工作清單", clickWork:"點擊查看工作", keyboardWork:"按 L 開啟工作清單", keyboardPhase:"按 1-5 查看階段工作", keyboardQuestions:"按 Q 查看問題", noPeople:"此階段目前沒有已知工作", noQuestions:"目前沒有待回覆問題", files:"變更檔案", commands:"指令", copy:"複製", copied:"已複製", unknown:"未知", unassigned:"未分配", phases:{planning:"規劃", implementing:"開發", verifying:"驗證", reviewing:"審查", integrating:"整合", unknown:"未分配"}, statuses:{active:"進行中", idle:"閒置", running:"工作中", reviewing:"審查中", verifying:"驗證中", blocked:"受阻", delivered:"已交付", complete:"已完成", unknown:"未知", unassigned:"未分配", pending:"待回覆"}}
|
|
310
310
|
};
|
|
311
311
|
function getLanguage(){
|
|
312
312
|
try { var cookie = document.cookie.split(";").map(function(part){ return part.trim().split("="); }).find(function(pair){ return pair[0] === "agent-office-language"; }); if (cookie && (cookie[1] === "en" || cookie[1] === "zh")) return cookie[1]; } catch (_) {}
|
|
@@ -472,9 +472,11 @@ function drawRoomBase(floor){
|
|
|
472
472
|
var b=floor.board,board=progressBoardGeometry(canvas.width,canvas.height),bx=board.x,by=board.y,bw=board.width,bh=board.height;
|
|
473
473
|
ctx.fillStyle=PALETTE[9];ctx.fillRect(bx,by,bw,bh);ctx.fillStyle=PALETTE[15];ctx.fillRect(bx+2*unit,by+2*unit,bw-4*unit,bh-4*unit);
|
|
474
474
|
fillText('▣ '+t('clickWork'),bx+6*unit,by+5*unit,8,PALETTE[1]);
|
|
475
|
-
|
|
475
|
+
// A room without a task has nothing to verify or review; saying "pending" would claim unfinished work.
|
|
476
|
+
if(b.taskId==='unassigned'&&!b.total){fillText(t('noTask'),bx+6*unit,by+16*unit,10,PALETTE[1]);fillText(t('noTaskHint'),bx+6*unit,by+40*unit,8,PALETTE[4]);}
|
|
477
|
+
else{fillText(t('progress')+' '+b.passed+'/'+(b.total||'?'),bx+6*unit,by+16*unit,10,PALETTE[1]);
|
|
476
478
|
ctx.fillStyle=PALETTE[3];ctx.fillRect(bx+6*unit,by+29*unit,bw-12*unit,5*unit);ctx.fillStyle=PALETTE[2];ctx.fillRect(bx+6*unit,by+29*unit,(bw-12*unit)*Math.min(1,b.total?b.passed/b.total:0),5*unit);
|
|
477
|
-
fillText(t('verify')+' '+outcomeText(b.verify)+' '+t('review')+' '+outcomeText(b.review),bx+6*unit,by+40*unit,8,PALETTE[4]);
|
|
479
|
+
fillText(t('verify')+' '+outcomeText(b.verify)+' '+t('review')+' '+outcomeText(b.review),bx+6*unit,by+40*unit,8,PALETTE[4]);}
|
|
478
480
|
var pendingRow=pendingBoardGeometry(canvas.width,canvas.height);
|
|
479
481
|
if(b.pending)fillText('! '+t('pending')+' '+b.pending,pendingRow.x+2*unit,pendingRow.y+3*unit,8,PALETTE[13]);
|
|
480
482
|
var oldS=S;S=Math.max(1,Math.round(unit));var cx=52*rx,cy=ry;draw('clock',cx,cy);
|
|
@@ -68,13 +68,12 @@ function retained(record, now) {
|
|
|
68
68
|
const lastSeen = Date.parse(record.lastSeenAt);
|
|
69
69
|
if (!Number.isFinite(lastSeen))
|
|
70
70
|
return false;
|
|
71
|
-
if (record.status === "active") {
|
|
72
|
-
// ponytail: one TTL handles crashed hook clients
|
|
71
|
+
if (record.status === "active" || record.completedAt === null || record.completedAt === undefined) {
|
|
72
|
+
// ponytail: one TTL handles crashed hook clients and windows closed without `end`;
|
|
73
|
+
// a persistent host heartbeat can replace it if needed.
|
|
73
74
|
return now - lastSeen <= OFFICE_SESSION_STALE_MS;
|
|
74
75
|
}
|
|
75
|
-
const completed =
|
|
76
|
-
? lastSeen
|
|
77
|
-
: Date.parse(record.completedAt);
|
|
76
|
+
const completed = Date.parse(record.completedAt);
|
|
78
77
|
return Number.isFinite(completed) && now - completed < OFFICE_SESSION_RETENTION_MS;
|
|
79
78
|
}
|
|
80
79
|
function prune(records, now) {
|
|
@@ -100,7 +99,7 @@ export async function recordOfficeSession(options) {
|
|
|
100
99
|
root: options.projectRoot,
|
|
101
100
|
firstSeenAt: previous?.firstSeenAt ?? at,
|
|
102
101
|
lastSeenAt: at,
|
|
103
|
-
status: options.event === "stop" ? "idle" : "active",
|
|
102
|
+
status: options.event === "stop" || options.event === "end" ? "idle" : "active",
|
|
104
103
|
...(options.agentId ?? previous?.agentId ? { agentId: options.agentId ?? previous?.agentId } : {}),
|
|
105
104
|
...(options.runId ?? previous?.runId ? { runId: options.runId ?? previous?.runId } : {}),
|
|
106
105
|
...(options.workerId ?? previous?.workerId ? { workerId: options.workerId ?? previous?.workerId } : {}),
|
|
@@ -109,7 +108,9 @@ export async function recordOfficeSession(options) {
|
|
|
109
108
|
...(options.host ?? previous?.host ? { host: options.host ?? previous?.host } : {}),
|
|
110
109
|
// A finished session keeps its closed room through the tool calls that
|
|
111
110
|
// report the result; only a new start (or resume) reopens it.
|
|
112
|
-
...(options.event
|
|
111
|
+
...(options.event === "end"
|
|
112
|
+
? { completedAt: previous?.completedAt ?? at }
|
|
113
|
+
: options.event !== "start" && previous?.completedAt !== undefined ? { completedAt: previous.completedAt } : {})
|
|
113
114
|
};
|
|
114
115
|
const without = records.filter(record => record.sessionId !== options.sessionId);
|
|
115
116
|
await writePrivateFile(path, JSON.stringify({ schemaVersion: 1, sessions: [...without, next].slice(-MAX_SESSIONS) }) + "\n", options.commonDir);
|
|
@@ -217,7 +217,12 @@ Claude or Codex transcripts or file contents.
|
|
|
217
217
|
first report from a repository that still has a 0.7 per-repository record
|
|
218
218
|
boots its launchd job out and removes that record; its pid is never signalled.
|
|
219
219
|
A successful `worktree finish` records the session's completion so its room
|
|
220
|
-
closes; only a new start or an active task reopens it.
|
|
220
|
+
closes; only a new start or an active task reopens it. With Office enabled,
|
|
221
|
+
Claude Code also gets a managed `SessionEnd` hook: when the conversation
|
|
222
|
+
closes, its room closes too, even without a task. It writes no output and
|
|
223
|
+
never fails the host. Other hosts publish no end event and rely on the
|
|
224
|
+
activity timeout. A room without a task shows a no-task board rather than
|
|
225
|
+
pending verification and review. Session metadata contains bounded
|
|
221
226
|
identifiers, known work state and activity times, never native transcripts,
|
|
222
227
|
prompts or tool/file contents. Startup and observation failures are advisory.
|
|
223
228
|
The server binds
|
|
@@ -187,7 +187,10 @@ base 的 `git diff`;旁白只使用路徑(`docs/**` 為「writing docs」、
|
|
|
187
187
|
帶有 repository 名稱,key 以其區隔,頁面可依 repository 篩選。仍留有 0.7
|
|
188
188
|
per-repository 紀錄的 repository 第一次回報時,會 bootout 其 launchd job 並
|
|
189
189
|
刪除該紀錄,絕不對其 pid 發送訊號。`worktree finish` 成功後會記錄 session
|
|
190
|
-
完成,使房間關閉;只有新的啟動或 active task
|
|
190
|
+
完成,使房間關閉;只有新的啟動或 active task 會重新開啟。啟用 Office 時,Claude Code
|
|
191
|
+
另有受管理的 `SessionEnd` hook:對話關閉時房間也隨之關閉,即使沒有 task。它不輸出
|
|
192
|
+
任何內容,也絕不讓 host 失敗。其他 host 沒有結束事件,依活動逾時處理。沒有 task 的
|
|
193
|
+
房間白板顯示「無任務」,而非待驗證與待審查。Session metadata
|
|
191
194
|
只記錄有界限的識別資訊、已知工作狀態與活動時間,不包含 native transcript、
|
|
192
195
|
prompt、tool 或檔案內容。啟動與活動觀察失敗均為 advisory。
|
|
193
196
|
Server 綁定 127.0.0.1 的隨機 port,要求 URL 中不可猜測的 token,拒絕該位址與
|
package/package.json
CHANGED