@cocorograph/hub-agent 0.8.70 → 0.8.72

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.70",
3
+ "version": "0.8.72",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,83 @@
1
+ import { createReadStream } from "node:fs"
2
+ import { createInterface } from "node:readline"
3
+ import path from "node:path"
4
+ import os from "node:os"
5
+
6
+ export function validateCodexSessionOperation({ operation, cwd, session_id, title, configDir }) {
7
+ if (typeof cwd !== "string" || !path.isAbsolute(cwd)) return "cwd required"
8
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(session_id)) return "invalid session_id"
9
+ if (typeof configDir !== "string" || !path.isAbsolute(configDir)) return "unknown profile"
10
+ if (operation !== "rename" && operation !== "delete") return "unsupported operation"
11
+ if (operation === "rename") {
12
+ if (typeof title !== "string") return "title must be a string"
13
+ const name = title.trim().replace(/\s+/g, " ")
14
+ if (!name) return "title required"
15
+ if (Array.from(name).length > 200) return "title too long"
16
+ }
17
+ return null
18
+ }
19
+
20
+ /** Use only the selected profile's app-server. Never fall back to archive or local deletion. */
21
+ export async function operateCodexSession(options) {
22
+ const error = validateCodexSessionOperation(options)
23
+ if (error) return { ok: false, error }
24
+ const { operation, cwd, session_id, title, configDir, clientFactory, beforeMutation, logger } = options
25
+ let client
26
+ try {
27
+ client = clientFactory({ cwd, env: { ...process.env, CODEX_HOME: configDir } })
28
+ client.on("error", (err) => {
29
+ logger?.warn?.({ operation, code: err?.code, reason: err?.reason }, "codex session client error")
30
+ })
31
+ client.start()
32
+ await client.request("initialize", { clientInfo: { name: "cockpit-session-operations", version: "1.0.0" } })
33
+ client.notify("initialized", {})
34
+ const result = await client.request("thread/read", { threadId: session_id, includeTurns: false })
35
+ if (result?.thread?.id !== session_id || result.thread.cwd !== cwd) {
36
+ return { ok: false, error: "session context mismatch" }
37
+ }
38
+ const blocked = beforeMutation?.()
39
+ if (blocked) return { ok: false, error: blocked }
40
+ if (operation === "rename") {
41
+ const custom_title = title.trim().replace(/\s+/g, " ")
42
+ await client.request("thread/name/set", { threadId: session_id, name: custom_title })
43
+ return { ok: true, custom_title }
44
+ }
45
+ await client.request("thread/delete", { threadId: session_id })
46
+ return { ok: true }
47
+ } catch (err) {
48
+ // RPC errors may include paths or user data. Return a fixed public error only.
49
+ logger?.warn?.({ operation, code: err?.code, reason: err?.reason }, "codex session operation failed")
50
+ return { ok: false, error: err?.code === -32601 ? "session operation unsupported" : `${operation} failed` }
51
+ } finally {
52
+ client?.stop()
53
+ }
54
+ }
55
+
56
+ /** Titles are persisted by Codex in the profile's append-only session index. */
57
+ export async function addCodexSessionTitles(sessions, sessionsDir) {
58
+ const root = path.resolve(sessionsDir || process.env.HUB_CODEX_SESSIONS_DIR || path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "sessions"))
59
+ const wanted = new Set(sessions.map((session) => session.thread_id))
60
+ if (!wanted.size) return sessions
61
+ const titles = new Map()
62
+ const input = createReadStream(path.join(path.dirname(root), "session_index.jsonl"), { encoding: "utf8" })
63
+ const lines = createInterface({ input, crlfDelay: Infinity })
64
+ try {
65
+ for await (const line of lines) {
66
+ let row
67
+ try { row = JSON.parse(line) } catch { continue }
68
+ if (!wanted.has(row?.id) || typeof row.thread_name !== "string") continue
69
+ const time = Date.parse(row.updated_at)
70
+ if (!Number.isFinite(time)) continue
71
+ const previous = titles.get(row.id)
72
+ if (!previous || time >= previous.time) titles.set(row.id, { title: row.thread_name, time })
73
+ }
74
+ } catch {
75
+ return sessions
76
+ } finally {
77
+ lines.close()
78
+ input.destroy()
79
+ }
80
+ return sessions.map((session) => titles.has(session.thread_id)
81
+ ? { ...session, title: titles.get(session.thread_id).title }
82
+ : session)
83
+ }
@@ -42,11 +42,14 @@
42
42
  */
43
43
  import { EventEmitter } from "node:events"
44
44
  import { randomUUID } from "node:crypto"
45
+ import path from "node:path"
45
46
 
46
47
  import { recordInjectSourcePersistent } from "./inject-source-store.mjs"
47
48
 
48
49
  import { AsyncTtlCache } from "./async-ttl-cache.mjs"
49
50
  import { CodexAppServerClient } from "./codex-appserver-client.mjs"
51
+ import { addCodexSessionTitles, operateCodexSession, validateCodexSessionOperation } from "./codex-session-operations.mjs"
52
+ import { defaultCodexDir } from "./codex-profiles.mjs"
50
53
  import {
51
54
  buildCodexArtifactTemplatePickerConfig,
52
55
  preflightCodexArtifactTemplatePicker,
@@ -949,6 +952,7 @@ export class CodexStreamBridge extends EventEmitter {
949
952
  // #1 in-memory reattach のキー。attach 試行を跨いで安定 (WS 再接続で stream_id は変わる)。
950
953
  attachId: attachId || null,
951
954
  client,
955
+ codexHome: client.env?.CODEX_HOME || process.env.CODEX_HOME || defaultCodexDir(),
952
956
  threadId: null,
953
957
  // 新規 thread/start は、最初の usage.response を受け取るまで context 使用量が 0%。
954
958
  // state loop が同じ cwd の古い TUI 値を拾って上書きしないための境界マーカー。
@@ -976,6 +980,8 @@ export class CodexStreamBridge extends EventEmitter {
976
980
  /** 取消成功 id は accepted(二重実行ガード)から外さず、browser が「accepted +キュー不在」
977
981
  * のバブル昇格から除外するための別軸として保持する。 */
978
982
  cancelledInputIds: [],
983
+ /** 取消が input より先着した場合の guard。後着 input で消費する。 */
984
+ canceledInputIds: new Set(),
979
985
  /** @type {{resolve: Function, timer: any} | null} round3 major#2: turn/start 後に turn/started
980
986
  * (activeTurnId 確定) を待つ barrier の待機者。timeout 付きでハングを防ぐ。 */
981
987
  _turnActiveWaiter: null,
@@ -1961,7 +1967,42 @@ export class CodexStreamBridge extends EventEmitter {
1961
1967
  // Phase 3 (Read-plane スライス2a): app-server を spawn せず rollout の disk 走査で一覧を作る。
1962
1968
  // updated_at_ms は rollout ファイルの mtime (= 最終書き込み時刻 = ターン境界に相当) で、
1963
1969
  // claude 側 ClaudeSessionSummary.mtime と単位・意味を揃える。並びは mtime desc。
1964
- return this._listThreadsFromDisk({ cwd, limit, sessionsDir, logger: this.logger })
1970
+ const sessions = await this._listThreadsFromDisk({ cwd, limit, sessionsDir, logger: this.logger })
1971
+ return addCodexSessionTitles(sessions, sessionsDir)
1972
+ }
1973
+
1974
+ async operateSession(options) {
1975
+ const invalid = validateCodexSessionOperation(options)
1976
+ if (invalid) return { ok: false, error: invalid }
1977
+ const { operation, cwd, session_id, configDir } = options
1978
+ const candidates = () => this._liveSessions().filter((session) =>
1979
+ session.cwd === cwd && (session.threadId || session.resumeThreadId) === session_id)
1980
+ const matching = () => candidates().filter((session) =>
1981
+ path.resolve(session.codexHome) === path.resolve(configDir))
1982
+ const busy = () => {
1983
+ const live = candidates()
1984
+ const selected = matching()
1985
+ // Shared rollout paths mean a profile mismatch is not proof that deletion is safe.
1986
+ if (live.length !== selected.length) return "session busy"
1987
+ return operation === "delete" && selected.some((session) =>
1988
+ session.activeTurnId || this._isProtected(session, Date.now())) ? "session busy" : null
1989
+ }
1990
+ const blocked = busy()
1991
+ if (blocked) return { ok: false, error: blocked }
1992
+ const result = await operateCodexSession({
1993
+ ...options,
1994
+ clientFactory: (opts) => this._clientFactory({ ...opts, codexCmd: this.codexCmd, logger: this.logger }),
1995
+ logger: this.logger,
1996
+ beforeMutation: busy,
1997
+ })
1998
+ if (!result.ok) return result
1999
+ const deletedLive = operation === "delete" ? matching() : []
2000
+ const response = { ...result, ...(operation === "delete" ? { stopped: deletedLive.length } : {}) }
2001
+ this._invalidateListThreadsCacheForCwd(cwd)
2002
+ // The request must settle before exit clears the browser's pending requests.
2003
+ options.onSuccess?.(response)
2004
+ for (const session of deletedLive) this._reapSession(session, Date.now(), "session-deleted")
2005
+ return response
1965
2006
  }
1966
2007
 
1967
2008
  /**
@@ -2127,16 +2168,34 @@ export class CodexStreamBridge extends EventEmitter {
2127
2168
  /** 次ターン FIFO に残っている browser 入力を id 指定で取り消す。
2128
2169
  * dispatchingInput は既に turn/start RPC 中なので対象外(実行開始済みとして扱う)。 */
2129
2170
  cancelQueued({ stream_id, id }) {
2130
- const session = this._require(stream_id)
2131
- if (!id) return false
2171
+ const session = this.sessions.get(stream_id)
2172
+ if (!session || session.dead) {
2173
+ return { outcome: "stream_missing", flush: () => {} }
2174
+ }
2175
+ if (typeof id !== "string" || !id) throw new Error("invalid_input_id")
2132
2176
  const index = session.inputQueue.findIndex(
2133
2177
  (item) =>
2134
2178
  (item?.inputId === id || item?.deliveryId === id) &&
2135
2179
  item !== session.dispatchingInput,
2136
2180
  )
2137
2181
  if (index < 0) {
2138
- this._emitQueueState(session)
2139
- return false
2182
+ // accepted は二重実行防止の記録。取消済みなら再要求でも実行開始済みに変えない。
2183
+ if (session.cancelledInputIds?.includes(id)) {
2184
+ return { outcome: "cancelled", flush: () => this._emitQueueState(session) }
2185
+ }
2186
+ const dispatching = session.dispatchingInput
2187
+ const alreadyStarted =
2188
+ dispatching?.inputId === id ||
2189
+ dispatching?.deliveryId === id ||
2190
+ session.acceptedInputIds?.includes(id)
2191
+ if (!alreadyStarted) {
2192
+ if (session.canceledInputIds.size >= 1024) session.canceledInputIds.clear()
2193
+ session.canceledInputIds.add(id)
2194
+ }
2195
+ return {
2196
+ outcome: alreadyStarted ? "already_started" : "cancelled",
2197
+ flush: () => this._emitQueueState(session),
2198
+ }
2140
2199
  }
2141
2200
  const [item] = session.inputQueue.splice(index, 1)
2142
2201
  this._recordCancelledInput(session, item.inputId || item.deliveryId)
@@ -2146,8 +2205,8 @@ export class CodexStreamBridge extends EventEmitter {
2146
2205
  })
2147
2206
  item.reject(err)
2148
2207
  }
2149
- this._emitQueueState(session)
2150
- return true
2208
+ // main が outcome を送った後で snapshot を送る。
2209
+ return { outcome: "cancelled", flush: () => this._emitQueueState(session) }
2151
2210
  }
2152
2211
 
2153
2212
  _emitQueueState(session, startedItem = null) {
@@ -3534,6 +3593,12 @@ export class CodexStreamBridge extends EventEmitter {
3534
3593
  this._touch(session, stream_id)
3535
3594
  const input = userInputFromMessage(message)
3536
3595
  const text = input.map((part) => part?.text || "").join("\n")
3596
+ if (inputId && session.canceledInputIds.delete(inputId)) {
3597
+ this._recordAcceptedInput(session, inputId)
3598
+ this._recordCancelledInput(session, inputId)
3599
+ this._emitQueueState(session)
3600
+ return { method: "queued", deliveryId }
3601
+ }
3537
3602
  if (inputId && session.acceptedInputIds?.includes(inputId)) {
3538
3603
  return { method: session.activeTurnId ? "queued" : "turn/start", deliveryId }
3539
3604
  }
@@ -4572,7 +4637,7 @@ export class CodexStreamBridge extends EventEmitter {
4572
4637
  producer_seq: session.seq,
4573
4638
  thread_id: session.threadId,
4574
4639
  code: 0,
4575
- reason: "idle-reaped",
4640
+ reason: reason === "session-deleted" ? "session-deleted" : "idle-reaped",
4576
4641
  })
4577
4642
  }
4578
4643
 
package/src/main.mjs CHANGED
@@ -2331,6 +2331,8 @@ export function buildCodexReadyPayload(streamId, info, queueState) {
2331
2331
  // Browser は旧 hub-agent との混在期間、この明示 capability が true のときだけ
2332
2332
  // 子 thread の個別停止 UI を有効化する。省略は未対応を意味する。
2333
2333
  supports_subagent_interrupt: true,
2334
+ // 旧 agent との混在時、取消結果を待てることを明示する。
2335
+ supports_queue_cancel_response: true,
2334
2336
  }
2335
2337
  if (Number.isInteger(info?.historyStart)) payload.history_start = info.historyStart
2336
2338
  if (Number.isInteger(info?.historyTotal)) payload.history_total = info.historyTotal
@@ -4797,9 +4799,16 @@ export async function dispatch(msg, ctx) {
4797
4799
  // turn/steer / turn/interrupt は明示操作の別経路として扱う。
4798
4800
  handleCodexChatInput(msg, ctx)
4799
4801
  return
4800
- case "codex.queue.cancel":
4802
+ case "codex.queue.cancel": {
4803
+ const cancelRes = {
4804
+ type: "codex.queue.response",
4805
+ stream_id: msg.stream_id,
4806
+ request_id: msg.request_id || null,
4807
+ input_id: msg.id || null,
4808
+ }
4809
+ let canceled
4801
4810
  try {
4802
- ctx.codexBridge?.cancelQueued({
4811
+ canceled = ctx.codexBridge?.cancelQueued({
4803
4812
  stream_id: msg.stream_id,
4804
4813
  id: msg.id,
4805
4814
  })
@@ -4808,8 +4817,13 @@ export async function dispatch(msg, ctx) {
4808
4817
  { err: err?.message, stream_id: msg.stream_id, input_id: msg.id },
4809
4818
  "codex queued input cancel failed",
4810
4819
  )
4820
+ ctx.client.send({ ...cancelRes, error: err?.message || "cancel_failed" })
4821
+ return
4811
4822
  }
4823
+ ctx.client.send({ ...cancelRes, outcome: canceled?.outcome || "stream_missing" })
4824
+ canceled?.flush?.()
4812
4825
  return
4826
+ }
4813
4827
  case "codex.permission.reply":
4814
4828
  if (!ctx.codexBridge) return
4815
4829
  ctx.codexBridge.permissionReply({
@@ -4884,6 +4898,7 @@ export async function dispatch(msg, ctx) {
4884
4898
  epoch,
4885
4899
  profile_id: profileId,
4886
4900
  sessions: withLive,
4901
+ session_operations: true,
4887
4902
  })
4888
4903
  } catch (err) {
4889
4904
  ctx.client.send({
@@ -4898,6 +4913,43 @@ export async function dispatch(msg, ctx) {
4898
4913
  }
4899
4914
  return
4900
4915
  }
4916
+ case "codex.session.rename":
4917
+ case "codex.session.delete": {
4918
+ const operation = msg.type === "codex.session.rename" ? "rename" : "delete"
4919
+ const context = {
4920
+ request_id: typeof msg.request_id === "string" ? msg.request_id : undefined,
4921
+ stream_id: msg.stream_id,
4922
+ cwd: typeof msg.cwd === "string" ? msg.cwd : "",
4923
+ session_id: typeof msg.session_id === "string" ? msg.session_id : "",
4924
+ profile_id: typeof msg.profile_id === "string" ? msg.profile_id : undefined,
4925
+ }
4926
+ let result = { ok: false, error: "unknown profile" }
4927
+ let responded = false
4928
+ const respond = (response) => {
4929
+ ctx.client.send({ type: `${msg.type}.response`, ...context, ...response })
4930
+ responded = true
4931
+ }
4932
+ try {
4933
+ const profileApi = ctx.codexProfiles || codexProfiles
4934
+ const resolved = context.profile_id ? await profileApi.resolveConfigDir(context.profile_id) : null
4935
+ const active = resolved?.resolved ? await profileApi.getActiveProfile() : null
4936
+ if (resolved?.resolved && resolved.profileId === context.profile_id && active?.id === resolved.profileId) {
4937
+ result = ctx.codexBridge
4938
+ ? await ctx.codexBridge.operateSession({
4939
+ operation, cwd: context.cwd, session_id: context.session_id,
4940
+ title: msg.title, configDir: resolved.configDir,
4941
+ onSuccess: respond,
4942
+ })
4943
+ : { ok: false, error: "codex unavailable" }
4944
+ } else if (resolved?.resolved && active?.id !== resolved.profileId) {
4945
+ result = { ok: false, error: "inactive profile" }
4946
+ }
4947
+ } catch {
4948
+ result = { ok: false, error: `${operation} failed` }
4949
+ }
4950
+ if (!responded) respond(result)
4951
+ return
4952
+ }
4901
4953
  case "codex.history.request": {
4902
4954
  const stream_id = msg.stream_id
4903
4955
  const thread_id = msg.thread_id
package/src/usage.mjs CHANGED
@@ -23,6 +23,7 @@ import path from "node:path"
23
23
  import { StringDecoder } from "node:string_decoder"
24
24
 
25
25
  import { encodeCwdToDirName } from "./claude-history.mjs"
26
+ import { redactSecrets } from "./tool-result-mask.mjs"
26
27
 
27
28
  function configPath(envKey, ...fallback) {
28
29
  return process.env[envKey] || path.join(...fallback)
@@ -1468,7 +1469,7 @@ export async function codexRolloutPresence(threadId) {
1468
1469
  // 4 フィールド (thread_id / cwd / preview / updated_at_ms) はすべて rollout から安価に取れる:
1469
1470
  // - thread_id: session_meta.session_id (無ければファイル名末尾の UUID)
1470
1471
  // - cwd: session_meta.payload.cwd (先頭行)
1471
- // - preview: 先頭近傍の最初の event_msg/user_message.message (clean な生プロンプト)
1472
+ // - preview: 先頭近傍の user_message または response_item の実ユーザー本文
1472
1473
  // - updated_at_ms: ファイル mtime (Claude 側 listSessions の mtime と単位を揃える)
1473
1474
  // ---------------------------------------------------------------------------
1474
1475
 
@@ -1482,6 +1483,23 @@ function threadIdFromRolloutFilename(fp) {
1482
1483
  const CODEX_ROLLOUT_HEAD_CHUNK_BYTES = 32 * 1024
1483
1484
  const CODEX_ROLLOUT_PREVIEW_MAX_BYTES = 1024 * 1024
1484
1485
 
1486
+ function codexUserPreviewText(text) {
1487
+ if (typeof text !== "string") return ""
1488
+ let remaining = text.trim()
1489
+ // Codex は初期コンテキストも role=user の input_text に保存する。
1490
+ // 既知の注入ブロックだけを先頭から除き、後続に依頼文があれば残す。
1491
+ while (remaining) {
1492
+ if (/^# AGENTS\.md instructions(?:\s|$)/.test(remaining)) return ""
1493
+ const injected = /^<(recommended_plugins|environment_context|user_instructions|skills_instructions)>/.exec(remaining)
1494
+ if (!injected) break
1495
+ const endTag = `</${injected[1]}>`
1496
+ const end = remaining.indexOf(endTag, injected[0].length)
1497
+ if (end < 0) return ""
1498
+ remaining = remaining.slice(end + endTag.length).trimStart()
1499
+ }
1500
+ return redactSecrets(remaining).replace(/\s+/g, " ").trim()
1501
+ }
1502
+
1485
1503
  // sessionsDir ごとの派生インデックス。rollout 自体が正本なので永続化は不要。
1486
1504
  // _codexTokenCountMemo と同じ mtime+size 無効化規約で、変更ファイルだけを再解析する。
1487
1505
  // 共通 helper 化は usage 集計側への回帰範囲を広げるため、一覧用 index として独立させる。
@@ -1561,14 +1579,24 @@ async function parseCodexRolloutHead(fp, expectedCwd = "", testHooks = null) {
1561
1579
  subagent != null &&
1562
1580
  typeof subagent === "object" &&
1563
1581
  Object.prototype.hasOwnProperty.call(subagent, "thread_spawn")
1564
- } else if (
1565
- !preview &&
1566
- o.type === "event_msg" &&
1567
- o.payload &&
1568
- o.payload.type === "user_message" &&
1569
- typeof o.payload.message === "string"
1570
- ) {
1571
- preview = o.payload.message.slice(0, 80)
1582
+ } else if (!preview) {
1583
+ let userText = ""
1584
+ if (o.type === "event_msg" && o.payload?.type === "user_message") {
1585
+ userText = codexUserPreviewText(o.payload.message)
1586
+ } else if (
1587
+ o.type === "response_item" &&
1588
+ o.payload?.type === "message" &&
1589
+ o.payload.role === "user" &&
1590
+ Array.isArray(o.payload.content)
1591
+ ) {
1592
+ userText = o.payload.content
1593
+ .filter((block) => block?.type === "input_text")
1594
+ .map((block) => codexUserPreviewText(block.text))
1595
+ .filter(Boolean)
1596
+ .join(" ")
1597
+ }
1598
+ // code point 単位で切り、絵文字などのサロゲートペアを壊さない。
1599
+ preview = Array.from(userText).slice(0, 80).join("")
1572
1600
  }
1573
1601
  }
1574
1602
 
package/src/ws-client.mjs CHANGED
@@ -81,6 +81,8 @@ const BUFFERED_CTRL_TYPES = new Set([
81
81
  "codex.permission.request",
82
82
  "codex.input.ack",
83
83
  "codex.input.nack",
84
+ "codex.session.rename.response",
85
+ "codex.session.delete.response",
84
86
  "session.event",
85
87
  ])
86
88
  // 制御/イベントバッファの上限。低頻度なので frames は小さめ。