@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.
@@ -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) {
@@ -8,7 +8,8 @@ export const CLAUDE_SUPPORTED_EVENTS = [
8
8
  "PostCompact",
9
9
  "SubagentStart",
10
10
  "SubagentStop",
11
- "Stop"
11
+ "Stop",
12
+ "SessionEnd"
12
13
  ];
13
14
  export function claudeNonInteractiveTrust(printMode) {
14
15
  return printMode ? "dialog-skipped" : "interactive-dialog";
@@ -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
- fillText(t('progress')+' '+b.passed+'/'+(b.total||'?'),bx+6*unit,by+16*unit,10,PALETTE[1]);
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; a persistent host heartbeat can replace it if needed.
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 = record.completedAt === null || record.completedAt === undefined
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 !== "start" && previous?.completedAt !== undefined ? { completedAt: previous.completedAt } : {})
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);
@@ -236,7 +236,7 @@ export class CodexGoalHost {
236
236
  clientInfo: {
237
237
  name: "agent-ops",
238
238
  title: "agent-ops native goal host",
239
- version: "0.7.1"
239
+ version: "0.7.3"
240
240
  }
241
241
  });
242
242
  session.notify("initialized", {});
@@ -28,7 +28,8 @@ const HOOK_EVENT_VALUES = new Set([
28
28
  "PostCompact",
29
29
  "SubagentStart",
30
30
  "SubagentStop",
31
- "Stop"
31
+ "Stop",
32
+ "SessionEnd"
32
33
  ]);
33
34
  const MAX_TIMEOUT_MS = 2_147_483_647;
34
35
  const MAX_EXIT_CODE = 4_294_967_295;
@@ -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. Session metadata contains bounded
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 會重新開啟。Session metadata
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Evidence-driven development loops for agy, Codex, Claude Code, and opencode",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -144,7 +144,8 @@
144
144
  "PostCompact",
145
145
  "SubagentStart",
146
146
  "SubagentStop",
147
- "Stop"
147
+ "Stop",
148
+ "SessionEnd"
148
149
  ]
149
150
  }
150
151
  },