@cocorograph/hub-agent 0.6.61 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.6.61",
3
+ "version": "0.6.62",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -148,11 +148,47 @@ export async function fetchSessionHistory({ cwd, session_id, maxLines, projectsR
148
148
  }
149
149
 
150
150
  /**
151
- * jsonl ファイルから最初の user メッセージ本文 (preview 用) を抽出する。
152
- * 大きいファイルでも先頭付近で見つかるので、先頭 64KB だけ読んで探す。
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>} 先頭 user メッセージの冒頭 (最大 80 文字)、無ければ ""
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
- str = str.trim().replace(/\s+/g, " ")
193
- if (str) return str.slice(0, 80)
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
- // 1) 載せ替え先 session_id を確定。
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 --resume で載せ替え (targetId があるときだけ)。
1555
- // 冪等性: 同じ session を同じ id へ既に載せ替え済みなら respawn しない
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
- if (targetId && sessionName) {
1560
- if (ctx.tuiReboundSessions.get(sessionName) === targetId) {
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, targetId)
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/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
- cmd = buildResumeCmd(sessionId, opts) // session_id 検証もここで実施
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 コマンドを送って claude を起動。
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
- "tui rebind: respawned pane shell + claude --resume",
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) {