@cocorograph/hub-agent 0.8.121 → 0.8.122

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.121",
3
+ "version": "0.8.122",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -98,6 +98,8 @@ export class CodexAppServerClient extends EventEmitter {
98
98
  "COCKPIT_SIBLING_SELF_KIND",
99
99
  "COCKPIT_SIBLING_CWD",
100
100
  "COCKPIT_SIBLING_TOKEN",
101
+ "COCKPIT_DELEGATE_DISPLAY_TOKEN",
102
+ "COCKPIT_DELEGATE_EVENT_ACK_VERSION",
101
103
  ]) {
102
104
  const val = spawnEnv[key]
103
105
  // TOML 文字列としてクォート (パスの空白/特殊文字対策)。値に " は含まれない
@@ -4204,7 +4204,7 @@ export class CodexStreamBridge extends EventEmitter {
4204
4204
  }
4205
4205
 
4206
4206
  /** Display events never start a turn and only target an exact thread in this cwd. */
4207
- emitToStream(cwd, sessionId, event) {
4207
+ emitToStream(cwd, sessionId, event, notificationName = "cockpit/review_event") {
4208
4208
  if (!cwd || !sessionId) return { status: "not_found" }
4209
4209
  const matches = [...this._liveSessions()].filter((s) => s.cwd === cwd && s.threadId === sessionId)
4210
4210
  if (matches.length !== 1) return { status: matches.length ? "ambiguous" : "not_found" }
@@ -4214,7 +4214,7 @@ export class CodexStreamBridge extends EventEmitter {
4214
4214
  producer_seq: session.seq,
4215
4215
  thread_id: session.threadId,
4216
4216
  cwd: session.cwd,
4217
- notification: { method: "cockpit/review_event", params: event },
4217
+ notification: { method: notificationName, params: event },
4218
4218
  })
4219
4219
  return { status: "delivered", session: this._describe(session) }
4220
4220
  }
@@ -0,0 +1,80 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"
4
+ import { paths } from "./config.mjs"
5
+
6
+ /**
7
+ * Publish a complete key atomically: write a temp file in the same directory, then link(2) it to
8
+ * the final name. link fails with EEXIST if another process won, so the published file is always
9
+ * complete and never overwritten (overwriting would invalidate tokens already handed out).
10
+ * An existing malformed key is NOT regenerated here; key() rejects it so the failure stays visible.
11
+ */
12
+ function publishKey(dir, file) {
13
+ const tmp = path.join(dir, `key.${process.pid}.${randomBytes(8).toString("hex")}.tmp`)
14
+ try {
15
+ const fd = fs.openSync(tmp, "wx", 0o600)
16
+ try {
17
+ fs.writeFileSync(fd, randomBytes(32))
18
+ fs.fsyncSync(fd)
19
+ } finally { fs.closeSync(fd) }
20
+ try {
21
+ fs.linkSync(tmp, file)
22
+ } catch (err) {
23
+ if (err.code !== "EEXIST") throw new Error("delegate display auth key creation failed")
24
+ }
25
+ } finally {
26
+ try { fs.unlinkSync(tmp) } catch {}
27
+ }
28
+ }
29
+
30
+ function key() {
31
+ const dir = process.env.HUB_DELEGATE_DISPLAY_AUTH_DIR || path.join(paths.configDir, "delegate-display-auth")
32
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
33
+ const directory = fs.lstatSync(dir)
34
+ if (!directory.isDirectory() || directory.isSymbolicLink() || process.getuid?.() == null ||
35
+ directory.uid !== process.getuid() || (directory.mode & 0o777) !== 0o700) {
36
+ throw new Error("unsafe delegate display auth directory")
37
+ }
38
+ const file = path.join(dir, "key")
39
+ let info
40
+ try {
41
+ info = fs.lstatSync(file)
42
+ } catch (err) {
43
+ if (err.code !== "ENOENT") throw err
44
+ publishKey(dir, file)
45
+ info = fs.lstatSync(file)
46
+ }
47
+ if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid() ||
48
+ (info.mode & 0o777) !== 0o600 || info.size !== 32) {
49
+ throw new Error("unsafe delegate display auth key")
50
+ }
51
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
52
+ try {
53
+ const opened = fs.fstatSync(fd)
54
+ if (!opened.isFile() || opened.ino !== info.ino || opened.dev !== info.dev ||
55
+ opened.uid !== info.uid || (opened.mode & 0o777) !== 0o600 || opened.size !== 32) {
56
+ throw new Error("delegate display auth key changed while reading")
57
+ }
58
+ const bytes = Buffer.alloc(33)
59
+ let length = 0
60
+ while (length < bytes.length) {
61
+ const count = fs.readSync(fd, bytes, length, bytes.length - length, null)
62
+ if (!count) break
63
+ length += count
64
+ }
65
+ if (length !== 32) throw new Error("invalid delegate display auth key length")
66
+ return bytes.subarray(0, 32)
67
+ } finally { fs.closeSync(fd) }
68
+ }
69
+
70
+ export function displayTokenForCwd(cwd) {
71
+ if (typeof cwd !== "string" || !cwd) throw new Error("invalid delegate display cwd")
72
+ return "v1." + createHmac("sha256", key()).update("delegate-event\0" + cwd).digest("base64url")
73
+ }
74
+
75
+ export function verifyDisplayToken(cwd, token) {
76
+ if (typeof cwd !== "string" || !cwd || typeof token !== "string" || !/^v1\.[A-Za-z0-9_-]{43}$/.test(token)) return false
77
+ const expected = Buffer.from(displayTokenForCwd(cwd))
78
+ const actual = Buffer.from(token)
79
+ return actual.length === expected.length && timingSafeEqual(actual, expected)
80
+ }
@@ -92,9 +92,13 @@ export class DelegationEventStore {
92
92
  this.queue = pending.catch(() => {})
93
93
  return pending
94
94
  }
95
- _file(cwd, sessionId) {
95
+ _target(cwd, sessionId, kind = "claude") {
96
+ if (!["claude", "codex"].includes(kind)) throw new Error("invalid delegation kind")
97
+ return kind === "codex" ? [cwd, sessionId, kind] : [cwd, sessionId]
98
+ }
99
+ _file(cwd, sessionId, kind = "claude") {
96
100
  if ([cwd, sessionId].some((value) => typeof value !== "string" || !value)) throw new Error("invalid delegation target")
97
- return path.join(this.dir, createHash("sha256").update(JSON.stringify([cwd, sessionId])).digest("hex") + ".json")
101
+ return path.join(this.dir, createHash("sha256").update(JSON.stringify(this._target(cwd, sessionId, kind))).digest("hex") + ".json")
98
102
  }
99
103
  async _checkDirectory() {
100
104
  const info = await fs.lstat(this.dir)
@@ -135,7 +139,7 @@ export class DelegationEventStore {
135
139
  snapshot = JSON.parse(buffer.subarray(0, length).toString("utf8"))
136
140
  } finally { await handle.close() }
137
141
  if (!snapshot || snapshot.schema_version !== 1 || !Array.isArray(snapshot.target) ||
138
- snapshot.target.length !== 2 || snapshot.target.some((v) => typeof v !== "string" || !v) ||
142
+ !(snapshot.target.length === 2 || (snapshot.target.length === 3 && snapshot.target[2] === "codex")) || snapshot.target.some((v) => typeof v !== "string" || !v) ||
139
143
  (target && JSON.stringify(snapshot.target) !== JSON.stringify(target)) ||
140
144
  this._file(...snapshot.target) !== file || !Array.isArray(snapshot.rows)) throw new Error("invalid delegation snapshot")
141
145
  const ids = new Set()
@@ -159,23 +163,23 @@ export class DelegationEventStore {
159
163
  .slice(0, Math.max(0, oldest.length - DELEGATION_SESSION_LIMIT)))
160
164
  return rows.filter((row) => !removed.has(row))
161
165
  }
162
- rows(cwd, sessionId) {
166
+ rows(cwd, sessionId, kind = "claude") {
163
167
  return this._serial(async () => {
164
168
  await this._prepare()
165
- const file = this._file(cwd, sessionId)
166
- return this._prune(await this._read(file, [cwd, sessionId]))
169
+ const file = this._file(cwd, sessionId, kind)
170
+ return this._prune(await this._read(file, this._target(cwd, sessionId, kind)))
167
171
  })
168
172
  }
169
- record(cwd, sessionId, event) {
173
+ record(cwd, sessionId, event, kind = "claude") {
170
174
  // Snapshot caller-owned input before waiting for the writer.
171
175
  const copy = structuredClone(event)
172
- return this._serial(() => this._record(cwd, sessionId, copy))
176
+ return this._serial(() => this._record(cwd, sessionId, copy, kind))
173
177
  }
174
- async _record(cwd, sessionId, event) {
178
+ async _record(cwd, sessionId, event, kind = "claude") {
175
179
  if (!validEvent(event)) throw new Error("invalid delegation event")
176
180
  await this._prepare()
177
- const file = this._file(cwd, sessionId)
178
- const target = [cwd, sessionId]
181
+ const file = this._file(cwd, sessionId, kind)
182
+ const target = this._target(cwd, sessionId, kind)
179
183
  const rows = this._prune(await this._read(file, target))
180
184
  const result = this._apply(rows, target, event)
181
185
  if (result.acceptance === "accept") await this._write(file, result.serialized)
@@ -246,10 +250,10 @@ export class DelegationEventStore {
246
250
  }
247
251
  }
248
252
  /** Reconcile the entire liveness plan against one fresh snapshot, then write at most once. */
249
- replayPlan(cwd, sessionId, plan, emit) {
253
+ replayPlan(cwd, sessionId, plan, emit, kind = "claude") {
250
254
  return this._serial(async () => {
251
- const file = this._file(cwd, sessionId)
252
- const target = [cwd, sessionId]
255
+ const file = this._file(cwd, sessionId, kind)
256
+ const target = this._target(cwd, sessionId, kind)
253
257
  const rows = this._prune(await this._read(file, target))
254
258
  const current = new Map(rows.map((row) => [row.id, JSON.stringify(row)]))
255
259
  // One working copy for the whole plan; `current` keeps the snapshot used for matching.
package/src/main.mjs CHANGED
@@ -4365,6 +4365,15 @@ export async function dispatch(msg, ctx) {
4365
4365
  } catch (err) {
4366
4366
  ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events")
4367
4367
  }
4368
+ try {
4369
+ if (info.threadId) {
4370
+ void ctx.siblingInjectBridge
4371
+ ?.replayDelegations?.(resolvedCwd || process.env.HOME || process.cwd(), info.threadId, "codex")
4372
+ ?.catch((err) => ctx.logger?.warn?.({ err: err?.message }, "failed to replay delegation events"))
4373
+ }
4374
+ } catch (err) {
4375
+ ctx.logger?.warn?.({ err: err?.message }, "failed to replay delegation events")
4376
+ }
4368
4377
  const session = ctx.codexBridge.sessions?.get(stream_id)
4369
4378
  if (session && info.resuming) {
4370
4379
  void loadResumeChatContext(session, "codex").catch(() => {})
@@ -71,6 +71,7 @@
71
71
  * 判断材料として **外部契約に残す**。
72
72
  */
73
73
 
74
+ import { verifyDisplayToken } from "./delegate-display-auth.mjs"
74
75
  import { watch } from "node:fs"
75
76
  import { chmod, lstat, mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/promises"
76
77
  import { execFile as execFileCallback } from "node:child_process"
@@ -458,6 +459,12 @@ export class SiblingInjectBridge {
458
459
  }
459
460
  }
460
461
 
462
+ _emitDelegation(kind, cwd, sessionId, event) {
463
+ const bridge = kind === "codex" ? this.codexBridge : this.claudeBridge
464
+ return bridge?.emitToStream?.(cwd, sessionId, event,
465
+ ...(kind === "codex" ? ["cockpit/delegate_event"] : [])) || { status: "not_found" }
466
+ }
467
+
461
468
  _liveClaudeSession(cwd, sessionId) {
462
469
  const matches = describeLiveSessionsUnion(this._bridges(), {
463
470
  cwd,
@@ -497,10 +504,10 @@ export class SiblingInjectBridge {
497
504
  }
498
505
 
499
506
  /** attach/再接続時に同じ cwd+session の進行中・終了済み委譲を開始順で再送する。 */
500
- async replayDelegations(cwd, sessionId) {
507
+ async replayDelegations(cwd, sessionId, kind = "claude") {
501
508
  if (!cwd || !sessionId) return
502
509
  try {
503
- const snapshot = await this.delegationEvents.rows(cwd, sessionId)
510
+ const snapshot = await this.delegationEvents.rows(cwd, sessionId, kind)
504
511
  const rows = snapshot.filter((row) => row.started).sort((a, b) => a.startedAt - b.startedAt)
505
512
  const plan = []
506
513
  for (const row of rows) {
@@ -526,7 +533,7 @@ export class SiblingInjectBridge {
526
533
  }
527
534
  }
528
535
  await this.delegationEvents.replayPlan(cwd, sessionId, plan,
529
- (event) => this.claudeBridge?.emitToStream?.(cwd, sessionId, event))
536
+ (event) => this._emitDelegation(kind, cwd, sessionId, event), kind)
530
537
  } catch (err) {
531
538
  this.logger?.warn?.({ error: err?.message }, "delegation replay failed")
532
539
  }
@@ -749,6 +756,8 @@ export class SiblingInjectBridge {
749
756
  pid,
750
757
  pid_started_at,
751
758
  request_ack,
759
+ display_auth,
760
+ ack_capability,
752
761
  } = body || {}
753
762
  // 連携カード種別 (Phase 2, 2026-07-23): 注入を受け手画面で専用カード表示させるためのタグ。
754
763
  // 既知値 ('peer'|'orchestrator') のみ通し、未知値/未指定は undefined に落とす (未知の source を
@@ -758,6 +767,15 @@ export class SiblingInjectBridge {
758
767
  const isList = op === "list"
759
768
  const isExplicit = op === "inject"
760
769
  const isDelegateEvent = op === "delegate-event"
770
+ const ackV2 = isDelegateEvent && request_ack === true && ack_capability === "persisted-delivered-v2"
771
+ const delegateKind = target_cli_kind === undefined ? "claude" : target_cli_kind
772
+ let displayAuthorized = false
773
+ const hasDisplayAuth = isDelegateEvent && Object.hasOwn(body, "display_auth")
774
+ if (hasDisplayAuth) {
775
+ try { displayAuthorized = verifyDisplayToken(cwd, display_auth) } catch {
776
+ this.logger?.warn?.({}, "delegate display auth unavailable")
777
+ }
778
+ }
761
779
  const isReviewEvent = op === "review-event"
762
780
  const hasRequestAck = Object.prototype.hasOwnProperty.call(body || {}, "request_ack")
763
781
  const isRestart = op === "restart"
@@ -765,6 +783,13 @@ export class SiblingInjectBridge {
765
783
  const badKind = !["claude", "codex", "grok"].includes(target_cli_kind)
766
784
  const delegatePhases = new Set(["started", "completed", "failed", "stopped"])
767
785
  const badDelegateBody =
786
+ !["claude", "codex"].includes(delegateKind) ||
787
+ (target_session_id !== undefined && typeof target_session_id !== "string") ||
788
+ Object.keys(body || {}).some((key) => ![
789
+ "op", "cwd", "target_cli_kind", "token", "target_session_id", "delegation_id", "phase",
790
+ "agent", "description", "background", "prompt", "timestamp", "pid", "pid_started_at",
791
+ "request_ack", "display_auth", "ack_capability",
792
+ ].includes(key)) ||
768
793
  typeof delegation_id !== "string" ||
769
794
  !delegation_id ||
770
795
  delegation_id.length > DELEGATION_ID_MAX_LENGTH ||
@@ -782,7 +807,7 @@ export class SiblingInjectBridge {
782
807
  const badBody = isReviewEvent
783
808
  ? !validReviewEvent(body.event) || typeof target_session_id !== "string" || !target_session_id ||
784
809
  typeof cwd !== "string" || !path.isAbsolute(cwd) ||
785
- Object.keys(body).some((key) => !["op", "token", "cwd", "target_cli_kind", "target_session_id", "event"].includes(key)) ||
810
+ Object.keys(body).some((key) => !["op", "token", "cwd", "target_cli_kind", "target_session_id", "event", "display_auth", "ack_capability"].includes(key)) ||
786
811
  !requestInfo.isFile() || requestInfo.isSymbolicLink() || requestInfo.uid !== process.getuid?.() ||
787
812
  (requestInfo.mode & 0o777) !== 0o600
788
813
  : isDelegateEvent
@@ -833,7 +858,7 @@ export class SiblingInjectBridge {
833
858
  }
834
859
  } else if (!cwd || (!isDelegateEvent && badKind) || badBody) {
835
860
  result = { ok: false, error: "invalid_request" }
836
- } else if (!verifySiblingTokenAny(token)) {
861
+ } else if (hasDisplayAuth ? !displayAuthorized : !verifySiblingTokenAny(token)) {
837
862
  // 2026-07-23 統一再設計 1-2: 認可を「ターゲット cwd 所有」→「いずれかの cwd の有効トークン
838
863
  // 所有 (= 同一ユーザーの spawn 済みセッション)」へ緩和し、クロス cwd 連携を解禁する。
839
864
  // cwd は引き続きターゲット解決のスコープとして使う (誤 cwd 着弾・情報漏洩は解決側で担保)。
@@ -895,18 +920,32 @@ export class SiblingInjectBridge {
895
920
  let acceptance
896
921
  let persisted = true
897
922
  try {
898
- acceptance = await this.delegationEvents.record(cwd, target_session_id, event)
923
+ acceptance = await this.delegationEvents.record(cwd, target_session_id, event, delegateKind)
899
924
  } catch (err) {
900
925
  persisted = false
901
926
  this.logger?.warn?.({ request_id, error: err?.message }, "delegation persistence failed")
902
927
  }
903
- if (acceptance === "duplicate") {
928
+ if (ackV2) {
929
+ persisted = persisted && acceptance !== "full"
930
+ const delivery = acceptance === "stale" ? { status: "not_found" }
931
+ : this._emitDelegation(delegateKind, cwd, target_session_id, event)
932
+ const delivered = delivery.status === "delivered"
933
+ result = !persisted
934
+ ? { ok: false, ack_version: 2, persisted: false, delivered,
935
+ error: acceptance === "full" ? "delegation_store_full" : "delegation_persistence_failed", retryable: true }
936
+ : { ok: true, ack_version: 2, persisted: true, delivered,
937
+ ...(acceptance === "duplicate" ? { duplicate: true } : {}),
938
+ ...(acceptance === "stale" ? { stale: true } : {}),
939
+ ...(!delivered && acceptance !== "stale" && acceptance !== "duplicate"
940
+ ? { delivery_status: delivery.status } : {}) }
941
+ } else if (acceptance === "duplicate") {
904
942
  // ACK は「過去に受理した」だけでなく、対象 stream が現在も live であることも示す。
905
943
  // 台帳だけで成功を返すと、宛先消失後の retry を受理済みと誤認させる。
906
- const live = this._liveClaudeSession(cwd, target_session_id)
944
+ const live = delegateKind === "claude" ? this._liveClaudeSession(cwd, target_session_id)
945
+ : { status: "delivered" }
907
946
  // 保存後に未配送のまま切断した可能性があるため、duplicate も再配送する。
908
947
  const delivered = live.status === "delivered"
909
- ? this.claudeBridge?.emitToStream?.(cwd, target_session_id, event) || { status: "not_found" }
948
+ ? this._emitDelegation(delegateKind, cwd, target_session_id, event)
910
949
  : live
911
950
  result = delivered.status === "delivered"
912
951
  ? { ok: true, duplicate: true }
@@ -916,9 +955,7 @@ export class SiblingInjectBridge {
916
955
  } else if (acceptance === "full") {
917
956
  result = { ok: false, error: "delegation_store_full" }
918
957
  } else {
919
- const r = this.claudeBridge?.emitToStream
920
- ? this.claudeBridge.emitToStream(cwd, target_session_id, event)
921
- : { status: "not_found" }
958
+ const r = this._emitDelegation(delegateKind, cwd, target_session_id, event)
922
959
  if (r.status === "delivered") {
923
960
  result = { ok: true, delivered_to: r.session }
924
961
  } else {
@@ -1109,6 +1146,10 @@ export class SiblingInjectBridge {
1109
1146
  }
1110
1147
  }
1111
1148
 
1149
+ if (ackV2 && result.ack_version !== 2) {
1150
+ result = { ok: false, ack_version: 2, persisted: false, delivered: false,
1151
+ error: result.error, retryable: false }
1152
+ }
1112
1153
  this.logger?.[result.ok ? "info" : "warn"]?.(
1113
1154
  {
1114
1155
  request_id,
@@ -24,6 +24,8 @@
24
24
  * 範囲がこの cwd 限定トークンより大きくなるため。
25
25
  */
26
26
 
27
+ import { displayTokenForCwd } from "./delegate-display-auth.mjs"
28
+
27
29
  import { randomBytes } from "node:crypto"
28
30
 
29
31
  /** @type {Map<string, string>} cwd -> token */
@@ -126,6 +128,14 @@ export function buildSiblingEnv(cwd, { selfKind, delegateEventAck = false, strip
126
128
  ...(delegateEventAck ? { COCKPIT_DELEGATE_EVENT_ACK: "1" } : {}),
127
129
  COCKPIT_SIBLING_SELF_KIND: selfKind,
128
130
  }
131
+ delete env.COCKPIT_DELEGATE_DISPLAY_TOKEN
132
+ delete env.COCKPIT_DELEGATE_EVENT_ACK_VERSION
133
+ try {
134
+ env.COCKPIT_DELEGATE_DISPLAY_TOKEN = displayTokenForCwd(cwd)
135
+ env.COCKPIT_DELEGATE_EVENT_ACK_VERSION = "2"
136
+ } catch {
137
+ console.warn("delegate display auth unavailable; display capability omitted")
138
+ }
129
139
  // ACK を付けない CLI では、継承元に ACK があっても子へ持ち込まない。
130
140
  if (!delegateEventAck) delete env.COCKPIT_DELEGATE_EVENT_ACK
131
141
  if (stripParentSessionIds) {