@cocorograph/hub-agent 0.4.1

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.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * node-pty ベースの pty 多重化ブリッジ (Sprint D)。
3
+ *
4
+ * - 1 agent プロセス内で複数 stream を `Map<stream_id, PtyProcess>` で管理
5
+ * - `attach()` で pty を spawn (plugin `interceptPtySpawn` が override 可)
6
+ * - `write()` / `resize()` / `detach()` は stream_id で個別操作
7
+ * - pty 出力は `'output'` イベント、終了は `'exit'` イベントで EventEmitter 経由
8
+ *
9
+ * デフォルト spawn は `tmux attach -t <session_name>` (Sprint E で tmux モジュール
10
+ * が attach 前に session が存在することを保証する想定)。
11
+ *
12
+ * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
13
+ * 移植元: D00000_cockpit/webapp/server/ws-server.mjs
14
+ */
15
+ import { EventEmitter } from "node:events"
16
+ import { execFileSync } from "node:child_process"
17
+
18
+ import { runHookChain } from "./plugin-loader.mjs"
19
+
20
+ const DEFAULT_COLS = 120
21
+ const DEFAULT_ROWS = 32
22
+
23
+ function resolveBin(name) {
24
+ try {
25
+ return execFileSync("/usr/bin/which", [name], { encoding: "utf-8" }).trim()
26
+ } catch {
27
+ return name
28
+ }
29
+ }
30
+
31
+ let _tmuxBin = null
32
+ function tmuxBin() {
33
+ if (_tmuxBin === null) _tmuxBin = resolveBin("tmux")
34
+ return _tmuxBin
35
+ }
36
+
37
+ export class PtyBridge extends EventEmitter {
38
+ /**
39
+ * @param {object} opts
40
+ * @param {object} opts.ptyModule - `await import('node-pty')` の結果 (テストで stub 注入可)
41
+ * @param {import('pino').Logger} [opts.logger]
42
+ * @param {Array} [opts.plugins] - plugin-loader.mjs の loadPlugins() 結果
43
+ * @param {(args: {sessionName: string, cols: number, rows: number, env: object}) => {command: string, args: string[], env?: object}} [opts.defaultSpawnCommand]
44
+ * - plugin が null を返したときに使うデフォルト spawn 仕様。省略時は
45
+ * `/bin/sh -c "exec tmux attach -t <sessionName>"`
46
+ */
47
+ constructor({ ptyModule, logger, plugins = [], defaultSpawnCommand } = {}) {
48
+ super()
49
+ if (!ptyModule || typeof ptyModule.spawn !== "function") {
50
+ throw new TypeError("PtyBridge requires { ptyModule: { spawn } }")
51
+ }
52
+ this.ptyModule = ptyModule
53
+ this.logger = logger
54
+ this.plugins = plugins
55
+ this.defaultSpawnCommand =
56
+ defaultSpawnCommand ||
57
+ (({ sessionName }) => ({
58
+ command: "/bin/sh",
59
+ args: ["-c", `exec ${tmuxBin()} attach -t ${sessionName}`],
60
+ env: process.env,
61
+ }))
62
+ /** @type {Map<string, import('node-pty').IPty>} */
63
+ this.streams = new Map()
64
+ }
65
+
66
+ /**
67
+ * 新しい pty を spawn して stream_id に紐付ける。
68
+ *
69
+ * @param {{stream_id: string, sessionName?: string, cols?: number, rows?: number, env?: object}} args
70
+ * @returns {Promise<{plugin: string|null, command: string, args: string[]}>}
71
+ */
72
+ async attach({ stream_id, sessionName = "", cols = DEFAULT_COLS, rows = DEFAULT_ROWS, env }) {
73
+ if (!stream_id) throw new TypeError("attach requires stream_id")
74
+ if (this.streams.has(stream_id)) {
75
+ throw new Error(`stream_id "${stream_id}" は既に attach 済みです`)
76
+ }
77
+ const ctx = {
78
+ logger: this.logger,
79
+ sessionName,
80
+ cols,
81
+ rows,
82
+ env: env || process.env,
83
+ }
84
+
85
+ const hookResult = await runHookChain(this.plugins, "interceptPtySpawn", ctx)
86
+ const spec = hookResult?.result ?? this.defaultSpawnCommand(ctx)
87
+
88
+ let pty
89
+ try {
90
+ pty = this.ptyModule.spawn(spec.command, spec.args, {
91
+ name: "xterm-256color",
92
+ cols,
93
+ rows,
94
+ cwd: spec.env?.HOME || process.env.HOME,
95
+ env: spec.env || process.env,
96
+ })
97
+ } catch (err) {
98
+ this.logger?.error(
99
+ { stream_id, command: spec.command, err: err.message },
100
+ "pty spawn failed",
101
+ )
102
+ throw err
103
+ }
104
+
105
+ this.streams.set(stream_id, pty)
106
+ this.logger?.info(
107
+ { stream_id, sessionName, command: spec.command, plugin: hookResult?.plugin || null },
108
+ "pty attached",
109
+ )
110
+
111
+ pty.onData((data) => {
112
+ this.emit("output", { stream_id, data })
113
+ })
114
+ pty.onExit(({ exitCode }) => {
115
+ this.streams.delete(stream_id)
116
+ this.emit("exit", { stream_id, code: exitCode })
117
+ })
118
+
119
+ return {
120
+ plugin: hookResult?.plugin || null,
121
+ command: spec.command,
122
+ args: spec.args,
123
+ }
124
+ }
125
+
126
+ /** ブラウザから来た入力を pty に流す。 */
127
+ write({ stream_id, data }) {
128
+ const pty = this.streams.get(stream_id)
129
+ if (!pty) {
130
+ this.logger?.warn({ stream_id }, "pty.write but stream missing")
131
+ return false
132
+ }
133
+ pty.write(data)
134
+ return true
135
+ }
136
+
137
+ /** ターミナルサイズ変更。 */
138
+ resize({ stream_id, cols, rows }) {
139
+ const pty = this.streams.get(stream_id)
140
+ if (!pty) {
141
+ this.logger?.warn({ stream_id }, "pty.resize but stream missing")
142
+ return false
143
+ }
144
+ try {
145
+ pty.resize(cols, rows)
146
+ return true
147
+ } catch (err) {
148
+ this.logger?.warn({ stream_id, err: err.message }, "pty.resize failed")
149
+ return false
150
+ }
151
+ }
152
+
153
+ /** pty を kill する。tmux session 自体は残る (detach 相当)。 */
154
+ detach({ stream_id }) {
155
+ const pty = this.streams.get(stream_id)
156
+ if (!pty) return false
157
+ try {
158
+ pty.kill()
159
+ } catch {
160
+ /* ignore — onExit で Map から消える */
161
+ }
162
+ return true
163
+ }
164
+
165
+ /** 全 pty を即時 kill (agent shutdown 用)。 */
166
+ shutdown() {
167
+ for (const stream_id of Array.from(this.streams.keys())) {
168
+ this.detach({ stream_id })
169
+ }
170
+ }
171
+
172
+ /** 現在 attach 中の stream_id 一覧 (debug 用)。 */
173
+ list() {
174
+ return Array.from(this.streams.keys())
175
+ }
176
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `hub-agent install-service` / `uninstall-service` の実装 (Sprint J)。
3
+ *
4
+ * - macOS: ~/Library/LaunchAgents/co.cocorograph.hub-agent.plist を install
5
+ * して launchctl bootstrap gui/<uid> で常駐化
6
+ * - Linux: ~/.config/systemd/user/hub-agent.service を install して
7
+ * systemctl --user enable --now hub-agent.service
8
+ *
9
+ * テンプレ内の __HUB_AGENT_BIN__ / __HOME__ / __PATH__ を実行時に置換する。
10
+ * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
11
+ */
12
+ import { promises as fs } from "node:fs"
13
+ import os from "node:os"
14
+ import path from "node:path"
15
+ import { spawnSync } from "node:child_process"
16
+ import { fileURLToPath } from "node:url"
17
+
18
+ const PLIST_LABEL = "co.cocorograph.hub-agent"
19
+ const SYSTEMD_UNIT_NAME = "hub-agent.service"
20
+
21
+ function repoTemplatesDir() {
22
+ // src/service-install.mjs → ../templates
23
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "templates")
24
+ }
25
+
26
+ function ensureUnixUid() {
27
+ if (process.getuid) return process.getuid()
28
+ return null
29
+ }
30
+
31
+ function macPlistPath() {
32
+ return path.join(os.homedir(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`)
33
+ }
34
+
35
+ function linuxUnitPath() {
36
+ return path.join(os.homedir(), ".config", "systemd", "user", SYSTEMD_UNIT_NAME)
37
+ }
38
+
39
+ async function readTemplate(name) {
40
+ const p = path.join(repoTemplatesDir(), name)
41
+ return fs.readFile(p, "utf-8")
42
+ }
43
+
44
+ function expandTemplate(text, hubAgentBin) {
45
+ return text
46
+ .replaceAll("__HUB_AGENT_BIN__", hubAgentBin)
47
+ .replaceAll("__HOME__", os.homedir())
48
+ .replaceAll("__PATH__", process.env.PATH || "/usr/local/bin:/usr/bin:/bin")
49
+ }
50
+
51
+ async function ensureDir(p) {
52
+ await fs.mkdir(p, { recursive: true })
53
+ }
54
+
55
+ async function ensureLogFile() {
56
+ await ensureDir(path.join(os.homedir(), ".hub"))
57
+ }
58
+
59
+ function run(cmd, args, opts = {}) {
60
+ const r = spawnSync(cmd, args, { stdio: "inherit", ...opts })
61
+ if (r.status !== 0) {
62
+ throw new Error(`${cmd} ${args.join(" ")} failed (exit ${r.status})`)
63
+ }
64
+ }
65
+
66
+ /** インストール先の hub-agent CLI のフルパスを返す。`hub-agent` が PATH にある前提。 */
67
+ function detectHubAgentBin() {
68
+ const r = spawnSync("/usr/bin/which", ["hub-agent"], { encoding: "utf-8" })
69
+ if (r.status === 0 && r.stdout.trim()) return r.stdout.trim()
70
+ // fallback: node $repo/bin/hub-agent.mjs
71
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "bin", "hub-agent.mjs")
72
+ }
73
+
74
+ export async function installService({ bin } = {}) {
75
+ const hubAgentBin = bin || detectHubAgentBin()
76
+ await ensureLogFile()
77
+
78
+ if (process.platform === "darwin") {
79
+ const tpl = await readTemplate("co.cocorograph.hub-agent.plist")
80
+ const expanded = expandTemplate(tpl, hubAgentBin)
81
+ const dest = macPlistPath()
82
+ await ensureDir(path.dirname(dest))
83
+ await fs.writeFile(dest, expanded, { mode: 0o644 })
84
+
85
+ const uid = ensureUnixUid()
86
+ // 既存ロード解除 → bootstrap → kickstart
87
+ spawnSync("launchctl", ["bootout", `gui/${uid}`, dest], { stdio: "ignore" })
88
+ run("launchctl", ["bootstrap", `gui/${uid}`, dest])
89
+ run("launchctl", ["kickstart", "-k", `gui/${uid}/${PLIST_LABEL}`])
90
+ return { platform: "darwin", path: dest, label: PLIST_LABEL, bin: hubAgentBin }
91
+ }
92
+
93
+ if (process.platform === "linux") {
94
+ const tpl = await readTemplate("hub-agent.service")
95
+ const expanded = expandTemplate(tpl, hubAgentBin)
96
+ const dest = linuxUnitPath()
97
+ await ensureDir(path.dirname(dest))
98
+ await fs.writeFile(dest, expanded, { mode: 0o644 })
99
+
100
+ run("systemctl", ["--user", "daemon-reload"])
101
+ run("systemctl", ["--user", "enable", "--now", SYSTEMD_UNIT_NAME])
102
+ return { platform: "linux", path: dest, unit: SYSTEMD_UNIT_NAME, bin: hubAgentBin }
103
+ }
104
+
105
+ throw new Error(`unsupported platform: ${process.platform}`)
106
+ }
107
+
108
+ export async function uninstallService() {
109
+ if (process.platform === "darwin") {
110
+ const uid = ensureUnixUid()
111
+ const dest = macPlistPath()
112
+ spawnSync("launchctl", ["bootout", `gui/${uid}`, dest], { stdio: "ignore" })
113
+ try {
114
+ await fs.unlink(dest)
115
+ } catch (err) {
116
+ if (err.code !== "ENOENT") throw err
117
+ }
118
+ return { platform: "darwin", path: dest, removed: true }
119
+ }
120
+
121
+ if (process.platform === "linux") {
122
+ spawnSync("systemctl", ["--user", "disable", "--now", SYSTEMD_UNIT_NAME], {
123
+ stdio: "ignore",
124
+ })
125
+ const dest = linuxUnitPath()
126
+ try {
127
+ await fs.unlink(dest)
128
+ } catch (err) {
129
+ if (err.code !== "ENOENT") throw err
130
+ }
131
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" })
132
+ return { platform: "linux", path: dest, removed: true }
133
+ }
134
+
135
+ throw new Error(`unsupported platform: ${process.platform}`)
136
+ }
137
+
138
+ export const _internal = {
139
+ expandTemplate,
140
+ detectHubAgentBin,
141
+ macPlistPath,
142
+ linuxUnitPath,
143
+ repoTemplatesDir,
144
+ }
package/src/skills.mjs ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Claude Code の skill / slash command を集計する (Sprint H)。
3
+ *
4
+ * - `~/.claude/skills/<name>/SKILL.md` の YAML frontmatter から name/description
5
+ * - `~/.claude/commands/<name>.md` の YAML frontmatter から name/description
6
+ * - 組み込みコマンド (clear, compact, help 等) はハードコード
7
+ * - sessionName が project local の場合、`~/hub/projects/<sessionName>/.claude/...`
8
+ * も追加で読む (Sprint H 拡張)
9
+ *
10
+ * 移植元: D00000_cockpit/webapp/lib/skills.ts
11
+ * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
12
+ */
13
+ import { promises as fs } from "node:fs"
14
+ import os from "node:os"
15
+ import path from "node:path"
16
+
17
+ const CLAUDE_DIR = process.env.CLAUDE_DIR || path.join(os.homedir(), ".claude")
18
+ const GLOBAL_SKILLS_DIR = path.join(CLAUDE_DIR, "skills")
19
+ const GLOBAL_COMMANDS_DIR = path.join(CLAUDE_DIR, "commands")
20
+
21
+ // Claude Code 組み込みスラッシュコマンド (ファイルから取得できないのでハードコード)
22
+ const BUILT_IN_COMMANDS = [
23
+ { name: "clear", description: "会話履歴をクリア (短期メモリのリセット)", source: "builtin" },
24
+ { name: "compact", description: "会話を要約して圧縮 (context 節約)", source: "builtin" },
25
+ { name: "help", description: "ヘルプ表示", source: "builtin" },
26
+ { name: "cost", description: "現セッションの API コスト表示", source: "builtin" },
27
+ { name: "model", description: "使用する Claude モデルを切替", source: "builtin" },
28
+ { name: "config", description: "Claude Code 設定を開く", source: "builtin" },
29
+ { name: "status", description: "セッション状態 (mode / 認証等) を表示", source: "builtin" },
30
+ { name: "permissions", description: "ツール許可ルールの管理", source: "builtin" },
31
+ { name: "agents", description: "subagent 一覧 / 管理", source: "builtin" },
32
+ { name: "mcp", description: "MCP サーバー状態", source: "builtin" },
33
+ { name: "ide", description: "IDE 統合", source: "builtin" },
34
+ { name: "login", description: "Claude にログイン", source: "builtin" },
35
+ { name: "logout", description: "ログアウト", source: "builtin" },
36
+ { name: "exit", description: "セッション終了", source: "builtin" },
37
+ { name: "release-notes", description: "Claude Code のリリースノート", source: "builtin" },
38
+ { name: "bug", description: "Claude Code のバグ報告を送信", source: "builtin" },
39
+ { name: "upgrade", description: "Claude Code を最新版にアップグレード", source: "builtin" },
40
+ { name: "fast", description: "Fast mode (Opus 4.6) のトグル", source: "builtin" },
41
+ { name: "pr-comments", description: "PR のコメントを取得", source: "builtin" },
42
+ { name: "install-github-app", description: "GitHub アプリのインストール", source: "builtin" },
43
+ { name: "migrate-installer", description: "古い installer から移行", source: "builtin" },
44
+ ]
45
+
46
+ function extractFrontmatter(text) {
47
+ const m = text.match(/^---\s*\n([\s\S]*?)\n---/)
48
+ if (!m) return {}
49
+ const fm = m[1]
50
+ const out = {}
51
+ const nameMatch = fm.match(/^name:\s*["']?([^"'\n]+)["']?\s*$/m)
52
+ if (nameMatch) out.name = nameMatch[1].trim()
53
+ const descMatch = fm.match(/^description:\s*["']?(.*)["']?\s*$/m)
54
+ if (descMatch) out.description = descMatch[1].replace(/^["']|["']$/g, "").trim()
55
+ return out
56
+ }
57
+
58
+ async function readOrNull(p) {
59
+ try {
60
+ return await fs.readFile(p, "utf-8")
61
+ } catch {
62
+ return null
63
+ }
64
+ }
65
+
66
+ async function loadSkillsDir(skillsDir, source) {
67
+ const out = []
68
+ let entries
69
+ try {
70
+ entries = await fs.readdir(skillsDir, { withFileTypes: true })
71
+ } catch {
72
+ return out // dir 不在 OK
73
+ }
74
+ for (const e of entries) {
75
+ if (e.name.startsWith(".")) continue
76
+ if (!e.isDirectory()) continue
77
+ const text = await readOrNull(path.join(skillsDir, e.name, "SKILL.md"))
78
+ if (!text) continue
79
+ const fm = extractFrontmatter(text)
80
+ out.push({
81
+ name: fm.name || e.name,
82
+ description: fm.description || "",
83
+ source,
84
+ })
85
+ }
86
+ return out
87
+ }
88
+
89
+ async function loadCommandsDir(commandsDir, source) {
90
+ const out = []
91
+ let files
92
+ try {
93
+ files = await fs.readdir(commandsDir)
94
+ } catch {
95
+ return out
96
+ }
97
+ for (const f of files) {
98
+ if (f.startsWith(".")) continue
99
+ if (!f.endsWith(".md")) continue
100
+ const text = await readOrNull(path.join(commandsDir, f))
101
+ if (!text) continue
102
+ const fm = extractFrontmatter(text)
103
+ out.push({
104
+ name: fm.name || f.replace(/\.md$/, ""),
105
+ description: fm.description || "",
106
+ source,
107
+ })
108
+ }
109
+ return out
110
+ }
111
+
112
+ /**
113
+ * skills 全リストを返す。
114
+ *
115
+ * @param {{ sessionName?: string, projectsRoot?: string }} [opts]
116
+ * - sessionName が `Dxxxxx_*` 形式なら projectsRoot 配下の同名 dir からも
117
+ * `.claude/skills/` と `.claude/commands/` を追加で読む
118
+ */
119
+ export async function listSkills(opts = {}) {
120
+ const out = []
121
+ out.push(...BUILT_IN_COMMANDS)
122
+ out.push(...(await loadSkillsDir(GLOBAL_SKILLS_DIR, "skill")))
123
+ out.push(...(await loadCommandsDir(GLOBAL_COMMANDS_DIR, "command")))
124
+
125
+ const sessionName = opts.sessionName
126
+ const projectsRoot = opts.projectsRoot || path.join(os.homedir(), "hub", "projects")
127
+ if (sessionName && /^D\d{5}_/.test(sessionName)) {
128
+ const projectDir = path.join(projectsRoot, sessionName, ".claude")
129
+ out.push(...(await loadSkillsDir(path.join(projectDir, "skills"), "project-skill")))
130
+ out.push(...(await loadCommandsDir(path.join(projectDir, "commands"), "project-command")))
131
+ }
132
+
133
+ // 同名は最初を採用、名前順 sort
134
+ const seen = new Set()
135
+ const deduped = out.filter((s) => {
136
+ if (seen.has(s.name)) return false
137
+ seen.add(s.name)
138
+ return true
139
+ })
140
+ deduped.sort((a, b) => a.name.localeCompare(b.name))
141
+ return deduped
142
+ }
package/src/state.mjs ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * tmux session の Claude Code TUI 状態を判定する (Sprint H part 2)。
3
+ *
4
+ * - listSessionNames(): `tmux list-sessions -F '#S'` で session 名一覧
5
+ * - detectStatus(sessionName): `tmux capture-pane -p -t name -S -30 -E -` で末尾を取得
6
+ * → "esc to interrupt" → processing / "❯ " or "> " → waiting / それ以外 → idle
7
+ * - detectContextPct(text): pane 末尾から "X% context left" / "X% until auto-compact"
8
+ * 等のパターンを拾う (見つからなければ null)
9
+ * - plugin `transformStatusDetection` hook を chain して結果差し替え可
10
+ *
11
+ * 移植元: D00000_cockpit/webapp/lib/tmux.ts (getSessionStatus 部分)
12
+ * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
13
+ */
14
+ import { execFile } from "node:child_process"
15
+ import { promisify } from "node:util"
16
+
17
+ import { runHookChain } from "./plugin-loader.mjs"
18
+
19
+ const execFileP = promisify(execFile)
20
+
21
+ const STATUSES = Object.freeze(["processing", "waiting", "idle"])
22
+
23
+ const CONTEXT_PATTERNS = [
24
+ /(\d{1,3})\s*%\s*context\s*left/i,
25
+ /context\s*[:\-]?\s*(\d{1,3})\s*%/i,
26
+ /(\d{1,3})\s*%\s*until\s*auto[-\s]?compact/i,
27
+ ]
28
+
29
+ function stripAnsi(s) {
30
+ // ANSI CSI / OSC / charset switch を削る
31
+ return s
32
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "")
33
+ .replace(/\x1b\][^\x07]*\x07/g, "")
34
+ .replace(/\x1b[()][AB012]/g, "")
35
+ }
36
+
37
+ export function detectStatusFromText(text) {
38
+ if (/esc to interrupt/i.test(text)) return "processing"
39
+ if (/❯\s/.test(text) || /^>\s/m.test(text)) return "waiting"
40
+ return "idle"
41
+ }
42
+
43
+ export function detectContextPctFromText(text) {
44
+ for (const re of CONTEXT_PATTERNS) {
45
+ const m = text.match(re)
46
+ if (m) {
47
+ const n = Number(m[1])
48
+ if (Number.isFinite(n) && n >= 0 && n <= 100) return n
49
+ }
50
+ }
51
+ return null
52
+ }
53
+
54
+ export async function listSessionNames(opts = {}) {
55
+ const tmuxBin = opts.tmuxBin || "tmux"
56
+ try {
57
+ const { stdout } = await execFileP(tmuxBin, ["list-sessions", "-F", "#S"])
58
+ return stdout
59
+ .split("\n")
60
+ .map((s) => s.trim())
61
+ .filter(Boolean)
62
+ } catch {
63
+ return []
64
+ }
65
+ }
66
+
67
+ export async function capturePane(sessionName, opts = {}) {
68
+ const tmuxBin = opts.tmuxBin || "tmux"
69
+ try {
70
+ const { stdout } = await execFileP(tmuxBin, [
71
+ "capture-pane",
72
+ "-p",
73
+ "-t",
74
+ sessionName,
75
+ "-S",
76
+ "-30",
77
+ "-E",
78
+ "-",
79
+ ])
80
+ return stripAnsi(stdout)
81
+ } catch {
82
+ return ""
83
+ }
84
+ }
85
+
86
+ /**
87
+ * 1 session の現在状態を取得する。plugin hook で上書き可。
88
+ *
89
+ * @returns {Promise<{status: string, context_pct: number | null}>}
90
+ */
91
+ export async function detectSessionState(sessionName, opts = {}) {
92
+ const text = await capturePane(sessionName, opts)
93
+ const defaultStatus = detectStatusFromText(text)
94
+ const defaultContextPct = detectContextPctFromText(text)
95
+
96
+ if (opts.plugins && opts.plugins.length) {
97
+ const hookResult = await runHookChain(opts.plugins, "transformStatusDetection", {
98
+ logger: opts.logger,
99
+ sessionName,
100
+ paneText: text,
101
+ defaultStatus,
102
+ })
103
+ if (hookResult?.result) {
104
+ return {
105
+ status: hookResult.result.status || defaultStatus,
106
+ context_pct: hookResult.result.context_pct ?? defaultContextPct,
107
+ }
108
+ }
109
+ }
110
+
111
+ return { status: defaultStatus, context_pct: defaultContextPct }
112
+ }
113
+
114
+ /** 全 session の現在状態を取得する。 */
115
+ export async function listSessionStates(opts = {}) {
116
+ const names = await listSessionNames(opts)
117
+ return Promise.all(
118
+ names.map(async (name) => ({
119
+ session_name: name,
120
+ ...(await detectSessionState(name, opts)),
121
+ })),
122
+ )
123
+ }
124
+
125
+ export { STATUSES }