@cocorograph/hub-agent 0.7.48 → 0.7.50

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.48",
3
+ "version": "0.7.50",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -20,12 +20,31 @@
20
20
  * もともと config ディレクトリ単位で分離済み (profiles.mjs の hasFileCredentials を使う)。
21
21
  */
22
22
  import { promises as fs } from "node:fs"
23
+ import os from "node:os"
24
+ import path from "node:path"
23
25
  import { createHash } from "node:crypto"
24
26
  import { execFile } from "node:child_process"
25
27
  import { promisify } from "node:util"
26
28
 
27
29
  const execFileP = promisify(execFile)
28
30
 
31
+ /**
32
+ * 既定 (プライマリ) プロファイルで CLAUDE_CONFIG_DIR が未設定のとき、Claude Code は
33
+ * ハッシュ無しの素のサービス名 `Claude Code-credentials` に OAuth トークンを保存する
34
+ * (ハッシュ付き `-<sha256>` を使うのは CLAUDE_CONFIG_DIR を明示した非既定プロファイルだけ)。
35
+ *
36
+ * ⚠️ 2026-07 実バグ: 既定プロファイル (`~/.claude`) を「ハッシュ付き名だけ」で在否
37
+ * チェックしていたため、ログイン済みで実際に使用中でも常に空振りし、Cockpit の
38
+ * アカウント切替 UI に「要ログイン」が出っぱなしになっていた。既定プロファイルは
39
+ * 素のサービス名も候補に含めて判定する。
40
+ */
41
+ export const PLAIN_CREDENTIAL_SERVICE = "Claude Code-credentials"
42
+
43
+ /** 既定プロファイル (CLAUDE_CONFIG_DIR 未設定時) の config ディレクトリ (`~/.claude`)。 */
44
+ function defaultConfigDir() {
45
+ return path.join(os.homedir(), ".claude")
46
+ }
47
+
29
48
  /** Keychain を使うプラットフォームか (認証分離が Keychain 依存なのは darwin のみ)。 */
30
49
  export function isKeychainPlatform(platform = process.platform) {
31
50
  return platform === "darwin"
@@ -76,6 +95,11 @@ export async function existsGenericPassword(service, opts = {}) {
76
95
  * symlink パスと realpath のどちらでハッシュを計算するかに依存しないよう、両方の
77
96
  * ハッシュ候補を在否チェックし、いずれか存在すればログイン済みとみなす。
78
97
  *
98
+ * さらに既定プロファイル (~/.claude、CLAUDE_CONFIG_DIR 未設定) のときは、Claude Code が
99
+ * ハッシュ無しの素の `Claude Code-credentials` に保存するため、そのサービス名も候補に
100
+ * 含める (PLAIN_CREDENTIAL_SERVICE のコメント参照)。含めないと既定プロファイルが常に
101
+ * 空振りして「要ログイン」が出っぱなしになる。
102
+ *
79
103
  * @param {string} configDir
80
104
  * @param {{execFileP?: Function}} [opts]
81
105
  * @returns {Promise<boolean>}
@@ -87,9 +111,28 @@ export async function profileHasKeychainCredentials(configDir, opts = {}) {
87
111
  } catch {
88
112
  /* realpath 不能 (ディレクトリ未作成等) は無視して literal パスのみ評価 */
89
113
  }
90
- for (const dir of candidates) {
114
+
115
+ // チェックするサービス名を組み立てる。各 configDir 候補のハッシュ付き名に加え、
116
+ // 既定プロファイル (~/.claude、CLAUDE_CONFIG_DIR 未設定) のときだけ素のサービス名を足す。
117
+ const services = new Set()
118
+ for (const dir of candidates) services.add(credentialService(dir))
119
+
120
+ // 既定プロファイル判定: configDir 候補 (literal / realpath) のいずれかが
121
+ // ~/.claude (literal / realpath) に一致するか。iCloud 等への symlink でも拾える。
122
+ const defaultCandidates = new Set([defaultConfigDir()])
123
+ try {
124
+ defaultCandidates.add(await fs.realpath(defaultConfigDir()))
125
+ } catch {
126
+ /* ~/.claude 未作成なら literal のみで判定 */
127
+ }
128
+ const isDefaultProfile = [...candidates].some((dir) =>
129
+ defaultCandidates.has(dir),
130
+ )
131
+ if (isDefaultProfile) services.add(PLAIN_CREDENTIAL_SERVICE)
132
+
133
+ for (const service of services) {
91
134
  try {
92
- if (await existsGenericPassword(credentialService(dir), opts)) return true
135
+ if (await existsGenericPassword(service, opts)) return true
93
136
  } catch {
94
137
  /* security 実行エラーは「不明」→ 存在しない寄せ */
95
138
  }
@@ -209,6 +209,24 @@ function extractPromptText(message) {
209
209
  return ""
210
210
  }
211
211
 
212
+ /**
213
+ * SDK-P0-3 (2026-07-10): SDK の権威的ターン終了シグナル。
214
+ * sdk.d.ts (SDKSessionStateChangedMessage) いわく
215
+ * "'idle' fires after heldBackResult flushes and the bg-agent do-while exits
216
+ * — authoritative turn-over signal."
217
+ * つまり result が流れるターンでは `result` → `idle` の順で届き、result が届かない
218
+ * ターン (held back のまま終わる等) でも idle は届く。frontend は SDK-P0-1 で既に
219
+ * idle をターン終端として扱っているが、agent 側 (resident query) は result しか見て
220
+ * おらず _busy が固着していた。その非対称を解消するための判定。
221
+ */
222
+ function isSessionIdleEvent(msg) {
223
+ return (
224
+ msg?.type === "system" &&
225
+ msg?.subtype === "session_state_changed" &&
226
+ msg?.state === "idle"
227
+ )
228
+ }
229
+
212
230
  /**
213
231
  * 1 stream に対応する Claude セッション (per-message query モデル)。
214
232
  */
@@ -331,6 +349,18 @@ class ClaudeStreamSession {
331
349
  this._permissionResolvers = new Map()
332
350
  /** 現在ターン実行中か (多重 query 防止) */
333
351
  this._busy = false
352
+ /** SDK-P0-3 (2026-07-10): 現ターンが既に終端処理済みか (result / idle / abort の
353
+ * いずれか 1 回だけ _busy=false + drain させるための冪等ガード)。resident query は
354
+ * 1 ターンにつき `result` と `session_state_changed:idle` の**両方**が流れうるため、
355
+ * 素朴に両方で drain すると二重 drain (FIFO 崩れ) を起こす。ターン開始時
356
+ * (_beginResidentTurn) に false へ戻す。 */
357
+ this._turnSettled = false
358
+ /** SDK-P0-3: 現ターンが 1 件でもストリームイベントを出したか。
359
+ * 「前ターンの遅延 idle が、drain で始まったばかりの次ターンを誤終了させる」レースの
360
+ * ガードに使う。generator のイベント順序は保証される (turn N の idle は turn N+1 の
361
+ * 最初のイベントより必ず先に届く) ため、`idle 到着時に現ターンがまだ何も出していない`
362
+ * = その idle は前ターンのもの、と判定できる。ターン開始時に false へ戻す。 */
363
+ this._turnProgressed = false
334
364
  /** detach 済みフラグ (新規ターン受付停止) */
335
365
  this._closed = false
336
366
  /** 実行中ターンの完走を待ってからクローズする予約フラグ (graceful detach 用)。
@@ -721,7 +751,7 @@ class ClaudeStreamSession {
721
751
  this._emitQueueState()
722
752
  return { accepted: true }
723
753
  }
724
- this._busy = true
754
+ this._beginResidentTurn()
725
755
  // 改修4 (A): 死亡ガード。常駐 query が未起動 or (エラー等で) 終了済み (_residentQuery=null)
726
756
  // なら (再)起動する。_runResidentQuery は起動時 options.resume=this.sessionId で文脈を
727
757
  // 復元するため、途中死からの復活でも過去コンテキストは失われない。
@@ -1067,6 +1097,40 @@ class ClaudeStreamSession {
1067
1097
  }
1068
1098
  }
1069
1099
 
1100
+ /** SDK-P0-3 (2026-07-10): resident の「ターン開始」を 1 箇所に集約する。
1101
+ * _busy を立てるのと同時に、そのターンの終端フラグ (_turnSettled) と進行フラグ
1102
+ * (_turnProgressed) を必ずリセットする。ターン開始は 3 箇所ある
1103
+ * (sendMessage の直接 push / _drainResidentPending / _runResidentQuery finally の
1104
+ * 異常再起動 push) ので、リセット漏れを構造的に防ぐためのヘルパー。 */
1105
+ _beginResidentTurn() {
1106
+ this._busy = true
1107
+ this._turnSettled = false
1108
+ this._turnProgressed = false
1109
+ }
1110
+
1111
+ /** SDK-P0-3 (2026-07-10): resident の「ターン終端」を 1 箇所に集約する (result / idle 共通)。
1112
+ * 同一ターンで 2 回以上呼ばれても最初の 1 回しか作用しない (_turnSettled ガード)。
1113
+ * これにより `result` → `idle` の順で両方届く通常ケースでも drain は 1 回だけになる。
1114
+ * @param {string} reason "result" | "idle" — ログ用
1115
+ * @param {(msg: string) => void} [denyPending] 未解決 permission を閉じるクロージャ
1116
+ */
1117
+ _settleResidentTurn(reason, denyPending) {
1118
+ if (this._turnSettled) return
1119
+ this._turnSettled = true
1120
+ denyPending?.("turn ended")
1121
+ this._busy = false
1122
+ // 改修4 (A): シリアライズした pending があれば次の 1 件を InputQueue へ流す。
1123
+ // ultracode (0.6.28): _drainResidentPending は applyFlagSettings を await するため
1124
+ // async。for await ループ内からは fire-and-forget で呼ぶ (最初の await より前に
1125
+ // _beginResidentTurn が同期実行されるので、以降の sendMessage は busy を正しく観測する)。
1126
+ this._drainResidentPending().catch((err) =>
1127
+ this.logger?.warn(
1128
+ { stream_id: this.stream_id, err: err?.message, reason },
1129
+ "drainResidentPending threw",
1130
+ ),
1131
+ )
1132
+ }
1133
+
1070
1134
  /** 改修4 (A): ターン完了時に pending の先頭 1 件を InputQueue へ流す (ターンのシリアライズ)。
1071
1135
  * queue_state を更新して送信待ちチップを drain させる (frontend がバブル昇格する)。
1072
1136
  * ultracode (0.6.28): 次ターンの目標 ultracode 状態へ reconcile してから push する
@@ -1082,7 +1146,7 @@ class ClaudeStreamSession {
1082
1146
  // _busy=true のまま残り、以降の drain が全て弾かれて pending が永久に積み上がる
1083
1147
  // デッドロックになる。失敗時は取り出したメッセージを先頭へ戻し _busy を解除して、
1084
1148
  // 次の drain がやり直せる状態に必ず回復する (例外は既存方針通り warn で握る)。
1085
- this._busy = true
1149
+ this._beginResidentTurn()
1086
1150
  try {
1087
1151
  // ultracode reconcile (0.6.28) も await で例外を投げうるので try の中に含める。
1088
1152
  await this._reconcileResidentUltracode(next.ultracode === true)
@@ -1100,8 +1164,10 @@ class ClaudeStreamSession {
1100
1164
 
1101
1165
  /** 改修2+4: 常駐 query を 1 回だけ起動し、streaming input キューから複数ターンを処理する。
1102
1166
  * system/init は最初の 1 回のみ (毎ターン init = 文脈/キャッシュ温め直しを解消)。各ターンは
1103
- * result で _busy=false にするが query は継続し次 input を待つ。close (inputQueue.close) で
1104
- * generator が終わり query が完走する。
1167
+ * 終端シグナル (result / session_state_changed:idle) で _busy=false にするが query は継続し
1168
+ * 次 input を待つ。close (inputQueue.close) で generator が終わり query が完走する。
1169
+ * SDK-P0-3 (2026-07-10): 終端を result だけに頼ると、SDK が idle でターンを終えたときに
1170
+ * _busy が固着し、以降の送信が pending へ永久滞留する (T06052)。
1105
1171
  * 改修4: resume セッションも対象。query 起動時 options.resume に過去 session_id を渡して
1106
1172
  * 文脈を引き継ぐ (init は初回 1 回のみ)。continue は使わない (自動継続の暴走回避)。初回 input は
1107
1173
  * sendMessage が積むユーザーの実メッセージなので「Continue from where you left off」は発生しない。
@@ -1153,17 +1219,34 @@ class ClaudeStreamSession {
1153
1219
  if (msg?.type === "result") {
1154
1220
  if (typeof msg.session_id === "string") this.sessionId = msg.session_id
1155
1221
  // ターン完了: 未解決 permission を閉じ、次 input 受付へ (query は継続)。
1156
- denyPending("turn ended")
1157
- this._busy = false
1158
- // 改修4 (A): シリアライズした pending があれば次の 1 件を InputQueue へ流す。
1159
- // ultracode (0.6.28): _drainResidentPending は applyFlagSettings を await するため
1160
- // async。result ハンドラ (for await ループ内) からは fire-and-forget で呼ぶ。
1161
- this._drainResidentPending().catch((err) =>
1162
- this.logger?.warn(
1163
- { stream_id: this.stream_id, err: err?.message },
1164
- "drainResidentPending threw",
1165
- ),
1166
- )
1222
+ this._settleResidentTurn("result", denyPending)
1223
+ } else if (isSessionIdleEvent(msg)) {
1224
+ // SDK-P0-3 (2026-07-10): result が届かずに idle でターンが終わるケースの救済。
1225
+ // これを見ていなかったため _busy が true で固着し、以降の送信が
1226
+ // _pendingMessages に退避されたまま (drain 契機の result が永久に来ないので)
1227
+ // 永久滞留していた ("resident busy, message queued" が出続ける / リロードで消える)。
1228
+ //
1229
+ // ガード: 「前ターンの遅延 idle」で新ターンを誤終了させない。result で settle →
1230
+ // drain → 次ターン開始 (_busy=true) の直後に、同じターンの idle が続けて届く
1231
+ // (SDK は result → idle の順に流す) ため、素朴に _busy=false にすると新ターンを
1232
+ // 撃ち抜き二重 drain / FIFO 崩れを起こす。generator のイベント順序は保証されるので
1233
+ // 「現ターンがまだ 1 件もイベントを出していない (_turnProgressed=false)」なら
1234
+ // その idle は前ターンのものと確定できる。
1235
+ if (this._busy && this._turnProgressed) {
1236
+ this._settleResidentTurn("idle", denyPending)
1237
+ // per-message の RC-4 と対称に、チャット信号 (サイドバードット) を
1238
+ // processing→waiting へ落とす。result 経由なら main.mjs が result を見て
1239
+ // waiting にするが、idle 単独ではその契機が無く 90s TTL まで固着するため。
1240
+ try {
1241
+ this.onTurnSettled?.()
1242
+ } catch {
1243
+ /* ignore */
1244
+ }
1245
+ }
1246
+ } else {
1247
+ // 現ターンが「進行した」証跡。init / assistant / stream_event / user、および
1248
+ // session_state_changed の running / requires_action もここに含む。
1249
+ this._turnProgressed = true
1167
1250
  }
1168
1251
  try {
1169
1252
  this._emit(msg)
@@ -1207,7 +1290,7 @@ class ClaudeStreamSession {
1207
1290
  // close() するだけで参照を持ち越さないので、ここで差し替えても整合する。
1208
1291
  this._inputQueue = new InputQueue()
1209
1292
  const next = this._pendingMessages.shift()
1210
- this._busy = true
1293
+ this._beginResidentTurn()
1211
1294
  this._residentStarted = true
1212
1295
  // ultracode (0.6.29): query を先に起動して _residentQuery を確定させてから
1213
1296
  // reconcile → push する (sendMessage の通常経路と同順)。push してから起動すると
@@ -1237,6 +1320,11 @@ class ClaudeStreamSession {
1237
1320
  } catch {
1238
1321
  /* ignore */
1239
1322
  }
1323
+ // SDK-P0-3 (2026-07-10): 中断も「このターンの終端」なので settled を立てる。
1324
+ // interrupt() 後に SDK が session_state_changed:idle を流しても、_turnSettled=true
1325
+ // (pending 無しで drain が早期 return するケース) か _turnProgressed=false
1326
+ // (drain が次ターンを開始したケース) のどちらかで必ず無視される。
1327
+ this._turnSettled = true
1240
1328
  this._busy = false
1241
1329
  // SDK-P0-2 (2026-07-07): resident 経路のターン終端イベント非対称の解消。
1242
1330
  // per-message の abort (_runPerMessage finally) は合成 result (aborted_by_user) を
@@ -1248,8 +1336,9 @@ class ClaudeStreamSession {
1248
1336
  // - 実行中ターンがあった (wasBusy) ときだけ emit する (未走行での空 abort に
1249
1337
  // 「中断しました」フッターを出さない)。
1250
1338
  // - close()/reap 経由の abort (_closed=true) では browser は既に離脱しているため抑止する。
1251
- // - drain タイミングは変更しない (既存の同期 drain を維持し、result+idle 二重 drain や
1252
- // 中断ターンの遅延 idle が新ターンを誤終了させる新種レースを持ち込まない)。
1339
+ // - drain タイミングは変更しない (既存の同期 drain を維持する)。result+idle の二重 drain と
1340
+ // 中断ターンの遅延 idle による新ターン誤終了は、SDK-P0-3 の _turnSettled /
1341
+ // _turnProgressed ガードで潰してある (上の _turnSettled=true もその一部)。
1253
1342
  // - 合成 result の uuid は重複排除 (isDuplicateEvent の result 署名判定) 回避のため必須。
1254
1343
  if (wasBusy && !this._closed) {
1255
1344
  try {