@cocorograph/hub-agent 0.8.70 → 0.8.71

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.71",
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 値を拾って上書きしないための境界マーカー。
@@ -1961,7 +1965,42 @@ export class CodexStreamBridge extends EventEmitter {
1961
1965
  // Phase 3 (Read-plane スライス2a): app-server を spawn せず rollout の disk 走査で一覧を作る。
1962
1966
  // updated_at_ms は rollout ファイルの mtime (= 最終書き込み時刻 = ターン境界に相当) で、
1963
1967
  // claude 側 ClaudeSessionSummary.mtime と単位・意味を揃える。並びは mtime desc。
1964
- return this._listThreadsFromDisk({ cwd, limit, sessionsDir, logger: this.logger })
1968
+ const sessions = await this._listThreadsFromDisk({ cwd, limit, sessionsDir, logger: this.logger })
1969
+ return addCodexSessionTitles(sessions, sessionsDir)
1970
+ }
1971
+
1972
+ async operateSession(options) {
1973
+ const invalid = validateCodexSessionOperation(options)
1974
+ if (invalid) return { ok: false, error: invalid }
1975
+ const { operation, cwd, session_id, configDir } = options
1976
+ const candidates = () => this._liveSessions().filter((session) =>
1977
+ session.cwd === cwd && (session.threadId || session.resumeThreadId) === session_id)
1978
+ const matching = () => candidates().filter((session) =>
1979
+ path.resolve(session.codexHome) === path.resolve(configDir))
1980
+ const busy = () => {
1981
+ const live = candidates()
1982
+ const selected = matching()
1983
+ // Shared rollout paths mean a profile mismatch is not proof that deletion is safe.
1984
+ if (live.length !== selected.length) return "session busy"
1985
+ return operation === "delete" && selected.some((session) =>
1986
+ session.activeTurnId || this._isProtected(session, Date.now())) ? "session busy" : null
1987
+ }
1988
+ const blocked = busy()
1989
+ if (blocked) return { ok: false, error: blocked }
1990
+ const result = await operateCodexSession({
1991
+ ...options,
1992
+ clientFactory: (opts) => this._clientFactory({ ...opts, codexCmd: this.codexCmd, logger: this.logger }),
1993
+ logger: this.logger,
1994
+ beforeMutation: busy,
1995
+ })
1996
+ if (!result.ok) return result
1997
+ const deletedLive = operation === "delete" ? matching() : []
1998
+ const response = { ...result, ...(operation === "delete" ? { stopped: deletedLive.length } : {}) }
1999
+ this._invalidateListThreadsCacheForCwd(cwd)
2000
+ // The request must settle before exit clears the browser's pending requests.
2001
+ options.onSuccess?.(response)
2002
+ for (const session of deletedLive) this._reapSession(session, Date.now(), "session-deleted")
2003
+ return response
1965
2004
  }
1966
2005
 
1967
2006
  /**
@@ -4572,7 +4611,7 @@ export class CodexStreamBridge extends EventEmitter {
4572
4611
  producer_seq: session.seq,
4573
4612
  thread_id: session.threadId,
4574
4613
  code: 0,
4575
- reason: "idle-reaped",
4614
+ reason: reason === "session-deleted" ? "session-deleted" : "idle-reaped",
4576
4615
  })
4577
4616
  }
4578
4617
 
package/src/main.mjs CHANGED
@@ -4884,6 +4884,7 @@ export async function dispatch(msg, ctx) {
4884
4884
  epoch,
4885
4885
  profile_id: profileId,
4886
4886
  sessions: withLive,
4887
+ session_operations: true,
4887
4888
  })
4888
4889
  } catch (err) {
4889
4890
  ctx.client.send({
@@ -4898,6 +4899,43 @@ export async function dispatch(msg, ctx) {
4898
4899
  }
4899
4900
  return
4900
4901
  }
4902
+ case "codex.session.rename":
4903
+ case "codex.session.delete": {
4904
+ const operation = msg.type === "codex.session.rename" ? "rename" : "delete"
4905
+ const context = {
4906
+ request_id: typeof msg.request_id === "string" ? msg.request_id : undefined,
4907
+ stream_id: msg.stream_id,
4908
+ cwd: typeof msg.cwd === "string" ? msg.cwd : "",
4909
+ session_id: typeof msg.session_id === "string" ? msg.session_id : "",
4910
+ profile_id: typeof msg.profile_id === "string" ? msg.profile_id : undefined,
4911
+ }
4912
+ let result = { ok: false, error: "unknown profile" }
4913
+ let responded = false
4914
+ const respond = (response) => {
4915
+ ctx.client.send({ type: `${msg.type}.response`, ...context, ...response })
4916
+ responded = true
4917
+ }
4918
+ try {
4919
+ const profileApi = ctx.codexProfiles || codexProfiles
4920
+ const resolved = context.profile_id ? await profileApi.resolveConfigDir(context.profile_id) : null
4921
+ const active = resolved?.resolved ? await profileApi.getActiveProfile() : null
4922
+ if (resolved?.resolved && resolved.profileId === context.profile_id && active?.id === resolved.profileId) {
4923
+ result = ctx.codexBridge
4924
+ ? await ctx.codexBridge.operateSession({
4925
+ operation, cwd: context.cwd, session_id: context.session_id,
4926
+ title: msg.title, configDir: resolved.configDir,
4927
+ onSuccess: respond,
4928
+ })
4929
+ : { ok: false, error: "codex unavailable" }
4930
+ } else if (resolved?.resolved && active?.id !== resolved.profileId) {
4931
+ result = { ok: false, error: "inactive profile" }
4932
+ }
4933
+ } catch {
4934
+ result = { ok: false, error: `${operation} failed` }
4935
+ }
4936
+ if (!responded) respond(result)
4937
+ return
4938
+ }
4901
4939
  case "codex.history.request": {
4902
4940
  const stream_id = msg.stream_id
4903
4941
  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 は小さめ。