@cocorograph/hub-agent 0.6.59 → 0.6.61

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.59",
3
+ "version": "0.6.61",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/main.mjs CHANGED
@@ -69,6 +69,7 @@ import {
69
69
  removeWorktree as removeWorktreeDir,
70
70
  resumeWithMessage,
71
71
  setTmuxGlobalEnv,
72
+ setTuiModel,
72
73
  } from "./tmux.mjs"
73
74
  import { TuiPermissionBridge } from "./tui-permission-bridge.mjs"
74
75
  import { TuiViewerRegistry } from "./tui-viewer-registry.mjs"
@@ -1383,6 +1384,41 @@ async function dispatch(msg, ctx) {
1383
1384
  })()
1384
1385
  return
1385
1386
  }
1387
+ case "claude.tui.setModel": {
1388
+ // モデルバッジ選択 → 対話 claude TUI へ `/model <id>` を送ってモデルを切り替える。
1389
+ // 権限循環 (cyclePermission) と同設計: agent が実キーを送出 → 確認プロンプトを確定 →
1390
+ // 全ブラウザへ claude.tui.model を broadcast し、実際に動いているターミナルのモデルを
1391
+ // 正本として全端末に同期する。jsonl の message.model は次の assistant ターンまで更新
1392
+ // されないため、この即時 broadcast で切替直後のバッジズレを解消する。
1393
+ const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
1394
+ const sessionName =
1395
+ typeof msg.session_name === "string" ? msg.session_name : ""
1396
+ if (!sessionName) return
1397
+ // model="" は「デフォルト」= `/model default`。frontend へはそのまま空で返し、
1398
+ // 解決後の実 id は次ターンの jsonl 由来 (message.model) に委ねる。
1399
+ const model = typeof msg.model === "string" ? msg.model : ""
1400
+ ;(async () => {
1401
+ try {
1402
+ await setTuiModel(sessionName, model || "default", { logger })
1403
+ ctx.client.send({
1404
+ type: "claude.tui.model",
1405
+ cwd: cwd || undefined,
1406
+ session_name: sessionName,
1407
+ model,
1408
+ })
1409
+ logger.info(
1410
+ { session: sessionName, cwd, model: model || "(default)" },
1411
+ "tui model switched → notified browser",
1412
+ )
1413
+ } catch (err) {
1414
+ logger.warn(
1415
+ { err: err?.message, session: sessionName },
1416
+ "claude.tui.setModel failed",
1417
+ )
1418
+ }
1419
+ })()
1420
+ return
1421
+ }
1386
1422
  case "claude.tui.probePermission": {
1387
1423
  // 読み取り専用の権限モード問い合わせ (cold-load seed)。キーは送らず、ペインを
1388
1424
  // 読んで現在の実モードを claude.tui.permission として broadcast するだけ。
package/src/profiles.mjs CHANGED
@@ -3,10 +3,16 @@
3
3
  *
4
4
  * 1 台のマシンで複数の Claude アカウント (例: 個人 Max / チーム) を `CLAUDE_CONFIG_DIR`
5
5
  * で切り替えて使うための仕組み。各プロファイルは独立した config ディレクトリを持ち、
6
- * 認証・会話履歴 (`projects/`)・`.claude.json` をアカウント別に保持する。一方で
6
+ * 認証 (`.credentials.json` / Keychain) と `.claude.json` をアカウント別に保持する。一方で
7
7
  * skills / agents / commands / scripts / hooks / CLAUDE.md は symlink で共有し、
8
8
  * settings.json はコピーで共有する (provisionProfile が面倒を見る)。
9
9
  *
10
+ * 会話履歴 (`projects/`) は **既定 `~/.claude/projects` に集約して全プロファイルで共有する**
11
+ * (ensureSharedProjects が `<configDir>/projects` を既定への symlink にする)。同じ作業
12
+ * ディレクトリの会話コンテキストがアカウントをまたいで引き継げるようにするため。新規
13
+ * プロファイルは単純に symlink を張るだけ。既存の実体 `projects/` がある場合は無損失で
14
+ * 既定へマージ (no-clobber) してからバックアップ退避し symlink 化する。
15
+ *
10
16
  * 設計方針 (重要):
11
17
  * - 既定 (プライマリ) プロファイルは **常に `~/.claude`**。リネーム・移動しない。
12
18
  * profiles.json が存在しない単一アカウント利用者は、この仕組みに一切触れずに
@@ -259,6 +265,67 @@ export const SHARED_SYMLINK_ITEMS = [
259
265
  /** コピーで共有する資産 (アプリが書き換えるため symlink 不可)。 */
260
266
  export const SHARED_COPY_ITEMS = ["settings.json"]
261
267
 
268
+ /**
269
+ * 会話履歴 (`projects/`) を既定 `~/.claude/projects` に集約・共有する。
270
+ *
271
+ * - 既定プロファイル自身 (configDir === ~/.claude) は集約元なので何もしない。
272
+ * - `<configDir>/projects` が既に既定への symlink なら冪等に何もしない。
273
+ * - 実体ディレクトリが存在する場合: 中身を no-clobber で既定へマージ (履歴を 1 件も
274
+ * 失わない)、元ディレクトリを `projects.premigrate-backup[-n]` に退避してから symlink 化。
275
+ * - 何も無ければ素直に既定への symlink を張る。
276
+ *
277
+ * @param {string} configDir - 対象プロファイルの config ディレクトリ絶対パス
278
+ * @returns {Promise<"self"|"already-linked"|"linked"|"merged">}
279
+ */
280
+ export async function ensureSharedProjects(configDir) {
281
+ const canonical = path.join(defaultConfigDir(), "projects")
282
+ const link = path.join(configDir, "projects")
283
+
284
+ // 既定プロファイル自身は集約元。リンクで自己参照させない。
285
+ if (path.resolve(configDir) === defaultConfigDir()) return "self"
286
+
287
+ // 集約先を必ず用意しておく (空でも symlink 先として有効にするため)。
288
+ await fs.mkdir(canonical, { recursive: true, mode: 0o700 })
289
+
290
+ let st = null
291
+ try {
292
+ st = await fs.lstat(link)
293
+ } catch {
294
+ /* not exists */
295
+ }
296
+
297
+ if (st && st.isSymbolicLink()) {
298
+ // 既に symlink。既定を指していればそのまま (冪等)。別の場所を指していても
299
+ // 履歴の所在を勝手に変えるのは危険なので触らない。
300
+ return "already-linked"
301
+ }
302
+
303
+ if (!st) {
304
+ await fs.symlink(canonical, link)
305
+ return "linked"
306
+ }
307
+
308
+ // 実体が存在 (通常はディレクトリ)。無損失マージ → バックアップ退避 → symlink 化。
309
+ // force:false + errorOnExist:false で既存ファイルはスキップ (no-clobber)。
310
+ await fs.cp(link, canonical, {
311
+ recursive: true,
312
+ force: false,
313
+ errorOnExist: false,
314
+ })
315
+ let backup = `${link}.premigrate-backup`
316
+ for (let i = 1; ; i++) {
317
+ try {
318
+ await fs.lstat(backup)
319
+ backup = `${link}.premigrate-backup-${i}`
320
+ } catch {
321
+ break
322
+ }
323
+ }
324
+ await fs.rename(link, backup)
325
+ await fs.symlink(canonical, link)
326
+ return "merged"
327
+ }
328
+
262
329
  /**
263
330
  * プロファイルディレクトリを整備する。
264
331
  * - 共有 symlink 資産: 既定 `~/.claude/<item>` への絶対 symlink を張る (既存・実体があれば触らない)。
@@ -322,6 +389,17 @@ export async function provisionProfile(configDir) {
322
389
  }
323
390
  }
324
391
 
392
+ // 会話履歴は既定 ~/.claude/projects へ集約して共有する (アカウント間で会話
393
+ // コンテキストを引き継げるようにするため)。新規は素直に symlink、既存実体は
394
+ // 無損失マージしてから symlink 化する。
395
+ try {
396
+ const r = await ensureSharedProjects(configDir)
397
+ if (r === "linked" || r === "merged") linked.push(`projects(${r})`)
398
+ else skipped.push(`projects(${r})`)
399
+ } catch (err) {
400
+ skipped.push(`projects(失敗:${err?.code || err?.message || "error"})`)
401
+ }
402
+
325
403
  return { linked, copied, skipped }
326
404
  }
327
405
 
package/src/tmux.mjs CHANGED
@@ -865,6 +865,62 @@ export async function cyclePermissionMode(name, opts = {}) {
865
865
  }
866
866
  }
867
867
 
868
+ /**
869
+ * 対話 claude TUI に `/model <id>` を送ってモデルを切り替える。
870
+ *
871
+ * cockpit のモデルバッジ選択 (claude.tui.setModel) の書込側。権限循環 (cyclePermissionMode)
872
+ * と同じく、frontend は raw pty.data ではなく claude.tui.setModel を送り、agent 側で本関数を
873
+ * 実行 → 全ブラウザへ claude.tui.model を broadcast して全端末を実モデルに揃える。
874
+ *
875
+ * `/model` はフルモデルID をそのまま受理する (起動時 `claude --model <id>` と同仕様)。引数
876
+ * `default` は起動時の既定へ戻す。会話に出力済みの履歴があるとモデル切替時に確認プロンプトが
877
+ * 出るため、Enter を送って本文確定 → 少し待って追い Enter で確認を畳む (素の入力欄では空 Enter
878
+ * となり無害)。copy-mode に入っているとキーが奪われるので先に抜ける。ベストエフォート。
879
+ *
880
+ * @param {string} name tmux セッション名
881
+ * @param {string} modelArg `/model` 引数 (フルモデルID または "default")
882
+ * @param {{logger?:object,tmuxBin?:string}} [opts]
883
+ * @returns {Promise<{ok:boolean, error?:string}>}
884
+ */
885
+ export async function setTuiModel(name, modelArg, opts = {}) {
886
+ const bin = tmuxBin(opts)
887
+ const arg = String(modelArg || "default").replace(/[\r\n]+/g, " ").trim()
888
+ if (!arg) return { ok: false, error: "empty model arg" }
889
+ try {
890
+ // copy-mode 等に入っているとキーが奪われるので、入っている時だけ抜ける。
891
+ try {
892
+ const { stdout } = await execFileP(bin, [
893
+ "display-message",
894
+ "-p",
895
+ "-t",
896
+ `${name}:`,
897
+ "-F",
898
+ "#{pane_in_mode}",
899
+ ])
900
+ if (stdout.trim() === "1") {
901
+ await execFileP(bin, ["send-keys", "-t", name, "-X", "cancel"])
902
+ }
903
+ } catch {
904
+ // pane_in_mode 取得失敗はベストエフォートで無視。
905
+ }
906
+ // `/model <id>` をリテラルで送る (-l でキー名解釈・スラッシュ補完の暴発を避ける)。
907
+ await execFileP(bin, ["send-keys", "-t", name, "-l", `/model ${arg}`])
908
+ await _delay(120)
909
+ // Enter で本文確定 (send-keys の離散イベントなので paste 吸収は起きにくい)。
910
+ await execFileP(bin, ["send-keys", "-t", name, "Enter"])
911
+ // prior output ありの場合に出るモデル切替の確認プロンプトを Enter で畳む。
912
+ await _delay(450)
913
+ await execFileP(bin, ["send-keys", "-t", name, "Enter"])
914
+ return { ok: true }
915
+ } catch (err) {
916
+ opts.logger?.warn(
917
+ { session: name, model: arg, err: err?.message },
918
+ "setTuiModel failed",
919
+ )
920
+ return { ok: false, error: err?.message || String(err) }
921
+ }
922
+ }
923
+
868
924
  /**
869
925
  * 中断キャンセル後の入力欄復旧 (claude.tui.recoverInput / agent >= 0.6.57)。
870
926
  *