@cocorograph/hub-agent 0.6.60 → 0.6.62
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 +1 -1
- package/src/claude-history.mjs +42 -6
- package/src/main.mjs +17 -7
- package/src/profiles.mjs +79 -1
- package/src/tmux.mjs +25 -6
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -148,11 +148,47 @@ export async function fetchSessionHistory({ cwd, session_id, maxLines, projectsR
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
/**
|
|
151
|
-
*
|
|
152
|
-
*
|
|
151
|
+
* user メッセージ本文を preview 用テキストへ正規化する。実発言ではない合成メッセージや
|
|
152
|
+
* コンテキスト操作コマンド (/clear・/compact) の痕跡をスキップし、業務スラッシュコマンドは
|
|
153
|
+
* `/name args` ラベルへ整形する。frontend hooks/cockpit/stream/events.ts の
|
|
154
|
+
* isHiddenUserEvent / slashCommandLabel と整合させる (別リポのため同等ロジックを再実装)。
|
|
155
|
+
*
|
|
156
|
+
* @param {string} raw
|
|
157
|
+
* @returns {string|null} preview に採用するテキスト (最大 80 文字)。スキップすべき行なら null。
|
|
158
|
+
*/
|
|
159
|
+
function normalizePreviewText(raw) {
|
|
160
|
+
const trimmed = (raw || "").trim()
|
|
161
|
+
if (!trimmed) return null
|
|
162
|
+
// SDK / ハーネスが注入する合成 user メッセージはスキップ (会話の主題ではない)。
|
|
163
|
+
if (trimmed.startsWith("[Request interrupted by user")) return null
|
|
164
|
+
if (trimmed.startsWith("Continue from where you left off")) return null
|
|
165
|
+
if (trimmed.startsWith("Base directory for this skill:")) return null
|
|
166
|
+
// スラッシュコマンド echo (<command-name> 注入) → `/name args` へ正規化。
|
|
167
|
+
// /clear・/compact は session 回転 / 圧縮の痕跡で会話本文ではないため preview から除外し、
|
|
168
|
+
// 次の実発言を拾う ("一覧が全部 /clear で始まる" 問題の解消)。
|
|
169
|
+
const nameMatch = trimmed.match(/<command-name>([^<]*)<\/command-name>/)
|
|
170
|
+
if (nameMatch) {
|
|
171
|
+
const name = nameMatch[1].trim()
|
|
172
|
+
if (name === "/clear" || name === "/compact") return null
|
|
173
|
+
const argsMatch = trimmed.match(/<command-args>([\s\S]*?)<\/command-args>/)
|
|
174
|
+
const args = (argsMatch?.[1] || "").trim()
|
|
175
|
+
const label = args ? `${name} ${args}` : name
|
|
176
|
+
return label.replace(/\s+/g, " ").slice(0, 80)
|
|
177
|
+
}
|
|
178
|
+
// local-command の stdout / caveat だけの注入行はスキップ。
|
|
179
|
+
if (trimmed.includes("<local-command-stdout>") || trimmed.includes("<local-command-caveat>")) {
|
|
180
|
+
return null
|
|
181
|
+
}
|
|
182
|
+
return trimmed.replace(/\s+/g, " ").slice(0, 80)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* jsonl ファイルから preview 用テキストを抽出する。最初の「実ユーザー発言」(合成メッセージ /
|
|
187
|
+
* /clear・/compact の痕跡を除く) を返す。大きいファイルでも先頭付近で見つかるので、
|
|
188
|
+
* 先頭 64KB だけ読んで探す。
|
|
153
189
|
*
|
|
154
190
|
* @param {string} filePath
|
|
155
|
-
* @returns {Promise<string>}
|
|
191
|
+
* @returns {Promise<string>} preview テキスト (最大 80 文字)、無ければ ""
|
|
156
192
|
*/
|
|
157
193
|
async function extractPreview(filePath) {
|
|
158
194
|
// P-perf: ファイル全体を readFile してから 64KB に slice すると、肥大化した jsonl で
|
|
@@ -180,7 +216,7 @@ async function extractPreview(filePath) {
|
|
|
180
216
|
} catch {
|
|
181
217
|
continue
|
|
182
218
|
}
|
|
183
|
-
if (obj?.type === "user" && obj.message) {
|
|
219
|
+
if (obj?.type === "user" && obj.message && obj.isMeta !== true) {
|
|
184
220
|
const content = obj.message.content
|
|
185
221
|
let str = ""
|
|
186
222
|
if (typeof content === "string") {
|
|
@@ -189,8 +225,8 @@ async function extractPreview(filePath) {
|
|
|
189
225
|
const textBlock = content.find((b) => b?.type === "text" && typeof b.text === "string")
|
|
190
226
|
if (textBlock) str = textBlock.text
|
|
191
227
|
}
|
|
192
|
-
|
|
193
|
-
if (
|
|
228
|
+
const preview = normalizePreviewText(str)
|
|
229
|
+
if (preview) return preview
|
|
194
230
|
}
|
|
195
231
|
}
|
|
196
232
|
return ""
|
package/src/main.mjs
CHANGED
|
@@ -1528,12 +1528,16 @@ async function dispatch(msg, ctx) {
|
|
|
1528
1528
|
...payload,
|
|
1529
1529
|
})
|
|
1530
1530
|
try {
|
|
1531
|
-
//
|
|
1531
|
+
// fresh: 「まっさらな新規セッション」要求 (ピッカーの「+新規」)。cwd 最新へ
|
|
1532
|
+
// resume せず resume 無しの claude を起動する。session_id は起動後の初回送信で
|
|
1533
|
+
// 生成され、frontend は回転検知 / sessions 応答で拾う。
|
|
1534
|
+
const fresh = msg.fresh === true
|
|
1535
|
+
// 1) 載せ替え先 session_id を確定 (fresh は解決しない = 新規起動)。
|
|
1532
1536
|
let targetId =
|
|
1533
1537
|
typeof msg.session_id === "string" && msg.session_id
|
|
1534
1538
|
? msg.session_id
|
|
1535
1539
|
: null
|
|
1536
|
-
if (!targetId && cwd) {
|
|
1540
|
+
if (!fresh && !targetId && cwd) {
|
|
1537
1541
|
const projectsRoot = await getActiveProjectsRoot()
|
|
1538
1542
|
const { sessions } = await listSessions({
|
|
1539
1543
|
cwd,
|
|
@@ -1551,26 +1555,32 @@ async function dispatch(msg, ctx) {
|
|
|
1551
1555
|
session_id: targetId || undefined,
|
|
1552
1556
|
})
|
|
1553
1557
|
}
|
|
1554
|
-
// 3) tmux claude
|
|
1555
|
-
// 冪等性: 同じ session
|
|
1558
|
+
// 3) tmux claude を起動し直す (resume or fresh)。
|
|
1559
|
+
// 冪等性: 同じ session を同じキーへ既に載せ替え済みなら respawn しない
|
|
1556
1560
|
// (TUI ビューは remount のたびに bind を送るため、毎回 respawn すると
|
|
1557
1561
|
// claude が再起動ループになり送信不能になる。2026-06-06 leader 実機)。
|
|
1562
|
+
// resume は targetId をキー、fresh は session_id 不在のため bind の request_id を
|
|
1563
|
+
// キーにする (同一マウントの再送は同 request_id で skip、新規要求は再マウントで
|
|
1564
|
+
// 新 request_id になり 1 回だけ起動する)。
|
|
1558
1565
|
let rebind = { ok: false, skipped: false }
|
|
1559
|
-
|
|
1560
|
-
|
|
1566
|
+
const bindKey = fresh ? `fresh:${request_id || ""}` : targetId
|
|
1567
|
+
if (bindKey && sessionName && (fresh || targetId)) {
|
|
1568
|
+
if (ctx.tuiReboundSessions.get(sessionName) === bindKey) {
|
|
1561
1569
|
rebind = { ok: true, skipped: true }
|
|
1562
1570
|
} else {
|
|
1563
1571
|
rebind = await rebindClaudeSession(sessionName, targetId, {
|
|
1564
1572
|
cwd,
|
|
1565
1573
|
model: ctx.config?.claude_model || "",
|
|
1566
1574
|
permissionMode: ctx.config?.claude_permission_mode || "",
|
|
1575
|
+
fresh,
|
|
1567
1576
|
logger,
|
|
1568
1577
|
})
|
|
1569
|
-
if (rebind.ok) ctx.tuiReboundSessions.set(sessionName,
|
|
1578
|
+
if (rebind.ok) ctx.tuiReboundSessions.set(sessionName, bindKey)
|
|
1570
1579
|
}
|
|
1571
1580
|
}
|
|
1572
1581
|
reply({
|
|
1573
1582
|
session_id: targetId,
|
|
1583
|
+
fresh,
|
|
1574
1584
|
stopped_sdk: stoppedSdk,
|
|
1575
1585
|
rebound: !!rebind.ok,
|
|
1576
1586
|
skipped: !!rebind.skipped,
|
package/src/profiles.mjs
CHANGED
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 1 台のマシンで複数の Claude アカウント (例: 個人 Max / チーム) を `CLAUDE_CONFIG_DIR`
|
|
5
5
|
* で切り替えて使うための仕組み。各プロファイルは独立した config ディレクトリを持ち、
|
|
6
|
-
*
|
|
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
|
@@ -770,6 +770,19 @@ export function buildResumeCmd(sessionId, opts = {}) {
|
|
|
770
770
|
return `claude --resume ${sessionId}${flags ? " " + flags : ""}`.trim()
|
|
771
771
|
}
|
|
772
772
|
|
|
773
|
+
/**
|
|
774
|
+
* まっさらな新規セッション起動用の `claude [flags]` コマンド文字列を組み立てる (純粋関数)。
|
|
775
|
+
* --resume を付けないので claude は新しい session_id (新 jsonl) で起動する。Cockpit の
|
|
776
|
+
* セッション選択プルダウンの「+新規」で、cwd 最新へ resume せず仕切り直したいときに使う。
|
|
777
|
+
*
|
|
778
|
+
* @param {{model?:string,permissionMode?:string}} [opts]
|
|
779
|
+
* @returns {string}
|
|
780
|
+
*/
|
|
781
|
+
export function buildFreshCmd(opts = {}) {
|
|
782
|
+
const flags = composeClaudeFlags(opts)
|
|
783
|
+
return `claude${flags ? " " + flags : ""}`.trim()
|
|
784
|
+
}
|
|
785
|
+
|
|
773
786
|
const _delay = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
774
787
|
|
|
775
788
|
/**
|
|
@@ -1019,15 +1032,19 @@ export async function recoverTuiInput(name, expectText, opts = {}) {
|
|
|
1019
1032
|
*
|
|
1020
1033
|
* ベストエフォート。失敗しても throw せず {ok:false} を返し、上位は newest 表示に degrade する。
|
|
1021
1034
|
*
|
|
1035
|
+
* opts.fresh=true のときは sessionId を無視し、resume 無しの `claude` を起動して
|
|
1036
|
+
* まっさらな新規セッションにする (ピッカーの「+新規」)。
|
|
1037
|
+
*
|
|
1022
1038
|
* @param {string} name tmux セッション名
|
|
1023
|
-
* @param {string} sessionId 載せ替え先 Claude session_id
|
|
1024
|
-
* @param {{cwd?:string,model?:string,permissionMode?:string,logger?:object,tmuxBin?:string}} [opts]
|
|
1039
|
+
* @param {string|null} sessionId 載せ替え先 Claude session_id (opts.fresh 時は null 可)
|
|
1040
|
+
* @param {{cwd?:string,model?:string,permissionMode?:string,fresh?:boolean,logger?:object,tmuxBin?:string}} [opts]
|
|
1025
1041
|
* @returns {Promise<{ok:boolean, cmd?:string, error?:string}>}
|
|
1026
1042
|
*/
|
|
1027
1043
|
export async function rebindClaudeSession(name, sessionId, opts = {}) {
|
|
1028
1044
|
let cmd
|
|
1029
1045
|
try {
|
|
1030
|
-
|
|
1046
|
+
// fresh=新規起動 (--resume 無し)。それ以外は session_id 検証込みの resume。
|
|
1047
|
+
cmd = opts.fresh ? buildFreshCmd(opts) : buildResumeCmd(sessionId, opts)
|
|
1031
1048
|
} catch (err) {
|
|
1032
1049
|
return { ok: false, error: err?.message || String(err) }
|
|
1033
1050
|
}
|
|
@@ -1040,11 +1057,13 @@ export async function rebindClaudeSession(name, sessionId, opts = {}) {
|
|
|
1040
1057
|
await execFileP(bin, respArgs)
|
|
1041
1058
|
// 2) シェル準備待ち (rc 読み込み等)。send-keys は pty にバッファされるので過敏でなくてよい。
|
|
1042
1059
|
await _delay(300)
|
|
1043
|
-
// 3) クリーンなシェルに resume
|
|
1060
|
+
// 3) クリーンなシェルに claude 起動コマンドを送る (resume or 新規)。
|
|
1044
1061
|
await execFileP(bin, ["send-keys", "-t", name, cmd, "Enter"])
|
|
1045
1062
|
opts.logger?.info(
|
|
1046
|
-
{ session: name, resume: sessionId },
|
|
1047
|
-
|
|
1063
|
+
{ session: name, resume: opts.fresh ? null : sessionId, fresh: !!opts.fresh },
|
|
1064
|
+
opts.fresh
|
|
1065
|
+
? "tui rebind: respawned pane shell + claude (fresh session)"
|
|
1066
|
+
: "tui rebind: respawned pane shell + claude --resume",
|
|
1048
1067
|
)
|
|
1049
1068
|
return { ok: true, cmd }
|
|
1050
1069
|
} catch (err) {
|