@cocorograph/hub-agent 0.7.89 → 0.7.91

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.7.89",
3
+ "version": "0.7.91",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -26,12 +26,18 @@ import path from "node:path"
26
26
  import crypto from "node:crypto"
27
27
 
28
28
  const DEFAULT_HUB_API = "https://api.hub.cocorograph.com"
29
- const TOKEN_PATH = path.join(os.homedir(), ".claude", ".hub_token.json")
30
29
  const META_DIR = ".cockpit-sync"
31
- const META_FILE = "claude-md.json"
32
- const BASE_FILE = "CLAUDE.md.base"
33
30
  const BAK_KEEP = 3 // 通常 .bak と衝突 .bak それぞれ直近 3 件まで保持
34
31
 
32
+ function configPaths(configFilename = "CLAUDE.md") {
33
+ const filename = configFilename === "AGENTS.md" ? "AGENTS.md" : "CLAUDE.md"
34
+ return {
35
+ filename,
36
+ metaFile: filename === "AGENTS.md" ? "agents-md.json" : "claude-md.json",
37
+ baseFile: `${filename}.base`,
38
+ }
39
+ }
40
+
35
41
  async function readJsonOrNull(p) {
36
42
  try {
37
43
  const text = await fs.readFile(p, "utf-8")
@@ -41,9 +47,13 @@ async function readJsonOrNull(p) {
41
47
  }
42
48
  }
43
49
 
44
- async function loadAccessToken() {
45
- const data = await readJsonOrNull(TOKEN_PATH)
46
- return (data && typeof data.access === "string" && data.access) || null
50
+ async function loadAccessToken(configFilename = "CLAUDE.md") {
51
+ const cliDir = configPaths(configFilename).filename === "AGENTS.md" ? ".codex" : ".claude"
52
+ const primary = await readJsonOrNull(path.join(os.homedir(), cliDir, ".hub_token.json"))
53
+ if (primary && typeof primary.access === "string" && primary.access) return primary.access
54
+ // 既存のhub-agent enrollmentは ~/.claude にtokenを持つため、Codex単独導入前の端末も救済する。
55
+ const legacy = await readJsonOrNull(path.join(os.homedir(), ".claude", ".hub_token.json"))
56
+ return (legacy && typeof legacy.access === "string" && legacy.access) || null
47
57
  }
48
58
 
49
59
  function sha256(s) {
@@ -133,29 +143,29 @@ async function putHubClaudeMd({ hubUrl, accessToken, dirName, body, fetchImpl, l
133
143
  }
134
144
  }
135
145
 
136
- async function readLocalClaudeMd(targetDir) {
146
+ async function readLocalConfigMd(targetDir, configFilename) {
137
147
  try {
138
- return await fs.readFile(path.join(targetDir, "CLAUDE.md"), "utf-8")
148
+ return await fs.readFile(path.join(targetDir, configPaths(configFilename).filename), "utf-8")
139
149
  } catch (err) {
140
150
  if (err?.code === "ENOENT") return null
141
151
  throw err
142
152
  }
143
153
  }
144
154
 
145
- async function writeLocalClaudeMd(targetDir, body) {
146
- await fs.writeFile(path.join(targetDir, "CLAUDE.md"), body, "utf-8")
155
+ async function writeLocalConfigMd(targetDir, body, configFilename) {
156
+ await fs.writeFile(path.join(targetDir, configPaths(configFilename).filename), body, "utf-8")
147
157
  }
148
158
 
149
- async function readMeta(targetDir) {
150
- const metaPath = path.join(targetDir, META_DIR, META_FILE)
159
+ async function readMeta(targetDir, configFilename) {
160
+ const metaPath = path.join(targetDir, META_DIR, configPaths(configFilename).metaFile)
151
161
  return await readJsonOrNull(metaPath)
152
162
  }
153
163
 
154
- async function writeMeta(targetDir, meta) {
164
+ async function writeMeta(targetDir, meta, configFilename) {
155
165
  const metaDir = path.join(targetDir, META_DIR)
156
166
  await fs.mkdir(metaDir, { recursive: true })
157
167
  await fs.writeFile(
158
- path.join(metaDir, META_FILE),
168
+ path.join(metaDir, configPaths(configFilename).metaFile),
159
169
  JSON.stringify(meta, null, 2),
160
170
  "utf-8",
161
171
  )
@@ -171,11 +181,11 @@ async function writeMeta(targetDir, meta) {
171
181
  * meta(claude-md.json) は hash と updated_at しか持たないため、本文そのものが要る
172
182
  * 3-way merge の用途ではこのファイルが必要。
173
183
  */
174
- async function writeBaseBody(targetDir, body) {
184
+ async function writeBaseBody(targetDir, body, configFilename) {
175
185
  if (body == null) return
176
186
  const metaDir = path.join(targetDir, META_DIR)
177
187
  await fs.mkdir(metaDir, { recursive: true })
178
- await fs.writeFile(path.join(metaDir, BASE_FILE), body, "utf-8")
188
+ await fs.writeFile(path.join(metaDir, configPaths(configFilename).baseFile), body, "utf-8")
179
189
  }
180
190
 
181
191
  /**
@@ -184,11 +194,12 @@ async function writeBaseBody(targetDir, body) {
184
194
  *
185
195
  * @param {'normal'|'conflict'} kind バックアップ種別(命名 prefix)
186
196
  */
187
- async function backupLocalClaudeMd(targetDir, body, kind) {
197
+ async function backupLocalConfigMd(targetDir, body, kind, configFilename) {
188
198
  if (body == null) return
189
199
  const metaDir = path.join(targetDir, META_DIR)
190
200
  await fs.mkdir(metaDir, { recursive: true })
191
- const prefix = kind === "conflict" ? "CLAUDE.md.conflict" : "CLAUDE.md"
201
+ const filename = configPaths(configFilename).filename
202
+ const prefix = kind === "conflict" ? `${filename}.conflict` : filename
192
203
  const bakName = makeBakName(prefix)
193
204
  await fs.writeFile(path.join(metaDir, bakName), body, "utf-8")
194
205
  // 同 prefix の .bak を新しい順に BAK_KEEP 件まで残す。
@@ -224,12 +235,13 @@ export async function syncClaudeMdWithHub({
224
235
  accessToken,
225
236
  logger,
226
237
  fetchImpl,
238
+ configFilename = "CLAUDE.md",
227
239
  } = {}) {
228
240
  if (!targetDir) throw new Error("syncClaudeMdWithHub requires targetDir")
229
241
  if (!dirName) throw new Error("syncClaudeMdWithHub requires dirName")
230
242
 
231
243
  const token =
232
- accessToken === undefined ? await loadAccessToken() : accessToken || null
244
+ accessToken === undefined ? await loadAccessToken(configFilename) : accessToken || null
233
245
  if (!token) {
234
246
  logger?.info?.({ dirName }, "syncClaudeMdWithHub: no access token, skip")
235
247
  return { action: "skipped", reason: "no-token" }
@@ -252,11 +264,11 @@ export async function syncClaudeMdWithHub({
252
264
  const hubUpdatedAt = hub.claude_md_updated_at || null
253
265
 
254
266
  // 2) ローカル CLAUDE.md を読み、現在 hash を計算
255
- const localBody = await readLocalClaudeMd(targetDir)
267
+ const localBody = await readLocalConfigMd(targetDir, configFilename)
256
268
  const localCurrentHash = localBody == null ? null : sha256(localBody)
257
269
 
258
270
  // 3) メタファイルを読み、前回 sync の状態を取得
259
- const meta = await readMeta(targetDir)
271
+ const meta = await readMeta(targetDir, configFilename)
260
272
 
261
273
  // Hub が一度も保存されていない(空本文 + updated_at が null)プロジェクトは
262
274
  // 「Hub UI で意図的に未設定にしている」運用(テナント標準 CLAUDE.md に任せる等)の
@@ -276,7 +288,12 @@ export async function syncClaudeMdWithHub({
276
288
  if (!meta) {
277
289
  // 初回 sync。
278
290
  hubChanged = !!hubBody
279
- if (localBody == null) {
291
+ if (configPaths(configFilename).filename === "AGENTS.md") {
292
+ // Codex の AGENTS.md は既存リポジトリにも通常存在し、案件正本とは別の
293
+ // ローカル規約を含む。初回に terminal-wins で Project.claude_md を壊さないよう、
294
+ // Hub 正本を優先して pull(既存 AGENTS.md は .bak に退避)する。
295
+ localChanged = false
296
+ } else if (localBody == null) {
280
297
  localChanged = false
281
298
  } else {
282
299
  localChanged = !!localBody // 非空なら端末側に何かある=衝突候補
@@ -300,15 +317,15 @@ export async function syncClaudeMdWithHub({
300
317
 
301
318
  if (hubChanged && !localChanged) {
302
319
  // pull: ローカルを .bak 退避 → Hub の本文で上書き
303
- await backupLocalClaudeMd(targetDir, localBody, "normal")
304
- await writeLocalClaudeMd(targetDir, hubBody)
320
+ await backupLocalConfigMd(targetDir, localBody, "normal", configFilename)
321
+ await writeLocalConfigMd(targetDir, hubBody, configFilename)
305
322
  await writeMeta(targetDir, {
306
323
  lastSyncedHubUpdatedAt: hubUpdatedAt,
307
324
  lastSyncedHash: hubHash || sha256(hubBody),
308
325
  lastSyncedAt: nowIso(),
309
- })
326
+ }, configFilename)
310
327
  // 3-way merge 用 base: pull 直後はローカル == Hub なので Hub 本文を base に。
311
- await writeBaseBody(targetDir, hubBody)
328
+ await writeBaseBody(targetDir, hubBody, configFilename)
312
329
  logger?.info?.({ dirName }, "syncClaudeMdWithHub: pull")
313
330
  return { action: "pull" }
314
331
  }
@@ -334,17 +351,17 @@ export async function syncClaudeMdWithHub({
334
351
  lastSyncedHubUpdatedAt: nowIso(),
335
352
  lastSyncedHash: localCurrentHash,
336
353
  lastSyncedAt: nowIso(),
337
- })
354
+ }, configFilename)
338
355
  // 3-way merge 用 base: push 直後は Hub の正本がローカルと一致するので、
339
356
  // 次回の base は今 push した本文 (= localBody)。
340
- await writeBaseBody(targetDir, localBody || "")
357
+ await writeBaseBody(targetDir, localBody || "", configFilename)
341
358
  logger?.info?.({ dirName }, "syncClaudeMdWithHub: push")
342
359
  return { action: "push" }
343
360
  }
344
361
 
345
362
  // hubChanged && localChanged → 衝突。端末側を勝者にして Hub に PUT。
346
363
  // Hub の取り込み内容は .bak.conflict に退避(後で人手マージできるよう)。
347
- await backupLocalClaudeMd(targetDir, hubBody, "conflict")
364
+ await backupLocalConfigMd(targetDir, hubBody, "conflict", configFilename)
348
365
  const ok = await putHubClaudeMd({
349
366
  hubUrl: url,
350
367
  accessToken: token,
@@ -361,9 +378,9 @@ export async function syncClaudeMdWithHub({
361
378
  lastSyncedHubUpdatedAt: nowIso(),
362
379
  lastSyncedHash: localCurrentHash,
363
380
  lastSyncedAt: nowIso(),
364
- })
381
+ }, configFilename)
365
382
  // 3-way merge 用 base: conflict push 後の Hub 正本は端末側本文 (= localBody)。
366
- await writeBaseBody(targetDir, localBody || "")
383
+ await writeBaseBody(targetDir, localBody || "", configFilename)
367
384
  logger?.warn?.(
368
385
  { dirName },
369
386
  "syncClaudeMdWithHub: conflict (terminal wins); hub body saved to .bak.conflict",
@@ -374,7 +391,7 @@ export async function syncClaudeMdWithHub({
374
391
  // メタ操作・ハッシュ計算は ensureClaudeMd 側 (Hub pull 初回配信) でも使うため
375
392
  // named export する。同じファイルパス・同じスキーマで管理することで、初回配信と
376
393
  // 以降の sync で食い違いを起こさない。
377
- export { sha256, readMeta, writeMeta, writeBaseBody, nowIso }
394
+ export { sha256, readMeta, writeMeta, writeBaseBody, nowIso, configPaths }
378
395
 
379
396
  // 内部テスト用エクスポート
380
397
  export const _internals = {
@@ -382,7 +399,7 @@ export const _internals = {
382
399
  readMeta,
383
400
  writeMeta,
384
401
  writeBaseBody,
385
- backupLocalClaudeMd,
402
+ backupLocalConfigMd,
386
403
  BAK_KEEP,
387
- BASE_FILE,
404
+ configPaths,
388
405
  }
package/src/claude-md.mjs CHANGED
@@ -27,7 +27,6 @@ import {
27
27
  } from "./claude-md-sync.mjs"
28
28
 
29
29
  const DEFAULT_HUB_API = "https://api.hub.cocorograph.com"
30
- const TOKEN_PATH = path.join(os.homedir(), ".claude", ".hub_token.json")
31
30
 
32
31
  async function readJsonOrNull(p) {
33
32
  try {
@@ -38,9 +37,12 @@ async function readJsonOrNull(p) {
38
37
  }
39
38
  }
40
39
 
41
- async function loadAccessToken() {
42
- const data = await readJsonOrNull(TOKEN_PATH)
43
- return (data && typeof data.access === "string" && data.access) || null
40
+ async function loadAccessToken(configFilename = "CLAUDE.md") {
41
+ const cliDir = configFilename === "AGENTS.md" ? ".codex" : ".claude"
42
+ const primary = await readJsonOrNull(path.join(os.homedir(), cliDir, ".hub_token.json"))
43
+ if (primary && typeof primary.access === "string" && primary.access) return primary.access
44
+ const legacy = await readJsonOrNull(path.join(os.homedir(), ".claude", ".hub_token.json"))
45
+ return (legacy && typeof legacy.access === "string" && legacy.access) || null
44
46
  }
45
47
 
46
48
  /**
@@ -134,7 +136,7 @@ function isGitHubSlug(repoUrl) {
134
136
  return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repoUrl)
135
137
  }
136
138
 
137
- function renderRepositorySection(repositories, dirName = "") {
139
+ function renderRepositorySection(repositories, dirName = "", cliKind = "claude") {
138
140
  if (!Array.isArray(repositories) || repositories.length === 0) return ""
139
141
 
140
142
  const lines = ["## Cockpit クローンレス開発フロー", ""]
@@ -211,7 +213,7 @@ function renderRepositorySection(repositories, dirName = "") {
211
213
  "# 秘密鍵を ~/.ssh/hub_deploy/ に書き出し、ssh コマンドだけ受け取る",
212
214
  )
213
215
  lines.push(
214
- `python3 ~/.claude/scripts/hub_helper.py deploy_creds ${dirName || "<dir_name>"} --env ${envExample}`,
216
+ `python3 ~/.${cliKind === "codex" ? "codex" : "claude"}/scripts/hub_helper.py deploy_creds ${dirName || "<dir_name>"} --env ${envExample}`,
215
217
  )
216
218
  lines.push("```")
217
219
  lines.push("")
@@ -235,7 +237,7 @@ function renderRepositorySection(repositories, dirName = "") {
235
237
  return lines.join("\n")
236
238
  }
237
239
 
238
- function renderWithDirector(dirName, director, repositorySection = "") {
240
+ function renderWithDirector(dirName, director, repositorySection = "", configFilename = "CLAUDE.md") {
239
241
  const fm = director?.frontmatter || {}
240
242
  const client = (fm.client || "").trim()
241
243
  const domain = (fm.domain || "").trim()
@@ -245,9 +247,9 @@ function renderWithDirector(dirName, director, repositorySection = "") {
245
247
  const googleDrive = (fm.google_drive || "").trim()
246
248
 
247
249
  const lines = [
248
- "# CLAUDE.md",
250
+ `# ${configFilename}`,
249
251
  "",
250
- "This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.",
252
+ `This file provides guidance to ${configFilename === "AGENTS.md" ? "Codex" : "Claude Code"} when working with code in this repository.`,
251
253
  "",
252
254
  "## プロジェクト概要",
253
255
  "",
@@ -291,17 +293,17 @@ function renderWithDirector(dirName, director, repositorySection = "") {
291
293
  "開発手順・SSH 接続情報・横断ナレッジへのポインタなど、より詳細な内容は",
292
294
  )
293
295
  lines.push(
294
- "このリポジトリで `claude` を起動して `/init-claude-md` スキルを実行してください。",
296
+ `このリポジトリで ${configFilename === "AGENTS.md" ? "`codex` を起動して `/init-Codex-md`" : "`claude` を起動して `/init-claude-md`"} スキルを実行してください。`,
295
297
  )
296
298
  lines.push("")
297
299
  return lines.join("\n")
298
300
  }
299
301
 
300
- function renderPlaceholder(dirName, repositorySection = "") {
302
+ function renderPlaceholder(dirName, repositorySection = "", configFilename = "CLAUDE.md") {
301
303
  const sections = [
302
- "# CLAUDE.md",
304
+ `# ${configFilename}`,
303
305
  "",
304
- "This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.",
306
+ `This file provides guidance to ${configFilename === "AGENTS.md" ? "Codex" : "Claude Code"} when working with code in this repository.`,
305
307
  "",
306
308
  "## 関連",
307
309
  "",
@@ -315,7 +317,7 @@ function renderPlaceholder(dirName, repositorySection = "") {
315
317
  "## 初期化",
316
318
  "",
317
319
  "これは hub-agent の自動生成プレースホルダです。Hub director も未作成です。",
318
- "詳細を生成するには、このリポジトリで `claude` を起動して `/init-claude-md` スキルを実行してください。",
320
+ `詳細を生成するには、このリポジトリで ${configFilename === "AGENTS.md" ? "`codex` を起動して `/init-Codex-md`" : "`claude` を起動して `/init-claude-md`"} スキルを実行してください。`,
319
321
  "",
320
322
  )
321
323
  return sections.join("\n")
@@ -344,12 +346,16 @@ export async function ensureClaudeMd({
344
346
  accessToken,
345
347
  logger,
346
348
  fetchImpl,
349
+ configFilename = "CLAUDE.md",
347
350
  } = {}) {
348
351
  if (!targetDir) throw new Error("ensureClaudeMd requires targetDir")
349
352
  if (!dirName) throw new Error("ensureClaudeMd requires dirName")
350
- const claudeMdPath = path.join(targetDir, "CLAUDE.md")
353
+ const configMdPath = path.join(
354
+ targetDir,
355
+ configFilename === "AGENTS.md" ? "AGENTS.md" : "CLAUDE.md",
356
+ )
351
357
  try {
352
- await fs.access(claudeMdPath)
358
+ await fs.access(configMdPath)
353
359
  return { written: false, source: "existing" }
354
360
  } catch {
355
361
  /* not found → continue */
@@ -358,7 +364,7 @@ export async function ensureClaudeMd({
358
364
  // accessToken は明示 null (テスト用) で「token を読まない」を選べるようにする。
359
365
  // undefined のときだけ ~/.claude/.hub_token.json にフォールバックする。
360
366
  const token =
361
- accessToken === undefined ? await loadAccessToken() : accessToken || null
367
+ accessToken === undefined ? await loadAccessToken(configFilename) : accessToken || null
362
368
  const url = hubUrl || DEFAULT_HUB_API
363
369
 
364
370
  // Stage 3: Hub に正本があればそれを最優先で書き込み、メタも初期化する
@@ -374,17 +380,17 @@ export async function ensureClaudeMd({
374
380
  const hubBody = hub?.claude_md || ""
375
381
  const hubInitialized = !!hub?.claude_md_updated_at
376
382
  if (hubInitialized && hubBody) {
377
- await fs.writeFile(claudeMdPath, hubBody, "utf-8")
383
+ await fs.writeFile(configMdPath, hubBody, "utf-8")
378
384
  await writeMeta(targetDir, {
379
385
  lastSyncedHubUpdatedAt: hub.claude_md_updated_at,
380
386
  lastSyncedHash: hub.claude_md_hash || sha256(hubBody),
381
387
  lastSyncedAt: nowIso(),
382
- })
388
+ }, configFilename)
383
389
  // 初回 pull 直後は base = Hub 本文。以降の session-end 3-way merge の入力。
384
- await writeBaseBody(targetDir, hubBody)
390
+ await writeBaseBody(targetDir, hubBody, configFilename)
385
391
  logger?.info?.(
386
- { dirName, claudeMdPath },
387
- "wrote CLAUDE.md from Hub project claude_md (initial sync)",
392
+ { dirName, configMdPath },
393
+ "wrote project instruction file from Hub project claude_md (initial sync)",
388
394
  )
389
395
  return { written: true, source: "hub" }
390
396
  }
@@ -399,16 +405,17 @@ export async function ensureClaudeMd({
399
405
  ])
400
406
  : [null, []]
401
407
 
402
- const repositorySection = renderRepositorySection(repositories, dirName)
408
+ const cliKind = configFilename === "AGENTS.md" ? "codex" : "claude"
409
+ const repositorySection = renderRepositorySection(repositories, dirName, cliKind)
403
410
  const body = director
404
- ? renderWithDirector(dirName, director, repositorySection)
405
- : renderPlaceholder(dirName, repositorySection)
411
+ ? renderWithDirector(dirName, director, repositorySection, configFilename)
412
+ : renderPlaceholder(dirName, repositorySection, configFilename)
406
413
  const source = director ? "director" : "placeholder"
407
414
 
408
- await fs.writeFile(claudeMdPath, body, "utf-8")
415
+ await fs.writeFile(configMdPath, body, "utf-8")
409
416
  logger?.info?.(
410
- { dirName, claudeMdPath, source },
411
- "wrote CLAUDE.md for new workspace dir",
417
+ { dirName, configMdPath, source },
418
+ "wrote project instruction file for new workspace dir",
412
419
  )
413
420
  return { written: true, source }
414
421
  }
package/src/main.mjs CHANGED
@@ -1146,6 +1146,11 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1146
1146
  // ready-for-input 判定: claude プロセスの子プロセスを内省し「Stop フック/ツール実行が
1147
1147
  // 終わって真に入力可能になった」エッジを検出する (proc-introspect.mjs)。capture-pane は
1148
1148
  // Stop フック実行中を観測できないため、これが「stop の早期消灯/誤フラッシュ」の根治。
1149
+ // TUI の tmux session_name → Claude session_id。state loop 起動より**前**に作る。
1150
+ // 後で作ると state loop には undefined が渡り、バックグラウンド時だけ cwd 最新 JSONL
1151
+ // へフォールバックして別会話の context% を表示する。
1152
+ ctx.tuiReboundSessions = new Map()
1153
+
1149
1154
  const readinessTracker = new ReadinessTracker()
1150
1155
  // claude.tui.bind ハンドラが「生成中か (isArmed)」を参照して、生成中 claude を rebind の
1151
1156
  // respawn-pane -k で kill する「謎停止」を防ぐ (生成中ガード)。
@@ -1360,11 +1365,6 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1360
1365
  )
1361
1366
  }
1362
1367
 
1363
- // T04784 会話継続バインディングの冪等性ガード: tmux セッション名 → 最後に --resume で
1364
- // 載せ替えた Claude session_id。TUI ビューが remount のたびに claude.tui.bind を送っても、
1365
- // 同じ id への再 respawn (= claude 再起動) を抑止する。
1366
- ctx.tuiReboundSessions = new Map()
1367
-
1368
1368
  // session 回転通知 (T04786 後続) の多重送信ガード: 回転キー (tmux 名 or cwd) →
1369
1369
  // 最後に push した new_session_id。閲覧ハートビートは 5s 間隔で来るため、フロントが
1370
1370
  // 切替を完了し new id でハートビートを送り直すまでの間に来る余分なハートビートで
@@ -2295,6 +2295,9 @@ export async function pushImmediateChatState({
2295
2295
  stalled: false,
2296
2296
  turn_active: false,
2297
2297
  cli_kind: cliKind,
2298
+ // context_pct がどの会話の値かを明示する。frontend は ID が変わったときだけ
2299
+ // 0% を採用し、同一会話の一時的な 0/null は直近の確定値を保持する。
2300
+ context_session_id: sessionId ?? null,
2298
2301
  chat_session_id: sessionId ?? null,
2299
2302
  })
2300
2303
  }
@@ -2610,10 +2613,13 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2610
2613
  // (cwd, cliKind) キー化により「チャット信号の cliKind == 行の cliKind」が恒真に
2611
2614
  // なったので、常に非 null の構造値を送る方が真に厳しい (= 情報量が多い) 上位互換。
2612
2615
  //
2613
- // chat_session_id は「その行のチャットが今どのセッション/スレッドか」。チャット信号が
2614
- // 無ければ null (TUI 専用行)。
2616
+ // context_session_id は「この context_pct がどの会話の値か」。チャットの session/thread を
2617
+ // 優先し、Claude TUI 専用行では bind 済みの session_id(fresh は fresh:<request_id>)を使う。
2618
+ // frontend はこれを境界として、新規会話の 0% と同一会話の一時的な 0/null を区別する。
2615
2619
  const chatCliKind = sessionCliKind
2616
- const chatSessionId = chat?.sessionId ?? null
2620
+ const contextSessionId =
2621
+ chat?.sessionId ??
2622
+ (sessionCliKind === "claude" ? boundSessions?.get(s.session_name) ?? null : codexThreadId)
2617
2623
 
2618
2624
  const prev = lastByName.get(s.session_name)
2619
2625
  if (
@@ -2627,7 +2633,7 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2627
2633
  prev.stalled !== stalled ||
2628
2634
  prev.turn_active !== turnActive ||
2629
2635
  prev.cli_kind !== chatCliKind ||
2630
- prev.chat_session_id !== chatSessionId
2636
+ prev.context_session_id !== contextSessionId
2631
2637
  ) {
2632
2638
  lastByName.set(s.session_name, {
2633
2639
  status,
@@ -2639,7 +2645,9 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2639
2645
  stalled,
2640
2646
  turn_active: turnActive,
2641
2647
  cli_kind: chatCliKind,
2642
- chat_session_id: chatSessionId,
2648
+ context_session_id: contextSessionId,
2649
+ // 旧クライアント互換。新規 consumer は context_session_id を使う。
2650
+ chat_session_id: contextSessionId,
2643
2651
  })
2644
2652
  // 計装 (2026-06-19, 既定 OFF=2026-06-20): 状態変化時に値を記録する。常時 info はログ肥大の
2645
2653
  // 主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す。childBusy/outputActive を分けて出し、
@@ -2658,7 +2666,7 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2658
2666
  context_pct: contextPct,
2659
2667
  turn_active: turnActive,
2660
2668
  cli_kind: chatCliKind,
2661
- chat_session_id: chatSessionId,
2669
+ context_session_id: contextSessionId,
2662
2670
  },
2663
2671
  "session.state push",
2664
2672
  )
@@ -2680,9 +2688,11 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2680
2688
  turn_active: turnActive,
2681
2689
  // cli_kind (T05945): セッション名由来の構造値 ('claude' | 'codex')。frontend の
2682
2690
  // 単一セレクタ (useCockpitSessionDisplay) が「この push はどの CLI 行のものか」を
2683
- // 判定する唯一の軸。chat_session_id はチャット信号が無ければ null。
2691
+ // 判定する唯一の軸。context_session_id は context% の会話 ID。
2684
2692
  cli_kind: chatCliKind,
2685
- chat_session_id: chatSessionId,
2693
+ context_session_id: contextSessionId,
2694
+ // 旧クライアント互換。新規 consumer は context_session_id を使う。
2695
+ chat_session_id: contextSessionId,
2686
2696
  })
2687
2697
  }
2688
2698
  // 消滅セッションの GC (perf監査/メモリリーク対策): tmux から消えたセッションの状態 Map を
@@ -4172,7 +4182,11 @@ async function dispatch(msg, ctx) {
4172
4182
  // 非 fresh (resume/auto) の bind = ユーザーが既存会話を選んだ → 保留中の +新規 defer は破棄。
4173
4183
  if (!fresh) ctx.pendingFreshRespawn.delete(sessionName)
4174
4184
  let rebind = { ok: false, skipped: false }
4175
- const bindKey = fresh ? `fresh:${request_id || ""}` : targetId
4185
+ // 新規会話の 0% は会話切替として扱うため、request_id を送れない旧 client
4186
+ // 経由でも bind ごとに固有IDを作る。空の `fresh:` を再利用すると、2回目以降の
4187
+ // 新規会話が同一会話扱いになり、直前値が残る。
4188
+ const freshBindId = request_id || randomUUID()
4189
+ const bindKey = fresh ? `fresh:${freshBindId}` : targetId
4176
4190
  const recordDeferredFresh = () =>
4177
4191
  ctx.pendingFreshRespawn.set(sessionName, {
4178
4192
  cwd,
@@ -4281,6 +4295,19 @@ async function dispatch(msg, ctx) {
4281
4295
  noCache: true,
4282
4296
  logger,
4283
4297
  })
4298
+ // bind が確定している復帰は、capture-pane の見た目や cwd 最新ファイルでなく
4299
+ // **指定 session_id の JSONL** から context% を即時解決する。fresh はまだ実 ID
4300
+ // が無いため 0% と fresh:<request_id> を対にして送る。
4301
+ const contextSessionId = fresh ? bindKey : targetId
4302
+ const boundContext =
4303
+ targetId && cwd
4304
+ ? await jsonlContextForCwd(cwd, targetId).catch(() => null)
4305
+ : null
4306
+ const contextPct = fresh
4307
+ ? 0
4308
+ : typeof boundContext?.percent === "number"
4309
+ ? boundContext.percent
4310
+ : snap.context_pct
4284
4311
  // 【問題1 根治 2026-06-22】配る status はプロセス実体の busy を権威に解決する
4285
4312
  // (resolveBindSnapshotStatus)。capture(SPINNER_LINE_RE)の偽陽性に勝たせる。
4286
4313
  // - busy=true (ターン進行中): capture gap で snap.status が waiting に化けても
@@ -4296,7 +4323,8 @@ async function dispatch(msg, ctx) {
4296
4323
  type: "session.state",
4297
4324
  session_name: sessionName,
4298
4325
  status: snapStatus,
4299
- context_pct: snap.context_pct,
4326
+ context_pct: contextPct,
4327
+ context_session_id: contextSessionId ?? null,
4300
4328
  permission_mode: snap.permission_mode,
4301
4329
  stable:
4302
4330
  !busy && snapStatus !== "processing" && snap.stable === true,
package/src/tmux.mjs CHANGED
@@ -78,6 +78,18 @@ export function isUnderHubProjectsBase(p) {
78
78
  return true
79
79
  }
80
80
 
81
+ /**
82
+ * Hub workspace またはその worktree から、案件指示ファイルを置く親 workspace を返す。
83
+ * worktree 内の AGENTS.md / CLAUDE.md を別管理すると Hub 正本が分岐するため、
84
+ * `<workspace>/.claude/worktrees/<name>` で起動しても常に `<workspace>` を対象にする。
85
+ */
86
+ export function projectInstructionDir(p) {
87
+ if (!isUnderHubProjectsBase(p)) return path.resolve(p)
88
+ const base = path.resolve(hubProjectsBase())
89
+ const [workspace] = path.relative(base, path.resolve(p)).split(path.sep)
90
+ return path.join(base, workspace)
91
+ }
92
+
81
93
  /**
82
94
  * git branch 名から worktree dir 名 (= tmux session 名) を導出する。
83
95
  * 例: "feature/multi-area" -> "feature-multi-area"
@@ -788,15 +800,18 @@ export async function createSession(name, cwd, opts = {}) {
788
800
  // 何もしない(テナント標準 CLAUDE.md の運用を尊重する)。
789
801
  // どちらも失敗しても session 起動自体は止めない (warn のみ)。
790
802
  if (isUnderHubProjectsBase(resolvedCwd)) {
791
- const dirName = path.basename(resolvedCwd)
803
+ const instructionDir = projectInstructionDir(resolvedCwd)
804
+ const dirName = path.basename(instructionDir)
805
+ const configFilename = opts.cliKind === "codex" ? "AGENTS.md" : "CLAUDE.md"
792
806
  try {
793
807
  await ensureClaudeMd({
794
- targetDir: resolvedCwd,
808
+ targetDir: instructionDir,
795
809
  dirName,
796
810
  hubUrl: opts.hubUrl,
797
811
  accessToken: opts.hubAccessToken,
798
812
  logger: opts.logger,
799
813
  fetchImpl: opts.fetchImpl,
814
+ configFilename,
800
815
  })
801
816
  } catch (err) {
802
817
  opts.logger?.warn?.(
@@ -806,12 +821,13 @@ export async function createSession(name, cwd, opts = {}) {
806
821
  }
807
822
  try {
808
823
  const syncResult = await syncClaudeMdWithHub({
809
- targetDir: resolvedCwd,
824
+ targetDir: instructionDir,
810
825
  dirName,
811
826
  hubUrl: opts.hubUrl,
812
827
  accessToken: opts.hubAccessToken,
813
828
  logger: opts.logger,
814
829
  fetchImpl: opts.fetchImpl,
830
+ configFilename,
815
831
  })
816
832
  opts.logger?.debug?.(
817
833
  { session: name, dirName, sync: syncResult },