@cocorograph/hub-agent 0.6.69 → 0.6.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.6.69",
3
+ "version": "0.6.71",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -28,7 +28,12 @@ const _byCwd = new Map()
28
28
 
29
29
  // チャット信号を「生きている」と見なす最大経過時間 (ms)。これを過ぎたら tmux
30
30
  // スクレイプ結果へフォールバックさせる (チャット終了/別モード移行の検知)。
31
- const CHAT_SIGNAL_STALE_MS = 15 * 60 * 1000
31
+ // 90s: SDK result/abort を取りこぼしても status=processing がこの時間で解除される
32
+ // (RC-4 偽陽性 = 中断/異常終了で三点リーダーが固着する最長時間の短縮。旧 15 分)。正常な
33
+ // 生成中は SDK の assistant イベントが頻繁に updatedAtMs/statusAt を前進させるため途中で
34
+ // 切れない。abort/異常終了は claude-stream-bridge の finally が即 clearChatSignal するのが
35
+ // 一次対策で、本 TTL はその取りこぼし時のバックストップ。
36
+ const CHAT_SIGNAL_STALE_MS = 90 * 1000
32
37
 
33
38
  const VALID_STATUS = new Set(["processing", "waiting", "idle"])
34
39
 
package/src/claude-md.mjs CHANGED
@@ -82,6 +82,18 @@ async function fetchRepositories({ hubUrl, accessToken, dirName, fetchImpl }) {
82
82
  *
83
83
  * リポジトリが 1 件もない場合は空文字列を返し、CLAUDE.md には何も追記しない。
84
84
  */
85
+ /**
86
+ * repo_url が GitHub の `owner/repo` slug 形式かを判定する。
87
+ *
88
+ * `://` や `@` を含むフル URL(GitLab の `git@gitlab.com:...` や
89
+ * `https://gitlab.com/...` 等)は false を返す。これらは `gh` ではなく
90
+ * 素の `git clone` でチェックアウトする。
91
+ */
92
+ function isGitHubSlug(repoUrl) {
93
+ if (/:\/\/|@/.test(repoUrl)) return false
94
+ return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repoUrl)
95
+ }
96
+
85
97
  function renderRepositorySection(repositories, dirName = "") {
86
98
  if (!Array.isArray(repositories) || repositories.length === 0) return ""
87
99
 
@@ -113,16 +125,28 @@ function renderRepositorySection(repositories, dirName = "") {
113
125
  if (productionName) lines.push(`- **本番**: ${productionName}`)
114
126
  lines.push("")
115
127
 
128
+ const githubSlug = isGitHubSlug(repoUrl)
116
129
  lines.push("**作業手順(クローンレス)**:")
117
130
  lines.push("")
118
131
  lines.push("```bash")
119
132
  lines.push("TMPDIR=$(mktemp -d)")
120
- lines.push(`gh repo clone ${repoUrl} "$TMPDIR" -- --depth 1 --filter=blob:none --no-checkout`)
121
- lines.push(`cd "$TMPDIR" && git fetch origin ${branch} && git checkout -b feat/<your-change> origin/${branch}`)
122
- lines.push("# ファイル編集 動作確認")
123
- lines.push('git commit -am "<message>"')
124
- lines.push("git push -u origin HEAD")
125
- lines.push("gh pr create --base " + branch + " --fill")
133
+ if (githubSlug) {
134
+ // GitHub: gh CLI clone・PR 作成
135
+ lines.push(`gh repo clone ${repoUrl} "$TMPDIR" -- --depth 1 --filter=blob:none --no-checkout`)
136
+ lines.push(`cd "$TMPDIR" && git fetch origin ${branch} && git checkout -b feat/<your-change> origin/${branch}`)
137
+ lines.push("# ファイル編集 動作確認")
138
+ lines.push('git commit -am "<message>"')
139
+ lines.push("git push -u origin HEAD")
140
+ lines.push(`gh pr create --base ${branch} --fill`)
141
+ } else {
142
+ // GitLab 等: 素の git clone。MR は push 後に Web/glab で作成
143
+ lines.push(`git clone --depth 1 --filter=blob:none --no-checkout ${repoUrl} "$TMPDIR"`)
144
+ lines.push(`cd "$TMPDIR" && git fetch origin ${branch} && git checkout -b feat/<your-change> origin/${branch}`)
145
+ lines.push("# ファイル編集 → 動作確認")
146
+ lines.push('git commit -am "<message>"')
147
+ lines.push("git push -u origin HEAD")
148
+ lines.push("# MR は GitLab の Web UI(push 時に表示される URL)か `glab mr create` で作成")
149
+ }
126
150
  lines.push("# 作業終了後")
127
151
  lines.push('cd / && rm -rf "$TMPDIR"')
128
152
  lines.push("```")
@@ -165,7 +189,7 @@ function renderRepositorySection(repositories, dirName = "") {
165
189
  "- **本番反映の前に必ず現状バックアップを取る**(DB ダンプ / 対象ディレクトリの退避)。",
166
190
  "- ステージングで動作確認してから本番へ。",
167
191
  "- 破壊的操作(`reset --hard` / `rm -rf` / DB 操作)の前に対象を確認する。",
168
- "- Git 認証はローカルの `gh` CLI / SSH 鍵を使用します。",
192
+ "- Git 認証はローカルの `gh` CLI(GitHub)/ SSH 鍵(GitLab 等)を使用します。",
169
193
  "",
170
194
  )
171
195
  return lines.join("\n")
@@ -206,6 +206,7 @@ class ClaudeStreamSession {
206
206
  onExit,
207
207
  onError,
208
208
  onReap,
209
+ onTurnSettled,
209
210
  }) {
210
211
  this.stream_id = stream_id
211
212
  /** 多端末共有: このセッションを購読中の全端末 stream_id。CHAT_SHARED_ENABLED 時のみ
@@ -236,6 +237,10 @@ class ClaudeStreamSession {
236
237
  this.onError = onError
237
238
  /** ターン完走後に遅延クローズする際、manager にセッション撤去を依頼するコールバック */
238
239
  this.onReap = onReap
240
+ /** RC-4: result を届けずにターンが終わった (abort/異常終了) ときに呼ぶコールバック。
241
+ * manager 経由で main.mjs がチャット信号の status=processing を waiting へ落とす
242
+ * (三点リーダー/ステータスドットが TTL まで固着するのを防ぐ)。 */
243
+ this.onTurnSettled = onTurnSettled
239
244
 
240
245
  /** 次の query() で resume に使う session_id。各ターンの system/init で更新。 */
241
246
  this.sessionId = resumeSessionId || null
@@ -632,6 +637,9 @@ class ClaudeStreamSession {
632
637
  this._busy = true
633
638
  this._abortController = new AbortController()
634
639
  let aborted = false
640
+ // RC-4: このターンで SDK の result イベントが届いたか。届かずに終わった
641
+ // (abort / 異常終了) ときだけ onTurnSettled でチャット信号を waiting へ落とす。
642
+ let resultDelivered = false
635
643
 
636
644
  const options = {
637
645
  cwd: this.cwd,
@@ -677,8 +685,9 @@ class ClaudeStreamSession {
677
685
  this._ensureWatch()
678
686
  }
679
687
  // result イベントでも session_id が来ることがある (念のため拾う)
680
- if (msg?.type === "result" && typeof msg.session_id === "string") {
681
- this.sessionId = msg.session_id
688
+ if (msg?.type === "result") {
689
+ resultDelivered = true
690
+ if (typeof msg.session_id === "string") this.sessionId = msg.session_id
682
691
  }
683
692
  try {
684
693
  this.onEvent?.(msg)
@@ -726,6 +735,18 @@ class ClaudeStreamSession {
726
735
  if (aborted) {
727
736
  this.logger?.info({ stream_id: this.stream_id }, "claude turn aborted")
728
737
  }
738
+ // RC-4: result を届けずにターンが終わった (abort / 異常終了) 場合、チャット信号の
739
+ // status=processing が解除されず三点リーダー/ステータスドットが TTL まで固着する。
740
+ // ターン確定として onTurnSettled を呼び processing→waiting へ落とす。正常完了は
741
+ // result が既に waiting にしているので !resultDelivered のときだけ。_drainPending で
742
+ // 次ターン (processing) が始まる前に呼ぶこと (順序: ここ → reap/drain)。
743
+ if (!resultDelivered) {
744
+ try {
745
+ this.onTurnSettled?.()
746
+ } catch {
747
+ /* ignore */
748
+ }
749
+ }
729
750
  // graceful detach: browser が切れている間にターンが完走したら、ここで遅延
730
751
  // クローズする。manager 側で sessions Map から撤去 + exit を emit する。
731
752
  if (this._reapAfterTurn && !this._closed) {
@@ -1305,6 +1326,15 @@ export class ClaudeStreamBridge extends EventEmitter {
1305
1326
  session_id: session.sessionId,
1306
1327
  })
1307
1328
  },
1329
+ onTurnSettled: () => {
1330
+ // RC-4: result 不在でターンが終わった (abort/異常終了)。main.mjs が cwd 一致の
1331
+ // チャット信号を processing→waiting へ落とす (固着解除)。
1332
+ this.emit("turnsettled", {
1333
+ stream_id: session.stream_id,
1334
+ session_id: session.sessionId,
1335
+ cwd: session.cwd,
1336
+ })
1337
+ },
1308
1338
  })
1309
1339
  this.sessions.set(stream_id, session)
1310
1340
  this.logger?.info(
package/src/main.mjs CHANGED
@@ -489,6 +489,18 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
489
489
  claudeBridge.on("exit", ({ stream_id, code, reason, session_id }) => {
490
490
  client.send({ type: "claude.exit", stream_id, code, reason, session_id })
491
491
  })
492
+ claudeBridge.on("turnsettled", ({ cwd }) => {
493
+ // RC-4: result を届けずにターンが終わった (abort/異常終了)。チャット信号の
494
+ // status=processing を waiting に落として三点リーダー/ステータスドットの固着を防ぐ
495
+ // (正常完了は上の result ハンドラが既に waiting にしている)。
496
+ if (cwd) {
497
+ try {
498
+ recordChatActivity(cwd, { status: "waiting", inputPending: false })
499
+ } catch {
500
+ /* ignore */
501
+ }
502
+ }
503
+ })
492
504
  claudeBridge.on("error", ({ stream_id, session_id, error }) => {
493
505
  client.send({ type: "claude.error", stream_id, session_id, error })
494
506
  })
@@ -875,6 +887,16 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
875
887
  const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
876
888
  let stopped = false
877
889
 
890
+ // RC-8: WS 再接続のたびに差分送信の基準 (lastByName) をクリアし、次 tick で全 session.state を
891
+ // 再 push する。差分送信のままだと、切断中に起きた status 遷移 (processing→stop 等) の push が
892
+ // 失われ、再接続後も frontend が古い processing に固着する (zombie WS の検知が遅れると最大数十秒)。
893
+ // client は (再)接続ごとに "open" を emit する。フロントの tmux.list_sessions poll でも回復するが、
894
+ // これで再接続直後の次 tick (≤intervalMs) に短縮する。初回接続時は空 Map の clear で no-op。
895
+ const onReopen = () => {
896
+ lastByName.clear()
897
+ }
898
+ client.on("open", onReopen)
899
+
878
900
  const tick = async () => {
879
901
  if (stopped) return
880
902
  try {
@@ -963,6 +985,7 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
963
985
  stopped = true
964
986
  clearInterval(ti)
965
987
  clearTimeout(t0)
988
+ client.off?.("open", onReopen)
966
989
  },
967
990
  }
968
991
  }
package/src/state.mjs CHANGED
@@ -120,8 +120,38 @@ function stripAnsi(s) {
120
120
  .replace(/\x1b[()][AB012]/g, "")
121
121
  }
122
122
 
123
+ /**
124
+ * capturePane 結果の末尾フッター領域 (既定 8 行) を返す。claude TUI の作業スピナー /
125
+ * 中断フッターはペイン最下部に固定描画されるため、本文中に同じ語が出ても誤検出しない
126
+ * よう、作業スピナーの補助判定はこの領域に限定する。
127
+ */
128
+ function footerRegion(text, lines = 8) {
129
+ if (!text) return ""
130
+ const rows = text.split("\n")
131
+ return rows.length <= lines ? text : rows.slice(-lines).join("\n")
132
+ }
133
+
134
+ /**
135
+ * 作業スピナーのフッター行を検出する。claude TUI の生成中フッターは
136
+ * "✻ Cogitating… (12s · ↑ 1.2k tokens · esc to interrupt)" のように
137
+ * 「経過秒」と「トークンカウンタ」が同一行に同居する。idle/waiting の権限バナーや
138
+ * 通常本文には出ないシグネチャなので、中断フッター文言 ("esc to interrupt") が
139
+ * 未描画 (ターン序盤) / locale 差 / ツール実行中などで取りこぼされる区間でも
140
+ * 生成中を拾える。誤検出を抑えるためフッター領域 + 同一行同居を必須にする。
141
+ */
142
+ function detectWorkingSpinner(text) {
143
+ const footer = footerRegion(text)
144
+ for (const line of footer.split("\n")) {
145
+ if (/\btokens\b/i.test(line) && /(?:^|[\s(])\d+\s*s\b/.test(line)) return true
146
+ }
147
+ return false
148
+ }
149
+
123
150
  export function detectStatusFromText(text) {
151
+ // 主シグナル: 中断フッター (従来どおり全体一致。回帰防止のため範囲を狭めない)。
124
152
  if (/esc to interrupt/i.test(text)) return "processing"
153
+ // 補助シグナル: 作業スピナーの「経過秒 + トークンカウンタ」(フッター領域・同一行限定)。
154
+ if (detectWorkingSpinner(text)) return "processing"
125
155
  if (/❯\s/.test(text) || /^>\s/m.test(text)) return "waiting"
126
156
  return "idle"
127
157
  }
@@ -276,8 +306,11 @@ export async function capturePane(sessionName, opts = {}) {
276
306
  "-p",
277
307
  "-t",
278
308
  sessionName,
309
+ // 末尾 50 行を取得 (旧 30 行)。copy-mode スクロールや背の高いペインで作業/中断
310
+ // フッターが取得窓外に出て生成中を取りこぼす偽陰性を減らす。status 判定の作業
311
+ // スピナー検出はこのうち末尾フッター領域 (footerRegion) に限定する。
279
312
  "-S",
280
- "-30",
313
+ "-50",
281
314
  "-E",
282
315
  "-",
283
316
  ])