@cocorograph/hub-agent 0.8.83 → 0.8.84

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/src/tmux.mjs CHANGED
@@ -1,36 +1,16 @@
1
- /**
2
- * tmux サブプロセス呼び出し抽象 (Sprint E)。
3
- *
4
- * - `listSessions()`: 構造化された TmuxSession[] を返す。各 session に
5
- * status (state.mjs の detectSessionState) と cwd (active pane の current_path)
6
- * を並列で付与
7
- * - `createSession(name, cwd, opts)`: detached new-session。既定では pane に CLI
8
- * (claude / codex) を起動せず素のシェルのまま置く。`HUB_AGENT_TMUX_LAUNCH_CLI=1`
9
- * のときだけ従来どおり CLAUDE_CMD / CODEX_CMD を send-keys する。既存ならエラー throw
10
- * - `killSession(name)`: 1 session を kill。存在しない場合は 'session not found' throw
11
- * - `killManySessions(names)`: 複数 kill、存在しないものは無視
12
- *
13
- * 移植元: D00000_cockpit/webapp/lib/tmux.ts
14
- * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
15
- */
1
+ /** Worktree の作成・列挙・削除とチャット行の名前正規化。 */
16
2
  import { execFile } from "node:child_process"
17
3
  import fs from "node:fs/promises"
18
4
  import os from "node:os"
19
5
  import path from "node:path"
20
6
  import { promisify } from "node:util"
21
7
 
22
- import { expandTilde, hubProjectsBase, prepareChatWorkspace } from "./chat-workspace.mjs"
8
+ import { expandTilde, hubProjectsBase } from "./chat-workspace.mjs"
23
9
  export { expandTilde, isUnderHubProjectsBase, projectInstructionDir } from "./chat-workspace.mjs"
24
- import { detectSessionState, getSessionCwd } from "./state.mjs"
25
- import { shouldLaunchPaneCli } from "./pane-cli-flag.mjs"
26
- import { baseSessionName, cliKindForSessionName, sessionNameFor } from "./session-names.mjs"
27
- import { boundSessionId, getSessionUsages, isFreshUnboundBind, jsonlContextForCwd } from "./usage.mjs"
28
- import { quotePosixShellArg, requireCodexBinary } from "./codex-binary.mjs"
10
+ import { baseSessionName } from "./session-names.mjs"
29
11
 
30
12
  const execFileP = promisify(execFile)
31
13
 
32
- const DEFAULT_TMUX_BIN = "tmux"
33
-
34
14
  export function sanitizeTmuxName(s) {
35
15
  return s.replace(/[.:]/g, "-")
36
16
  }
@@ -260,8 +240,8 @@ async function inspectWorktreeDirty(wtPath) {
260
240
  }
261
241
 
262
242
  /**
263
- * `git worktree remove --force <wtPath>` を実行する。事前に live tmux session が
264
- * あれば kill する。cockpit `worktree.remove` ハンドラから呼ばれる。
243
+ * `git worktree remove --force <wtPath>` を実行する。事前に会話へ停止要求を出す。
244
+ * cockpit `worktree.remove` ハンドラから呼ばれる。
265
245
  *
266
246
  * - name から path を解決するには buildWorktreeIndex で fs を走査する (path 検証も兼ねる)
267
247
  * - HUB_PROJECTS_BASE 配下に居ない path は拒否 (path traversal 防止)
@@ -271,7 +251,7 @@ async function inspectWorktreeDirty(wtPath) {
271
251
  * - 未コミットの変更等で git remove 自体が失敗した場合はそのエラーを上位に投げる
272
252
  *
273
253
  * @param {string} name
274
- * @param {{ force?: boolean, tmuxBin?: string, stopSessions?: (wtPath: string) => Promise<number> }} [opts]
254
+ * @param {{ force?: boolean, stopSessions?: (wtPath: string) => Promise<number> }} [opts]
275
255
  * `stopSessions` は **dirty 判定を通過したあと・dir を消す前**に 1 回だけ呼ばれる
276
256
  * (**停止 API を呼んだ**本数を返す。止まったこと・終了したことの確認は行わない——本文の
277
257
  * コメント参照)。投げた場合は削除へ進まない。
@@ -308,7 +288,7 @@ export async function removeWorktree(name, opts = {}) {
308
288
 
309
289
  // ⚠️ dir を消す前に、この worktree を cwd とする **会話**へ停止要求を出す (ユーザー決定
310
290
  // 「会話を止めてから削除」)。会話の実体が tmux から離れたので、止める対象もセッションでは
311
- // なく会話になる (下の tmux kill が同じ理由で存在する)。stopSessions が投げたらここで
291
+ // なく会話になる。stopSessions が投げたらここで
312
292
  // 中断し、dir は消さない。
313
293
  //
314
294
  // ⚠️ ここが保証するのは「**対象の停止 API を呼んだこと**」だけで、「止まったこと」でも
@@ -327,31 +307,6 @@ export async function removeWorktree(name, opts = {}) {
327
307
  const stopRequestedSessions =
328
308
  typeof opts.stopSessions === "function" ? (await opts.stopSessions(resolved)) || 0 : 0
329
309
 
330
- // 走行中の session があれば kill。T05945: 同一 worktree に claude (`<wt>`) と codex
331
- // (`<wt>__codex`) の 2 セッションが並びうるため、両方を kill してから dir を消す
332
- // (片方が残ったまま dir を消すと、生存セッションの cwd が消滅した状態になる)。
333
- for (const target of [
334
- sessionNameFor(sanitized, "claude"),
335
- sessionNameFor(sanitized, "codex"),
336
- ]) {
337
- try {
338
- // `-t =<target>` で完全一致を強制 (T06593)。claude 名 `<wt>` は codex 名
339
- // `<wt>__codex` の前方一致になるため、素の `-t` だと claude 不在時に
340
- // claude kill が codex を先に巻き込んでしまう (ここでは両方 kill するので
341
- // 結果は同じだが、意図した順序で確実に対象を kill するため exact に揃える)。
342
- await execFileP(tmuxBin(opts), ["kill-session", "-t", `=${target}`])
343
- } catch (err) {
344
- const msg = err?.message || String(err)
345
- // session 不在は正常 (停止済みの worktree を消すパス / 片方の CLI しか起動していない)
346
- if (
347
- !msg.includes("can't find session") &&
348
- !msg.includes("no current session") &&
349
- !msg.includes("no server running")
350
- ) {
351
- throw err
352
- }
353
- }
354
- }
355
310
  // 親 repo を解決し `git -C <parent> worktree remove --force <wtPath>` を実行
356
311
  const wtSegment = path.join(".claude", "worktrees", path.basename(resolved))
357
312
  const parentRepo = resolved.endsWith(wtSegment)
@@ -449,674 +404,3 @@ export async function listWorktreeNameHistory(workspaceDir) {
449
404
  out.sort((a, b) => b.last_used - a.last_used)
450
405
  return out
451
406
  }
452
-
453
- /**
454
- * Claude CLI コマンドを組み立てる。
455
- *
456
- * 優先順:
457
- * 1. Hub から配信された agent 設定 (opts.model / opts.permissionMode)
458
- * - cockpit 管理画面で設定 → enrollment / heartbeat で配信
459
- * 2. 環境変数 `HUB_CLAUDE_CMD`
460
- * - shell startup file 等で各人が自由にカスタマイズ
461
- * 3. 最小デフォルト `claude --continue || claude`
462
- * - Claude Code 標準デフォルト (model / mode 共に Claude 側に委ねる)
463
- *
464
- * `--model` / `--permission-mode` を hard-code しない理由:
465
- * - model は個人の好み (Opus / Sonnet / Haiku) で分かれる
466
- * - `--permission-mode auto` は Team プラン限定機能で、個人プランでは
467
- * "Allowed choices are acceptEdits, ..." と CLI が拒否する事故 (2026-05-20 葛西)
468
- */
469
- export function buildClaudeCmd(opts = {}) {
470
- const fromHub = composeClaudeCmdFromOptions(opts)
471
- if (fromHub) return fromHub
472
- if (process.env.HUB_CLAUDE_CMD) return process.env.HUB_CLAUDE_CMD
473
- return "claude --continue || claude"
474
- }
475
-
476
- /**
477
- * agent 設定 (model / permissionMode) から claude コマンド文字列を組み立てる。
478
- * いずれも null / 空文字なら未指定とみなし、両方未指定なら null を返す
479
- * (上位で環境変数 / デフォルトにフォールバック)。
480
- */
481
- export function composeClaudeCmdFromOptions(opts = {}) {
482
- const model = (opts.model || "").trim()
483
- const mode = (opts.permissionMode || "").trim()
484
- if (!model && !mode) return null
485
- const flags = []
486
- if (model) flags.push(`--model ${model}`)
487
- if (mode) flags.push(`--permission-mode ${mode}`)
488
- const tail = flags.join(" ")
489
- return `claude --continue ${tail} || claude ${tail}`.replace(/\s+/g, " ").trim()
490
- }
491
-
492
- const DEFAULT_CLAUDE_CMD = buildClaudeCmd()
493
-
494
- // 判定の実体は ./pane-cli-flag.mjs (state.mjs との循環 import を避けるため分離)。
495
- // ここからの re-export は後方互換 (既存の `from "./tmux.mjs"` を壊さない)。
496
- export { shouldLaunchPaneCli }
497
-
498
- function tmuxBin(opts = {}) {
499
- return opts.tmuxBin || DEFAULT_TMUX_BIN
500
- }
501
-
502
- /**
503
- * pane CLI に渡す initialPrompt の危険な制御文字を拒否する。
504
- * 対話入力として必要な tab (HT) / LF / CR は許可する。
505
- */
506
- export function validateInitialPrompt(initialPrompt) {
507
- if (
508
- typeof initialPrompt === "string" &&
509
- /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(initialPrompt)
510
- ) {
511
- throw new Error("initialPrompt contains unsupported control characters")
512
- }
513
- }
514
-
515
- /**
516
- * claudeCmd の各セグメント末尾に initialPrompt を shell-safe quoting で
517
- * 末尾引数として注入する。
518
- *
519
- * `cmd1 || cmd2` のように `||` で連結された OR 構造の両側に同じ prompt を
520
- * 埋め込むため、`||` で split して各セグメント末尾に追加する。
521
- * 旧実装は `--permission-mode auto` を marker として正規表現マッチしていたが、
522
- * model / permission-mode が個人ごとに変わる前提で marker 非依存に変更した。
523
- *
524
- * POSIX shell の単一 argv として quote し、`$()` / backtick 等を展開させない。
525
- */
526
- export function injectInitialPrompt(claudeCmd, initialPrompt) {
527
- if (!initialPrompt || typeof initialPrompt !== "string") return claudeCmd
528
- validateInitialPrompt(initialPrompt)
529
- if (typeof claudeCmd !== "string" || claudeCmd.length === 0) return claudeCmd
530
- const quoted = quotePosixShellArg(initialPrompt)
531
- return claudeCmd
532
- .split("||")
533
- .map((part) => `${part.trim()} ${quoted}`)
534
- .join(" || ")
535
- }
536
-
537
- /**
538
- * claudeCmd の各セグメント先頭に `CLAUDE_CONFIG_DIR=<dir>` の環境変数代入を注入する。
539
- *
540
- * WHY: tmux pane のシェルは tmux サーバーの環境を引き継ぐため、hub-agent が
541
- * process.env.CLAUDE_CONFIG_DIR を切り替えても send-keys で起動する claude には
542
- * 伝わらない (SDK 経由のチャットセッションにしか効かない)。アカウント・
543
- * プロファイル切替を TUI セッションにも効かせるため、コマンド文字列自体に
544
- * 代入プレフィックスを焼き込む。`a || b` の OR 構造では代入が最初のコマンドに
545
- * しか効かないため、injectInitialPrompt と同様に `||` で split して両側に付ける。
546
- *
547
- * configDir が空 (= 既定プロファイル) のときは何もしない。
548
- *
549
- * ⚠️ この関数は **CLI を pane 起動するときだけ** 使われる
550
- * (`HUB_AGENT_TMUX_LAUNCH_CLI=1`)。素のシェルの pane へは setTmuxGlobalEnv の
551
- * `set-environment -g` 経由で CLAUDE_CONFIG_DIR が渡る。
552
- */
553
- export function injectConfigDirEnv(claudeCmd, configDir) {
554
- if (!configDir || typeof configDir !== "string") return claudeCmd
555
- if (typeof claudeCmd !== "string" || claudeCmd.length === 0) return claudeCmd
556
- const assignment = `CLAUDE_CONFIG_DIR=${quotePosixShellArg(configDir)}`
557
- return claudeCmd
558
- .split("||")
559
- .map((part) => `${assignment} ${part.trim()}`)
560
- .join(" || ")
561
- }
562
-
563
- /**
564
- * tmux サーバーのグローバル環境変数を設定/解除する。
565
- * pane は既定で素のシェルなので、ユーザーが pane 内で手動起動する claude / codex に
566
- * CLAUDE_CONFIG_DIR (CODEX_HOME) を届ける **主経路** がこれ。
567
- * (CLI を pane 起動する `HUB_AGENT_TMUX_LAUNCH_CLI=1` のときだけ、
568
- * injectConfigDirEnv のコマンド焼き込みが併用される)。
569
- * value が null/undefined なら unset (-gu)。tmux サーバー未起動などの失敗は握り潰す。
570
- */
571
- export async function setTmuxGlobalEnv(name, value, opts = {}) {
572
- const args =
573
- value == null
574
- ? ["set-environment", "-gu", name]
575
- : ["set-environment", "-g", name, value]
576
- try {
577
- await execFileP(tmuxBin(opts), args)
578
- return true
579
- } catch (err) {
580
- opts.logger?.debug?.(
581
- { name, err: err?.message || String(err) },
582
- "tmux set-environment failed (server not running?)",
583
- )
584
- return false
585
- }
586
- }
587
-
588
- // P1 (重複排除): cwd 取得は state.mjs の getSessionCwd (キャッシュ付き) を共用する。
589
- // 旧 tmux.mjs 私有実装は display-message を毎回 spawn しており、state loop の
590
- // listSessionStates と list_sessions が別キャッシュ無しで二重 fork していた。
591
-
592
- const TMUX_LIST_FIELDS = [
593
- "#{session_name}",
594
- "#{session_windows}",
595
- "#{session_created}",
596
- "#{session_attached}",
597
- "#{session_activity}",
598
- "#{session_last_attached}",
599
- ].join("\t")
600
-
601
- export async function listSessions(opts = {}) {
602
- let stdout = ""
603
- try {
604
- const r = await execFileP(tmuxBin(opts), [
605
- "list-sessions",
606
- "-F",
607
- TMUX_LIST_FIELDS,
608
- ])
609
- stdout = r.stdout
610
- } catch (err) {
611
- const msg = err?.message || String(err)
612
- if (msg.includes("no server running") || msg.includes("no current session")) {
613
- return []
614
- }
615
- throw err
616
- }
617
-
618
- const base = stdout
619
- .split("\n")
620
- .filter((line) => line.trim().length > 0)
621
- .map((line) => {
622
- const [name, windows, created, attached, activity, lastAttached] = line.split("\t")
623
- return {
624
- name,
625
- windows: Number(windows),
626
- created: Number(created),
627
- attached: attached !== "0",
628
- activity: Number(activity) || 0,
629
- lastAttached: Number(lastAttached) || 0,
630
- }
631
- })
632
-
633
- // state + cwd + worktree 親判定 + statusLine 由来 context% を並列付与
634
- // statusLine の `used_percentage` を context_pct (USED %) として使う。
635
- // pane 解析 (state.detectSessionState) はフォーマット変動に脆いので、
636
- // statusLine cache がある cwd では優先採用する。
637
- const [wtParentMap, sessionUsages] = await Promise.all([
638
- opts.worktreeIndex instanceof Map
639
- ? Promise.resolve(
640
- new Map(
641
- [...opts.worktreeIndex].map(([name, info]) => [
642
- name,
643
- info.parent_session_name,
644
- ]),
645
- ),
646
- )
647
- : buildWorktreeParentMap(),
648
- getSessionUsages().catch(() => []),
649
- ])
650
- // cwd → context_pct の lookup (同一 cwd で複数 session_id ある場合は最新 mtime 優先)
651
- const ctxByCwd = new Map()
652
- for (const u of sessionUsages) {
653
- const prev = ctxByCwd.get(u.cwd)
654
- if (!prev || u.updatedAtMs > prev.updatedAtMs) {
655
- ctxByCwd.set(u.cwd, { contextPercent: u.contextPercent, updatedAtMs: u.updatedAtMs })
656
- }
657
- }
658
-
659
- return Promise.all(
660
- base.map(async (s) => {
661
- const [state, cwd] = await Promise.all([
662
- detectSessionState(s.name, opts),
663
- getSessionCwd(s.name, opts),
664
- ])
665
- // context% (USED %) は以下の順で採用する:
666
- // 1. jsonl per-cwd で「`/clear` 直後 (uuid 変化) かつ新 jsonl に応答未着」
667
- // と確定したら 0% を即採用 (= リセット最速反映)。statusLine cache は
668
- // 前セッションの値を抱えたまま居座り、次のプロンプトまで更新されない。
669
- // 2. それ以外は statusLine cache (USED %) を最優先 (claude 公式値)。
670
- // 3. statusLine が無ければ jsonl per-cwd の末尾 assistant.usage 由来 %。
671
- // 4. それも無ければ pane scrape の正規表現フォールバック。
672
- const fromStatusLine = cwd ? ctxByCwd.get(cwd)?.contextPercent : null
673
- // bound session があればその jsonl で context% を引く (cwd dir の別セッション jsonl の取り違え回避)。
674
- // TUI bind (opts.boundSessions, tmux セッション名キー) を優先し、無ければ SDK チャット
675
- // (claudeBridge, cwd キー) のライブセッションへフォールバックする。チャットモードには
676
- // tmux 上の bind 台帳が無いため、これが無いと cold-start (session.state snapshot 未取得)
677
- // の poll 応答で cwd の別アクティビティ jsonl を「最新」と取り違える cross-activity 汚染が
678
- // 起き得る (2026-07 発覚。TUI 版は本行の boundSessions 解決で既に根治済みだった)。
679
- // Codex 行には Claude の bind / live ID 由来の使用率を載せない。
680
- // 代表会話の値は送信側の readRowChatState に委ねる。
681
- const allowClaudeUsage = cliKindForSessionName(s.name) !== "codex"
682
- const boundValue = allowClaudeUsage ? opts.boundSessions?.get(s.name) : null
683
- const freshUnbound = isFreshUnboundBind(boundValue)
684
- const boundId = allowClaudeUsage ? (
685
- boundSessionId(opts.boundSessions, s.name) ||
686
- (cwd && opts.claudeBridge?.liveSessionIdForCwd?.(cwd)) ||
687
- null
688
- ) : null
689
- // cwd だけでは同じリポジトリで並走する別 Claude / subagent / 過去会話を区別できない。
690
- // bound ID が無い状態で statusLine/jsonl の「cwd 最新値」を使うと、再起動後や未オープンの
691
- // tmux セッションへ別会話の 98〜100% が混入する。実際に値の帰属先を示せる bound session
692
- // がある場合だけ per-session cache を採用し、無ければ capture-pane の当該行由来値 (または
693
- // null) に留める。null は「不明」であり、他会話の数値を表示するより正しい。
694
- const hasBoundSession = typeof boundId === "string" && boundId.length > 0
695
- const jsonlInfo = hasBoundSession && cwd ? await jsonlContextForCwd(cwd, boundId) : null
696
- const context_pct =
697
- freshUnbound
698
- ? 0
699
- : jsonlInfo?.isReset && typeof jsonlInfo.percent === "number"
700
- ? jsonlInfo.percent
701
- : hasBoundSession && typeof fromStatusLine === "number"
702
- ? fromStatusLine
703
- : typeof jsonlInfo?.percent === "number"
704
- ? jsonlInfo.percent
705
- : state.context_pct
706
- return {
707
- ...s,
708
- status: state.status,
709
- context_pct,
710
- cwd,
711
- // T05945: worktree の親解決はベース名で行う (`<wt>__codex` も `<wt>` と同じ親配下)。
712
- // これをしないと codex の worktree セッションが親を失い「その他 (workspace 未登録)」へ
713
- // 落ちる。
714
- parent_session_name: wtParentMap.get(baseSessionName(s.name)) || null,
715
- }
716
- }),
717
- )
718
- }
719
-
720
- export async function killSession(name, opts = {}) {
721
- try {
722
- // `-t =<name>` で完全一致を強制 (T06593)。素の `-t <name>` は前方一致するため、
723
- // claude セッション `<dir>` を kill しようとして claude が不在だと codex
724
- // `<dir>__codex` に誤ヒットして codex を巻き込み kill してしまう。
725
- await execFileP(tmuxBin(opts), ["kill-session", "-t", `=${name}`])
726
- } catch (err) {
727
- const msg = err?.message || String(err)
728
- if (
729
- msg.includes("can't find session") ||
730
- msg.includes("no current session") ||
731
- msg.includes("no server running")
732
- ) {
733
- throw new Error("session not found")
734
- }
735
- throw err
736
- }
737
- }
738
-
739
- export async function killManySessions(names, opts = {}) {
740
- const killed = []
741
- const failed = []
742
- for (const n of names) {
743
- try {
744
- await killSession(n, opts)
745
- killed.push(n)
746
- } catch (err) {
747
- const msg = err?.message || String(err)
748
- if (msg === "session not found") continue
749
- failed.push({ name: n, reason: msg })
750
- }
751
- }
752
- return { killed, failed }
753
- }
754
-
755
- /**
756
- * 新規 session を detached で作成する。
757
- *
758
- * ⚠️ **既定では pane に CLI (claude / codex) を起動しない**(pane は素のシェルのまま)。
759
- * Cockpit の通常運用はチャットモードで、pane の TUI は誰も見ないまま常駐して
760
- * メモリを食うだけだったため (詳細は `pane-cli-flag.mjs` の shouldLaunchPaneCli の JSDoc)。
761
- * `HUB_AGENT_TMUX_LAUNCH_CLI=1` のときだけ従来どおり CLI を send-keys する。
762
- *
763
- * - 同名 session が既にあれば 'duplicate session' throw
764
- * - CLAUDE.md / AGENTS.md の生成・Hub 同期、cwd 自動作成、mouse mode 設定は
765
- * **CLI を起動するかどうかに関わらず**常に行う(素のシェルでも必要なため)
766
- * - 以下は CLI を起動するときだけ意味を持つので、起動時のみ評価される:
767
- * opts.claudeCmd / opts.codexCmd / opts.cliKind / opts.claudeConfigDir /
768
- * opts.codexHome / opts.initialPrompt / model・sandbox 等の codex フラグ
769
- * - opts.initialPrompt は CLI 起動時に位置引数として末尾へ shell-safe に埋め込まれ、
770
- * TUI の初回プロンプトとして実行される (`/orchestrate` 等のスキル発火に利用)
771
- */
772
- export async function createSession(name, cwd, opts = {}) {
773
- const launchPaneCli = shouldLaunchPaneCli()
774
- if (launchPaneCli) validateInitialPrompt(opts.initialPrompt)
775
- // tmux は `-c` をシェル展開しないため、ここで `~` を絶対パスに展開する。
776
- // 展開後の cwd は同名引数で以降の検証 / tmux に渡す。
777
- const resolvedCwd = expandTilde(cwd)
778
- // resolver 失敗時に workspace 作成・Hub 同期・tmux 作成の副作用を残さない。
779
- const prebuiltCodexBaseCmd =
780
- launchPaneCli && opts.cliKind === "codex"
781
- ? opts.codexCmd ?? buildCodexCmd(opts)
782
- : null
783
- await prepareChatWorkspace({ ...opts, cwd: resolvedCwd, session: name })
784
- // 既存チェック
785
- // ⚠️ `-t =<name>` で完全一致を強制する。tmux の `-t <name>` は「完全一致 →
786
- // 先頭一致(prefix) → fnmatch」の順で解決するため、素の `-t <name>` だと
787
- // claude セッション `<dir>` の重複チェックが codex セッション `<dir>__codex`
788
- // (claude 名を接頭辞に含む) に**前方一致**して false positive の "duplicate
789
- // session" を投げ、codex 稼働時に claude が起動できなくなる (T06593)。
790
- try {
791
- await execFileP(tmuxBin(opts), ["has-session", "-t", `=${name}`])
792
- throw new Error("duplicate session")
793
- } catch (err) {
794
- const msg = err?.message || String(err)
795
- if (msg === "duplicate session") throw err
796
- // has-session が非 0 = セッション無し
797
- }
798
- await execFileP(tmuxBin(opts), ["new-session", "-d", "-s", name, "-c", resolvedCwd])
799
- // mouse mode を ON にする (スマホの touch swipe → SGR wheel escape を
800
- // tmux が copy-mode スクロールとして拾えるようにするため)。tmux 2.1+ で `mouse`
801
- // option は session-scoped、`-t <session>` 指定で他 session への副作用なし。
802
- try {
803
- await execFileP(tmuxBin(opts), ["set-option", "-t", name, "mouse", "on"])
804
- } catch (err) {
805
- opts.logger?.warn?.(
806
- { session: name, err: err?.message || String(err) },
807
- "tmux set-option mouse on failed (cockpit touch scroll may not work in this session)",
808
- )
809
- }
810
- // 起動直後 pane の current_path を確認し、想定 cwd と異なれば WARN ログを残す
811
- // (Phase 3: 任意検証。tmux 側のクオート / 権限問題で fallback した場合を検知する)
812
- if (opts.logger) {
813
- try {
814
- const { stdout } = await execFileP(tmuxBin(opts), [
815
- "display-message",
816
- "-p",
817
- "-t",
818
- `${name}:`,
819
- "-F",
820
- "#{pane_current_path}",
821
- ])
822
- const actual = (stdout || "").trim()
823
- if (actual && actual !== resolvedCwd) {
824
- opts.logger.warn(
825
- { session: name, requested: resolvedCwd, actual },
826
- "tmux pane cwd differs from requested cwd",
827
- )
828
- }
829
- } catch {
830
- // display-message 失敗は致命的でないので飲み込む
831
- }
832
- }
833
- // 既定では pane を素のシェルのまま残す (CLI は起動しない)。ここから下は
834
- // HUB_AGENT_TMUX_LAUNCH_CLI=1 のときだけ走る。
835
- //
836
- // 素のシェルでも必要な処理 (cwd 解決・CLAUDE.md 生成/同期・mouse mode) は上で
837
- // 済ませてある。CLAUDE_CONFIG_DIR / CODEX_HOME も、pane で手動起動したときに
838
- // 効くよう main.mjs の syncTmuxProfileEnv / syncTmuxCodexEnv が
839
- // `tmux set-environment -g` でサーバーのグローバル環境へ流し込んでいるため、
840
- // ここでコマンド文字列に焼き込む必要があるのは CLI を起動する場合だけ。
841
- if (!launchPaneCli) {
842
- opts.logger?.debug?.(
843
- { session: name, cwd: resolvedCwd },
844
- "pane CLI launch skipped (set HUB_AGENT_TMUX_LAUNCH_CLI=1 to enable)",
845
- )
846
- return
847
- }
848
- // cliKind に応じて claude / codex いずれの CLI を pane で起動するか分岐する。
849
- // 既定は claude(後方互換)。initialPrompt はどちらも位置引数として末尾注入する
850
- // (claude "<p>" / codex "<p>" / codex resume <id> "<p>" いずれも [PROMPT] を受ける)。
851
- const launchCmd =
852
- opts.cliKind === "codex"
853
- ? injectInitialPrompt(
854
- injectCodexHomeEnv(
855
- prebuiltCodexBaseCmd,
856
- opts.codexHome ?? process.env.CODEX_HOME ?? null,
857
- ),
858
- opts.initialPrompt,
859
- )
860
- : injectInitialPrompt(
861
- // アカウント・プロファイル切替 (CLAUDE_CONFIG_DIR) を TUI セッションにも効かせる。
862
- // applyActiveProfileEnv が process.env を最新に保つため、既定では env から拾う。
863
- injectConfigDirEnv(
864
- opts.claudeCmd ?? DEFAULT_CLAUDE_CMD,
865
- opts.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? null,
866
- ),
867
- opts.initialPrompt,
868
- )
869
- if (launchCmd) {
870
- await execFileP(tmuxBin(opts), ["send-keys", "-t", name, launchCmd, "Enter"])
871
- }
872
- }
873
-
874
- /** session_id として安全な形だけ許可する (send-keys へ流すためコマンド注入を防ぐ)。
875
- * Claude の session_id は UUID (ASCII 英数 + ハイフン) なので、それ以外は弾く。 */
876
- export function isSafeSessionId(sessionId) {
877
- return typeof sessionId === "string" && /^[A-Za-z0-9_-]{8,128}$/.test(sessionId)
878
- }
879
-
880
- /** tmux セッション名として安全な形だけ許可する。`-t <name>` の引数に流すため、先頭ハイフン
881
- * (フラグ誤認) や予期せぬ文字を弾く。agent が作るセッション名は英数・`_`・`-`・`.` のみ。 */
882
- export function isSafeSessionName(name) {
883
- return typeof name === "string" && /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(name)
884
- }
885
-
886
- /** bracketed paste マーカーと貼り付け確定の待ち時間 (pasteToSessionByName 用)。 */
887
- const PASTE_START = "\x1b[200~"
888
- const PASTE_END = "\x1b[201~"
889
- const PASTE_CONFIRM_DELAY_MS = 120
890
-
891
- // ---------------------------------------------------------------------------
892
- // Codex CLI 起動コマンドビルダ (claude 版と同形の純粋関数)
893
- //
894
- // 設計メモ:
895
- // - codex の対話起動フラグ (v0.142.x 実機確認): `-m/--model` / `-s/--sandbox`
896
- // {read-only|workspace-write|danger-full-access} / `-a/--ask-for-approval`
897
- // {untrusted|on-request|never} / `-C/--cd` / `-p/--profile`。
898
- // - 値は安全なトークン (モデル名・enum) を前提に bare で並べる (claude 版と同方針)。
899
- // - codex は claude の `--continue` のような **cwd スコープ継続を持たない**
900
- // (rollout は ~/.codex/sessions/Y/M/D に日付シャードで保存され cwd 非依存)。
901
- // そのため既定は fresh 起動とし、継続は明示 session_id で resume する。
902
- // - アカウント/プロファイル切替は CLAUDE_CONFIG_DIR に対応する `CODEX_HOME` の
903
- // ディレクトリ切替で行う (injectCodexHomeEnv)。
904
- // ---------------------------------------------------------------------------
905
-
906
- /**
907
- * Cockpit Codex 両対応 Stream A5(2026-07-02 実機検証で確定、中村さん明示承認済み):
908
- * Codex は hooks.json の各 hook エントリごとに `~/.codex/config.toml` の
909
- * `[hooks.state]` へ SHA256 ハッシュを信頼登録しないと発火しない(Claude の
910
- * settings.json hooks には無い追加の信頼レイヤー)。実機確認: 未信頼の新規 hook を
911
- * hooks.json に足して `codex exec` を実行したところ、既存の信頼済み hook は発火
912
- * したが新規 hook はエラーも出さず無言でスキップされた。hub-agent が配信する
913
- * バンドル hook は Hub という唯一の管理された配信元から来る
914
- * (`--dangerously-bypass-hook-trust` のヘルプ文言そのまま
915
- * 「automation that already vets hook sources」に該当)ため、常時このフラグを
916
- * 付与する(中村さん承認: 「常に --dangerously-bypass-hook-trust フラグを付与して、
917
- * 全部信用登録なしでOK」)。`HUB_CODEX_BYPASS_HOOK_TRUST=0` で無効化可能
918
- * (対話的な信頼プロンプトに戻したい場合の避難ハッチ)。
919
- * @returns {string} 空文字 (無効時) または `"--dangerously-bypass-hook-trust"`
920
- */
921
- function codexHookTrustBypassFlag() {
922
- return process.env.HUB_CODEX_BYPASS_HOOK_TRUST !== "0" ? "--dangerously-bypass-hook-trust" : ""
923
- }
924
-
925
- function codexShellBinary(opts = {}) {
926
- const resolver = opts._resolveCodexBinary || requireCodexBinary
927
- const resolved = resolver()
928
- if (!resolved?.available || !resolved.path) {
929
- throw new Error(resolved?.error || "codex CLI is unavailable")
930
- }
931
- return quotePosixShellArg(resolved.path)
932
- }
933
-
934
- /**
935
- * agent 設定から codex 起動 flags を組み立てる (純粋関数)。
936
- * model / sandbox / approvalPolicy / effort のうち指定されたものだけを並べ、
937
- * 先頭に常時 `--dangerously-bypass-hook-trust` を付与する
938
- * (`codexHookTrustBypassFlag` 参照。無効時は付与しない)。
939
- * @param {{model?:string,sandbox?:string,approvalPolicy?:string,effort?:string}} [opts]
940
- * @returns {string}
941
- */
942
- export function composeCodexFlags(opts = {}) {
943
- const model = (opts.model || "").trim()
944
- const sandbox = (opts.sandbox || "").trim()
945
- const approval = (opts.approvalPolicy || "").trim()
946
- const effort = (opts.effort || "").trim()
947
- const flags = []
948
- const bypass = codexHookTrustBypassFlag()
949
- if (bypass) flags.push(bypass)
950
- if (model) flags.push(`--model ${model}`)
951
- if (sandbox) flags.push(`--sandbox ${sandbox}`)
952
- if (approval) flags.push(`--ask-for-approval ${approval}`)
953
- // effort はフラグが無いため -c 設定上書きで渡す (TOML 解析失敗時は raw 文字列)。
954
- if (effort) flags.push(`-c model_reasoning_effort=${effort}`)
955
- return flags.join(" ")
956
- }
957
-
958
- /**
959
- * codex 既定起動コマンド (claude の buildClaudeCmd 相当)。
960
- * hub オプション (model/sandbox/approval/effort) があれば反映、無ければ
961
- * HUB_CODEX_CMD 環境変数、どちらも無ければ共通 resolver の実行ファイル。
962
- * ⚠️ hub オプション有無に関わらず `--dangerously-bypass-hook-trust` は常時付与する
963
- * (hub オプション判定と bypass flag 判定を分離しているため、HUB_CODEX_CMD
964
- * 分岐を選んでも bypass flag は付く。詳細は `codexHookTrustBypassFlag` 参照)。
965
- * @param {{model?:string,sandbox?:string,approvalPolicy?:string,effort?:string}} [opts]
966
- * @returns {string}
967
- */
968
- export function buildCodexCmd(opts = {}) {
969
- const model = (opts.model || "").trim()
970
- const sandbox = (opts.sandbox || "").trim()
971
- const approval = (opts.approvalPolicy || "").trim()
972
- const effort = (opts.effort || "").trim()
973
- const hasHubOptions = !!(model || sandbox || approval || effort)
974
- const bypass = codexHookTrustBypassFlag()
975
-
976
- if (hasHubOptions) {
977
- const flags = composeCodexFlags(opts)
978
- return `${codexShellBinary(opts)} ${flags}`.trim()
979
- }
980
- if (process.env.HUB_CODEX_CMD) {
981
- return `${process.env.HUB_CODEX_CMD}${bypass ? ` ${bypass}` : ""}`.trim()
982
- }
983
- const codex = codexShellBinary(opts)
984
- return bypass ? `${codex} ${bypass}` : codex
985
- }
986
-
987
- /**
988
- * まっさらな新規 codex セッション起動用コマンド (純粋関数)。
989
- * resume を付けないので codex は新しい rollout で起動する。
990
- * @param {{model?:string,sandbox?:string,approvalPolicy?:string,effort?:string}} [opts]
991
- * @returns {string}
992
- */
993
- export function buildCodexFreshCmd(opts = {}) {
994
- const flags = composeCodexFlags(opts)
995
- const codex = codexShellBinary(opts)
996
- return `${codex}${flags ? " " + flags : ""}`.trim()
997
- }
998
-
999
- /**
1000
- * TUI 載せ替え用の `codex resume <id> [flags]` コマンド (純粋関数)。
1001
- * session_id を明示 resume することで表示と書込の rollout を一致させる。
1002
- * @param {string} sessionId codex セッション id (UUID 等)
1003
- * @param {{model?:string,sandbox?:string,approvalPolicy?:string,effort?:string}} [opts]
1004
- * @returns {string}
1005
- * @throws session_id が安全形でなければ throw (コマンド注入防止)
1006
- */
1007
- export function buildCodexResumeCmd(sessionId, opts = {}) {
1008
- if (!isSafeSessionId(sessionId)) {
1009
- throw new Error(`unsafe session_id for codex resume: ${String(sessionId)}`)
1010
- }
1011
- const flags = composeCodexFlags(opts)
1012
- const codex = codexShellBinary(opts)
1013
- return `${codex} resume ${sessionId}${flags ? " " + flags : ""}`.trim()
1014
- }
1015
-
1016
- /**
1017
- * codexCmd の各セグメント先頭に `CODEX_HOME=<dir>` 環境変数代入を注入する
1018
- * (claude の injectConfigDirEnv 相当)。codexHome が空なら何もしない。
1019
- * @param {string} codexCmd
1020
- * @param {string} codexHome
1021
- * @returns {string}
1022
- */
1023
- export function injectCodexHomeEnv(codexCmd, codexHome) {
1024
- if (!codexHome || typeof codexHome !== "string") return codexCmd
1025
- if (typeof codexCmd !== "string" || codexCmd.length === 0) return codexCmd
1026
- const assignment = `CODEX_HOME=${quotePosixShellArg(codexHome)}`
1027
- return codexCmd
1028
- .split("||")
1029
- .map((part) => `${assignment} ${part.trim()}`)
1030
- .join(" || ")
1031
- }
1032
-
1033
- const _delay = (ms) => new Promise((r) => setTimeout(r, ms))
1034
-
1035
- /**
1036
- * 離席中キュー投入: 送信待ちメッセージ 1 件を、ブラウザがそのセッションのターミナルを
1037
- * マウントしていなくても tmux send-keys で対話 claude TUI へ投入する (案A の agent 側実体)。
1038
- *
1039
- * resumeWithMessage は改行を空白へ畳む単一行用 (誤確定防止) でチャット本文の複数行を壊すため、
1040
- * こちらは **bracketed paste** (ESC[200~ … ESC[201~) で本文を「貼り付け」として送り、改行を
1041
- * 保ったまま入力欄へ入れてから、確定の Enter (CR) を別途送る。これは frontend の通常送信
1042
- * (`\x1b[200~${text}\x1b[201~` → CR) と同じ手順を agent 側で再現したもの。tmux send-keys
1043
- * で直接ペインへ届くので、ブラウザ側が何も開いていなくても投入できる。
1044
- *
1045
- * @param {string} name tmux セッション名
1046
- * @param {string} text 投入する本文 (複数行可)
1047
- * @param {{logger?:object,tmuxBin?:string}} [opts]
1048
- * @returns {Promise<{ok:boolean, error?:string}>}
1049
- */
1050
- export async function pasteToSessionByName(name, text, opts = {}) {
1051
- const bin = tmuxBin(opts)
1052
- const body = String(text ?? "")
1053
- if (!body) return { ok: false, error: "empty paste text" }
1054
- if (!isSafeSessionName(name)) return { ok: false, error: "unsafe session name" }
1055
- try {
1056
- // copy-mode 等に入っているとキーが奪われるので先に抜ける (cyclePermission と同じ防御)。
1057
- try {
1058
- await execFileP(bin, ["send-keys", "-t", name, "-X", "cancel"])
1059
- } catch {
1060
- /* copy-mode でなければ "not in a mode" エラー = 無視 */
1061
- }
1062
- // bracketed paste の開始/終了マーカーと本文を literal (-l) で送る。改行を含む本文も
1063
- // 「貼り付け」として扱われ、Claude TUI は改行で確定しない (CR を別送するまで未確定)。
1064
- await execFileP(bin, ["send-keys", "-t", name, "-l", PASTE_START])
1065
- await execFileP(bin, ["send-keys", "-t", name, "-l", body])
1066
- await execFileP(bin, ["send-keys", "-t", name, "-l", PASTE_END])
1067
- // 貼り付けが入力欄へ反映される猶予を置いてから確定 (CR を本文より先着させない)。
1068
- await _delay(PASTE_CONFIRM_DELAY_MS)
1069
- await execFileP(bin, ["send-keys", "-t", name, "Enter"])
1070
- opts.logger?.info(
1071
- { session: name, len: body.length },
1072
- "tui queue flush: pasted queued message (headless)",
1073
- )
1074
- return { ok: true }
1075
- } catch (err) {
1076
- opts.logger?.warn(
1077
- { session: name, err: err?.message },
1078
- "pasteToSessionByName failed",
1079
- )
1080
- return { ok: false, error: err?.message || String(err) }
1081
- }
1082
- }
1083
-
1084
- /**
1085
- * pane_current_command から claude プロセスの生存を 3 値で返す (T2)。
1086
- *
1087
- * T2 (`scanTurnActive` の tool_use 末尾クラッシュ判定) は「claude が確実に死んだ ('dead')
1088
- * ときだけ active=false に倒し、判定不能 ('unknown') / 生存 ('alive') では active を維持する」
1089
- * 設計。判定不能を false (= 死んだ扱い) に倒す 2 値版だと、tmux 失敗 → 誤って idle 化
1090
- * = 偽陰性増加 = 中断後の長時間ツール中ペインを灰化させてしまうため、3 値で返す。
1091
- *
1092
- * 3 値の意味:
1093
- * - 'dead' : pane の前景がシェル (fish/bash/zsh/sh/dash/tmux/login) = claude プロセス
1094
- * 突然死 (中断マーカー無しの crash) でシェルに戻った確証あり
1095
- * - 'alive' : pane の前景がシェル以外 (claude / claude.exe / node 等) = 生存
1096
- * - 'unknown' : tmux 失敗 / 空出力 = 判定不能 = 安全側で active 維持に倒す
1097
- *
1098
- * 命名 (`AliveOrUnknown`) は呼び出し側で「3 値である」ことを明示するため。
1099
- *
1100
- * @param {string} name
1101
- * @param {{ tmux?: string }} [opts]
1102
- * @returns {Promise<'alive' | 'dead' | 'unknown'>}
1103
- */
1104
- export async function paneClaudeAliveOrUnknown(name, opts = {}) {
1105
- if (!name) return "unknown"
1106
- try {
1107
- const { stdout } = await execFileP(tmuxBin(opts), [
1108
- "display-message",
1109
- "-p",
1110
- "-t",
1111
- `${name}:`,
1112
- "-F",
1113
- "#{pane_current_command}",
1114
- ])
1115
- const cmd = (stdout || "").trim().toLowerCase()
1116
- if (!cmd) return "unknown"
1117
- const SHELLS = new Set(["fish", "bash", "zsh", "sh", "dash", "tmux", "login"])
1118
- return SHELLS.has(cmd) ? "dead" : "alive"
1119
- } catch {
1120
- return "unknown"
1121
- }
1122
- }