@minimalcorp/tsunagi 0.1.40 → 0.1.42

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.
Files changed (95) hide show
  1. package/.next/standalone/apps/web/.next/BUILD_ID +1 -1
  2. package/.next/standalone/apps/web/.next/build-manifest.json +3 -3
  3. package/.next/standalone/apps/web/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/apps/web/.next/server/app/_global-error.html +1 -1
  5. package/.next/standalone/apps/web/.next/server/app/_global-error.rsc +1 -1
  6. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  9. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  10. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/apps/web/.next/server/app/_not-found.html +1 -1
  13. package/.next/standalone/apps/web/.next/server/app/_not-found.rsc +2 -2
  14. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  15. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  16. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  17. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  18. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  19. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  20. package/.next/standalone/apps/web/.next/server/app/index.html +1 -1
  21. package/.next/standalone/apps/web/.next/server/app/index.rsc +3 -3
  22. package/.next/standalone/apps/web/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  23. package/.next/standalone/apps/web/.next/server/app/index.segments/_full.segment.rsc +3 -3
  24. package/.next/standalone/apps/web/.next/server/app/index.segments/_head.segment.rsc +1 -1
  25. package/.next/standalone/apps/web/.next/server/app/index.segments/_index.segment.rsc +2 -2
  26. package/.next/standalone/apps/web/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  27. package/.next/standalone/apps/web/.next/server/app/page.js.nft.json +1 -1
  28. package/.next/standalone/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  29. package/.next/standalone/apps/web/.next/server/app/settings/page.js.nft.json +1 -1
  30. package/.next/standalone/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  31. package/.next/standalone/apps/web/.next/server/app/settings.html +1 -1
  32. package/.next/standalone/apps/web/.next/server/app/settings.rsc +3 -3
  33. package/.next/standalone/apps/web/.next/server/app/settings.segments/_full.segment.rsc +3 -3
  34. package/.next/standalone/apps/web/.next/server/app/settings.segments/_head.segment.rsc +1 -1
  35. package/.next/standalone/apps/web/.next/server/app/settings.segments/_index.segment.rsc +2 -2
  36. package/.next/standalone/apps/web/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  37. package/.next/standalone/apps/web/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
  38. package/.next/standalone/apps/web/.next/server/app/settings.segments/settings.segment.rsc +1 -1
  39. package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
  40. package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/apps/web/.next/server/chunks/ssr/{[root-of-the-server]__0nhzci6._.js → [root-of-the-server]__0d7excn._.js} +2 -2
  42. package/.next/standalone/apps/web/.next/server/chunks/ssr/{[root-of-the-server]__0vi01rl._.js → [root-of-the-server]__0w3_4.k._.js} +2 -2
  43. package/.next/standalone/apps/web/.next/server/chunks/ssr/{[root-of-the-server]__0zf09ry._.js → [root-of-the-server]__0ze019p._.js} +2 -2
  44. package/.next/standalone/apps/web/.next/server/chunks/ssr/{[root-of-the-server]__0ujaeyr._.js → [root-of-the-server]__13yhig0._.js} +2 -2
  45. package/.next/standalone/apps/web/.next/server/chunks/ssr/{_0soxqr.._.js → _0_2b4wf._.js} +2 -2
  46. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0g.cd6a._.js +1 -1
  47. package/.next/standalone/apps/web/.next/server/chunks/ssr/{_0bt-4.4._.js → _0s18_3n._.js} +2 -2
  48. package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_settings_page_tsx_0i6itlb._.js +1 -1
  49. package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_tasks_[id]_page_tsx_0jshsm2._.js +12 -12
  50. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0uej915._.js → node_modules_00z7dqq._.js} +1 -1
  51. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0k8xmjr._.js → node_modules_00~3~7_._.js} +1 -1
  52. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0bqsyfk._.js → node_modules_06uw9~g._.js} +1 -1
  53. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0smdx~2._.js → node_modules_07f58ia._.js} +1 -1
  54. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0fpqbi2._.js → node_modules_07ve26r._.js} +1 -1
  55. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0b6fjwn._.js → node_modules_0bq-u.l._.js} +1 -1
  56. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0pk6e0.._.js → node_modules_0f3mifg._.js} +1 -1
  57. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0_4__2k._.js → node_modules_0ih0wxv._.js} +1 -1
  58. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0r9vehj._.js → node_modules_0lgu9gx._.js} +1 -1
  59. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0bix9lu._.js → node_modules_0nwi8c2._.js} +1 -1
  60. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0w48uk8._.js → node_modules_0p2g6on._.js} +1 -1
  61. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_07-e6tb._.js → node_modules_0szd5t1._.js} +1 -1
  62. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0yoat72._.js → node_modules_0vtmkdp._.js} +1 -1
  63. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0bzw~9d._.js → node_modules_0z02r3w._.js} +1 -1
  64. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0fsewd2._.js → node_modules_0~pcd4h._.js} +1 -1
  65. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0l~40og._.js → node_modules_10mbxmu._.js} +1 -1
  66. package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +3 -3
  67. package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
  68. package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
  69. package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
  70. package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
  71. package/.next/standalone/apps/web/.next/static/chunks/{0nhcl~svhi_26.js → 0.e10jueei8qv.js} +3 -3
  72. package/.next/standalone/apps/web/.next/static/chunks/{0g5hoj5w-z.~6.js → 04ie1rttuhc99.js} +1 -1
  73. package/.next/standalone/apps/web/.next/static/chunks/{0q-u7bl7oa42n.js → 06sk6d3k059.n.js} +1 -1
  74. package/.next/standalone/apps/web/.next/static/chunks/09osn8dd42~wz.css +4 -0
  75. package/.next/standalone/apps/web/.next/static/chunks/0imnlpmbrdgiv.js +5 -0
  76. package/.next/standalone/apps/web/.next/static/chunks/{158q94mo2-6sx.js → 0u_07wgcigiqu.js} +8 -8
  77. package/.next/standalone/apps/web/.next/static/chunks/{02.u6zk77a338.js → 0wvv75.1gvloi.js} +12 -12
  78. package/.next/standalone/apps/web/.next/static/chunks/{0q_j4~6_etj_q.js → 10usg-2-r6y3f.js} +3 -3
  79. package/dist/server/lib/claude-command.d.ts +2 -0
  80. package/dist/server/lib/claude-command.js +47 -15
  81. package/dist/server/lib/local-llm-env.js +5 -0
  82. package/dist/server/lib/searxng.d.ts +2 -0
  83. package/dist/server/lib/searxng.js +280 -16
  84. package/dist/server/lib/tab-todos.d.ts +12 -0
  85. package/dist/server/lib/tab-todos.js +139 -0
  86. package/dist/server/routes/hooks.js +49 -63
  87. package/dist/server/routes/terminal.js +11 -4
  88. package/npm-shrinkwrap.json +2 -2
  89. package/package.json +1 -1
  90. package/tsunagi-marketplace/plugins/tsunagi-plugin/skills/tsunagi-task/SKILL.md +11 -9
  91. package/.next/standalone/apps/web/.next/static/chunks/09ortx3kdapgz.js +0 -5
  92. package/.next/standalone/apps/web/.next/static/chunks/0p9~lk406udl5.css +0 -4
  93. /package/.next/standalone/apps/web/.next/static/{O7XYaCzZTDY8_t2Tv-DeG → BNKOxgPR4vrQI79IAwnsk}/_buildManifest.js +0 -0
  94. /package/.next/standalone/apps/web/.next/static/{O7XYaCzZTDY8_t2Tv-DeG → BNKOxgPR4vrQI79IAwnsk}/_clientMiddlewareManifest.js +0 -0
  95. /package/.next/standalone/apps/web/.next/static/{O7XYaCzZTDY8_t2Tv-DeG → BNKOxgPR4vrQI79IAwnsk}/_ssgManifest.js +0 -0
@@ -2,5 +2,7 @@ import type { TabMode } from '@minimalcorp/tsunagi-shared';
2
2
  /** ローカルLLMタブの Claude Code の接続先(tsunagi の中継口) */
3
3
  export declare const LOCAL_LLM_PROXY_URL: string;
4
4
  export declare function isLocalLlmMode(mode: TabMode | undefined): boolean;
5
+ /** 起動スクリプトの type 引数。original: Anthropic、ollama / lm-studio: ローカルLLM */
6
+ export type LaunchType = 'original' | 'ollama' | 'lm-studio';
5
7
  /** タブの mode に応じた claude の起動コマンド(既存セッションがあれば resume) */
6
8
  export declare function buildClaudeCommand(sessionId: string, mode: TabMode): Promise<string>;
@@ -19,6 +19,10 @@ export function isLocalLlmMode(mode) {
19
19
  function shellQuote(value) {
20
20
  return `'${value.replace(/'/g, `'\\''`)}'`;
21
21
  }
22
+ const LAUNCH_TYPE_PROVIDER = {
23
+ ollama: 'ollama',
24
+ 'lm-studio': 'lmstudio',
25
+ };
22
26
  /**
23
27
  * ローカルLLMタブの claude に付ける引数。
24
28
  * - モデルは Settings でだけ切り替える: /model の一覧を中継口の名前1件にし、PreModelSwitch フックで
@@ -28,10 +32,9 @@ function shellQuote(value) {
28
32
  * 毎回プロンプトに載る(prefill が遅くなり tool 選択の精度も落ちる)。そのため MCP を tsunagi の
29
33
  * タスク管理と web_search だけに絞る
30
34
  */
31
- async function localLlmArgs() {
32
- const { active, webSearch } = await getLocalLlmSettings();
35
+ function localLlmArgs(provider, webSearch) {
33
36
  // ollama.com による WebSearch の代行は、使用中のモデルが Ollama のときだけ使える
34
- const localWebSearch = webSearch === 'local' || active?.provider !== 'ollama';
37
+ const localWebSearch = webSearch === 'local' || provider !== 'ollama';
35
38
  const settings = {
36
39
  modelPicker: {
37
40
  options: [
@@ -80,27 +83,56 @@ async function localLlmArgs() {
80
83
  return args;
81
84
  }
82
85
  /**
83
- * ローカルLLMタブの claude を起動するスクリプトを書き出し、そのパスを返す。
86
+ * タスク進捗(プログレスバー)の元になる Task 系ツール。
87
+ * Claude Code は旧モデル以外ではこれらを既定で無効にしており、--allowedTools で明示すると有効になる
88
+ */
89
+ const TASK_TOOLS = 'TaskCreate,TaskGet,TaskUpdate,TaskList';
90
+ /**
91
+ * claude の起動スクリプトを書き出し、そのパスを返す。`sh <script> <type> <session-id>` で起動し、
92
+ * 既存セッションがあれば resume、なければ新規作成する。
84
93
  * 起動コマンドは PTY に1行で書き込むため、macOS の端末入力の1行上限(MAX_CANON = 1024 バイト)を
85
94
  * 超えると後ろが切り捨てられる。引数の JSON をコマンドに直接載せると上限を超え、
86
95
  * `|| claude ... --session-id` 側が途中で切れてタブと別のセッションIDで起動してしまう
87
- * (hooks がタブに紐づかず、--settings / --mcp-config も効かない)。そのため引数はスクリプトに閉じ込める
96
+ * (hooks がタブに紐づかず、--settings / --mcp-config も効かない)。そのため引数はスクリプトに閉じ込める。
97
+ * ローカルLLMの引数は Settings に依存するため起動のたびに書き直す
88
98
  */
89
- async function writeLocalLlmLauncher(args) {
90
- const dir = path.join(getStateDir(), 'local-llm');
91
- await mkdir(dir, { recursive: true });
92
- const file = path.join(dir, 'claude.sh');
99
+ async function writeLauncher() {
100
+ const { webSearch } = await getLocalLlmSettings();
101
+ const branches = Object.keys(LAUNCH_TYPE_PROVIDER)
102
+ .map((type) => ` ${type}) set -- ${localLlmArgs(LAUNCH_TYPE_PROVIDER[type], webSearch).join(' ')} ;;`)
103
+ .join('\n');
104
+ const script = `#!/bin/sh
105
+ # usage: launch-claude.sh <original|ollama|lm-studio> <session-id>
106
+ type=$1
107
+ session_id=$2
108
+ case "$type" in
109
+ original) set -- ;;
110
+ ${branches}
111
+ *) echo "launch-claude.sh: unknown type: $type" >&2; exit 2 ;;
112
+ esac
113
+ claude --dangerously-skip-permissions --allowedTools ${TASK_TOOLS} "$@" --resume "$session_id" 2>/dev/null ||
114
+ exec claude --dangerously-skip-permissions --allowedTools ${TASK_TOOLS} "$@" --session-id "$session_id"
115
+ `;
116
+ const file = path.join(getStateDir(), 'launch-claude.sh');
117
+ await mkdir(path.dirname(file), { recursive: true });
93
118
  // 複数タブの同時起動で書きかけのファイルを読まないよう、書き終えてから置き換える
94
119
  const tmp = `${file}.${randomUUID()}.tmp`;
95
- await writeFile(tmp, `#!/bin/sh\nexec claude ${args.join(' ')} "$@"\n`);
120
+ await writeFile(tmp, script);
96
121
  await rename(tmp, file);
97
122
  return file;
98
123
  }
124
+ /** タブの mode から起動スクリプトの type を決める。ローカルLLMは Settings で選んだ使用中のモデルに従う */
125
+ async function resolveLaunchType(mode) {
126
+ if (!isLocalLlmMode(mode))
127
+ return 'original';
128
+ const { active } = await getLocalLlmSettings();
129
+ if (!active) {
130
+ throw new Error('ローカルLLMのモデルが未設定です。Settings で使用するモデルを選んでください');
131
+ }
132
+ return active.provider === 'ollama' ? 'ollama' : 'lm-studio';
133
+ }
99
134
  /** タブの mode に応じた claude の起動コマンド(既存セッションがあれば resume) */
100
135
  export async function buildClaudeCommand(sessionId, mode) {
101
- const args = ['--dangerously-skip-permissions'];
102
- const claude = isLocalLlmMode(mode)
103
- ? `sh ${shellQuote(await writeLocalLlmLauncher([...args, ...(await localLlmArgs())]))}`
104
- : `claude ${args.join(' ')}`;
105
- return `${claude} --resume ${sessionId} 2>/dev/null || ${claude} --session-id ${sessionId}`;
136
+ const type = await resolveLaunchType(mode);
137
+ return `sh ${shellQuote(await writeLauncher())} ${type} ${sessionId}`;
106
138
  }
@@ -76,6 +76,11 @@ export function buildLocalLlmEnv(params) {
76
76
  // 書き換えるため不要で、割り当てると `/model sonnet` が同じモデルへの切り替えとみなされ、
77
77
  // PreModelSwitch フックを通らずに ~/.claude/settings.json へ保存されてしまう
78
78
  CLAUDE_CODE_SUBAGENT_MODEL: LOCAL_MODEL_ALIAS,
79
+ // subagent が main や他の subagent と並列にリクエストすると、ローカル側の KV キャッシュが
80
+ // 溢れて毎ターン prefill し直しになり直列より遅くなる。フォアグラウンド(main は待機)で
81
+ // 同時に1つだけ実行させ、ローカルLLMへのリクエストを常に1本にする
82
+ CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1',
83
+ CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS: '1',
79
84
  // 未知のモデルは 200k 扱いになり auto-compact が効かないため、ローカル側の値に揃える
80
85
  CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(contextTokens),
81
86
  // リクエスト毎に変わる attribution がローカル側の KV キャッシュを無効化するため外す
@@ -9,6 +9,8 @@ export declare function parseSearxngSettings(body: unknown): {
9
9
  };
10
10
  /** searxng-run を探す(設定値 → PATH)。見つからなければ null */
11
11
  export declare function findSearxngBin(settings: SearxngSettings): string | null;
12
+ /** docker コマンドを探す(PATH → Docker Desktop / Homebrew の既定の場所)。見つからなければ null */
13
+ export declare function findDockerBin(): string | null;
12
14
  /** Ollama / LM Studio の有効状態に合わせて起動・停止する。失敗しても例外は投げない */
13
15
  export declare function syncSearxng(): Promise<void>;
14
16
  /** 手動での再起動(設定変更後やエラーからの復帰) */
@@ -1,4 +1,4 @@
1
- import { execFile, spawn } from 'node:child_process';
1
+ import { execFile, execFileSync, spawn } from 'node:child_process';
2
2
  import * as crypto from 'node:crypto';
3
3
  import * as fs from 'node:fs';
4
4
  import * as os from 'node:os';
@@ -7,9 +7,11 @@ import { readAppSetting, writeAppSetting } from './local-llm-env.js';
7
7
  import { getOllamaSettings } from './ollama-settings.js';
8
8
  import { getLmStudioSettings } from './lmstudio.js';
9
9
  /**
10
- * ローカル検索用の SearXNG をホスト上で1プロセスだけ管理する。
10
+ * ローカル検索用の SearXNG を1つだけ管理する。起動方法は2通り:
11
+ * - process: searxng-run を子プロセスとして起動する(インストールは利用者が行う)
12
+ * - docker: 公式イメージのコンテナを起動する(コンテナが無ければ作成する)
11
13
  * Ollama / LM Studio のどちらかが有効なら tsunagi 起動時・有効化時に自動起動し、
12
- * 両方無効化・tsunagi 終了時に止める。インストールは利用者が行う(searxng-run)。
14
+ * 両方無効化・tsunagi 終了時に止める。止めるのは tsunagi が起動したものだけ。
13
15
  * 失敗しても例外は外に出さず状態として記録するだけにし、tsunagi 本体の起動を妨げない。
14
16
  */
15
17
  const SETTING_KEY = 'searxng';
@@ -19,6 +21,21 @@ const SEARXNG_DIR = path.join(os.homedir(), '.tsunagi', 'searxng');
19
21
  const GENERATED_SETTINGS_PATH = path.join(SEARXNG_DIR, 'settings.yml');
20
22
  // 強制終了で残ったプロセスを次回起動時に片付けるための記録
21
23
  const PID_FILE = path.join(SEARXNG_DIR, 'searxng.pid');
24
+ // tsunagi が docker で起動したコンテナ名の記録(強制終了後の次回起動で引き継いで止められるように)
25
+ const DOCKER_MARKER_FILE = path.join(SEARXNG_DIR, 'docker-started');
26
+ const DEFAULT_DOCKER_CONTAINER = 'searxng';
27
+ const DOCKER_IMAGE = 'docker.io/searxng/searxng:latest';
28
+ // コンテナ内で SearXNG が待ち受けるポート
29
+ const DOCKER_CONTAINER_PORT = 8080;
30
+ // 初回はイメージ取得があるため長めに待つ
31
+ const DOCKER_PULL_TIMEOUT_MS = 10 * 60_000;
32
+ const DOCKER_COMMAND_TIMEOUT_MS = 60_000;
33
+ // PATH に無いときに探す場所(Docker Desktop / Homebrew)
34
+ const DOCKER_BIN_CANDIDATES = [
35
+ '/usr/local/bin/docker',
36
+ '/opt/homebrew/bin/docker',
37
+ '/Applications/Docker.app/Contents/Resources/bin/docker',
38
+ ];
22
39
  const STARTUP_TIMEOUT_MS = 60_000;
23
40
  const STDERR_TAIL_MAX_CHARS = 4000;
24
41
  // JSON 形式(API)の結果を返せるようにする。secret_key は起動時に SEARXNG_SECRET で渡す
@@ -34,7 +51,13 @@ search:
34
51
  - json
35
52
  `;
36
53
  export function defaultSearxngSettings() {
37
- return { port: DEFAULT_PORT, binPath: '', settingsPath: '' };
54
+ return {
55
+ port: DEFAULT_PORT,
56
+ method: 'auto',
57
+ binPath: '',
58
+ settingsPath: '',
59
+ dockerContainer: DEFAULT_DOCKER_CONTAINER,
60
+ };
38
61
  }
39
62
  export async function getSearxngSettings() {
40
63
  return readAppSetting(SETTING_KEY, defaultSearxngSettings());
@@ -50,22 +73,44 @@ export function parseSearxngSettings(body) {
50
73
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
51
74
  return { error: 'port must be an integer between 1 and 65535' };
52
75
  }
76
+ const method = b.method ?? 'auto';
77
+ if (method !== 'auto' && method !== 'process' && method !== 'docker') {
78
+ return { error: 'method must be auto, process or docker' };
79
+ }
53
80
  if (typeof b.binPath !== 'string')
54
81
  return { error: 'binPath must be string' };
55
82
  if (typeof b.settingsPath !== 'string')
56
83
  return { error: 'settingsPath must be string' };
84
+ const dockerContainer = typeof b.dockerContainer === 'string' && b.dockerContainer.trim()
85
+ ? b.dockerContainer.trim()
86
+ : DEFAULT_DOCKER_CONTAINER;
87
+ // docker のコンテナ名に使える文字(先頭は英数字)
88
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(dockerContainer)) {
89
+ return { error: 'dockerContainer must match [a-zA-Z0-9][a-zA-Z0-9_.-]*' };
90
+ }
57
91
  return {
58
- settings: { port, binPath: b.binPath.trim(), settingsPath: b.settingsPath.trim() },
92
+ settings: {
93
+ port,
94
+ method,
95
+ binPath: b.binPath.trim(),
96
+ settingsPath: b.settingsPath.trim(),
97
+ dockerContainer,
98
+ },
59
99
  };
60
100
  }
61
101
  let state = 'stopped';
62
102
  let lastError;
103
+ // 起動中の進捗(イメージ取得中など)
104
+ let progressMessage;
63
105
  let managedProcess = null;
106
+ // tsunagi が docker で起動したコンテナ名(止める対象)
107
+ let managedContainer = null;
64
108
  // 起動・停止の同時実行を直列化する
65
109
  let queue = Promise.resolve();
66
110
  function serialize(task) {
67
111
  queue = queue.then(task).catch((error) => {
68
112
  state = 'error';
113
+ progressMessage = undefined;
69
114
  lastError = error instanceof Error ? error.message : String(error);
70
115
  });
71
116
  return queue;
@@ -102,6 +147,57 @@ export function findSearxngBin(settings) {
102
147
  }
103
148
  return null;
104
149
  }
150
+ /** docker コマンドを探す(PATH → Docker Desktop / Homebrew の既定の場所)。見つからなければ null */
151
+ export function findDockerBin() {
152
+ for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
153
+ const candidate = path.join(dir, 'docker');
154
+ if (dir && isExecutable(candidate))
155
+ return candidate;
156
+ }
157
+ return DOCKER_BIN_CANDIDATES.find(isExecutable) ?? null;
158
+ }
159
+ function runDocker(bin, args, timeoutMs) {
160
+ return new Promise((resolve) => {
161
+ execFile(bin, args, { timeout: timeoutMs }, (error, stdout, stderr) => {
162
+ const code = error ? (typeof error.code === 'number' ? error.code : 1) : 0;
163
+ resolve({ code, stdout: stdout.trim(), stderr: (stderr || error?.message || '').trim() });
164
+ });
165
+ });
166
+ }
167
+ /** コンテナの状態。Docker が応答しなければ null */
168
+ async function inspectContainer(bin, name) {
169
+ const format = `{{.State.Running}} {{with index .HostConfig.PortBindings "${DOCKER_CONTAINER_PORT}/tcp"}}{{(index . 0).HostPort}}{{end}}`;
170
+ const result = await runDocker(bin, ['inspect', '--type', 'container', '--format', format, name], DOCKER_COMMAND_TIMEOUT_MS);
171
+ if (result.code === 0) {
172
+ const [running, hostPort] = result.stdout.split(' ');
173
+ return { exists: true, running: running === 'true', hostPort: hostPort || undefined };
174
+ }
175
+ if (/no such (container|object)/i.test(result.stderr))
176
+ return { exists: false, running: false };
177
+ return null;
178
+ }
179
+ // 状態表示(3秒ごとのポーリング)のたびに docker を叩かないよう短時間キャッシュする
180
+ const INSPECT_CACHE_MS = 5000;
181
+ let inspectCache = null;
182
+ async function inspectContainerCached(bin, name) {
183
+ const key = `${bin}:${name}`;
184
+ if (inspectCache && inspectCache.key === key && Date.now() - inspectCache.at < INSPECT_CACHE_MS) {
185
+ return inspectCache.info;
186
+ }
187
+ const info = await inspectContainer(bin, name);
188
+ inspectCache = { key, at: Date.now(), info };
189
+ return info;
190
+ }
191
+ /** 起動方法を決める(設定が auto なら searxng-run → Docker の順)。使えるものが無ければ null */
192
+ function resolveRunner(settings) {
193
+ if (settings.method === 'process')
194
+ return findSearxngBin(settings) ? 'process' : null;
195
+ if (settings.method === 'docker')
196
+ return findDockerBin() ? 'docker' : null;
197
+ if (findSearxngBin(settings))
198
+ return 'process';
199
+ return findDockerBin() ? 'docker' : null;
200
+ }
105
201
  /** Ollama / LM Studio のどちらかが有効か(= SearXNG を動かしておく必要があるか) */
106
202
  async function isRequired() {
107
203
  const [ollama, lmstudio] = await Promise.all([getOllamaSettings(), getLmStudioSettings()]);
@@ -145,15 +241,140 @@ async function killStaleProcess(settings) {
145
241
  function sleep(ms) {
146
242
  return new Promise((resolve) => setTimeout(resolve, ms));
147
243
  }
244
+ /**
245
+ * 前回 tsunagi が docker で起動したまま強制終了していれば、そのコンテナを引き継ぐ
246
+ * (tsunagi が起動したものとして扱い、停止・終了時に止める)
247
+ */
248
+ async function adoptStaleContainer() {
249
+ let name;
250
+ try {
251
+ name = fs.readFileSync(DOCKER_MARKER_FILE, 'utf8').trim();
252
+ }
253
+ catch {
254
+ return;
255
+ }
256
+ const bin = findDockerBin();
257
+ const info = bin && name ? await inspectContainer(bin, name) : null;
258
+ if (info?.running) {
259
+ managedContainer = name;
260
+ }
261
+ else {
262
+ fs.rmSync(DOCKER_MARKER_FILE, { force: true });
263
+ }
264
+ }
265
+ /** 応答するまで待つ。timeoutMs 以内に応答しなければ false */
266
+ async function waitHealthy(settings, timeoutMs) {
267
+ const startedAt = Date.now();
268
+ while (Date.now() - startedAt < timeoutMs) {
269
+ if (await isHealthy(settings))
270
+ return true;
271
+ await sleep(500);
272
+ }
273
+ return false;
274
+ }
275
+ /** 公式イメージでコンテナを作成して起動する。設定ファイルは ~/.tsunagi/searxng/docker/<name>/ に置く */
276
+ async function createContainer(bin, settings) {
277
+ const name = settings.dockerContainer;
278
+ const dir = path.join(SEARXNG_DIR, 'docker', name);
279
+ const configDir = path.join(dir, 'config');
280
+ const dataDir = path.join(dir, 'data');
281
+ fs.mkdirSync(configDir, { recursive: true });
282
+ fs.mkdirSync(dataDir, { recursive: true });
283
+ // 既にあれば利用者が編集したものとして残す(公式イメージは無ければ JSON 形式が無効な既定を生成する)
284
+ const configFile = path.join(configDir, 'settings.yml');
285
+ if (!fs.existsSync(configFile))
286
+ fs.writeFileSync(configFile, GENERATED_SETTINGS);
287
+ progressMessage = 'SearXNG のイメージを取得中';
288
+ const pulled = await runDocker(bin, ['pull', DOCKER_IMAGE], DOCKER_PULL_TIMEOUT_MS);
289
+ if (pulled.code !== 0) {
290
+ throw new Error(`SearXNG のイメージを取得できませんでした\n${pulled.stderr}`);
291
+ }
292
+ progressMessage = `コンテナ ${name} を作成中`;
293
+ const created = await runDocker(bin, [
294
+ 'run',
295
+ '-d',
296
+ '--name',
297
+ name,
298
+ '-p',
299
+ `127.0.0.1:${settings.port}:${DOCKER_CONTAINER_PORT}`,
300
+ '-v',
301
+ `${configDir}:/etc/searxng/`,
302
+ '-v',
303
+ `${dataDir}:/var/cache/searxng/`,
304
+ '-e',
305
+ `SEARXNG_SECRET=${crypto.randomBytes(32).toString('hex')}`,
306
+ DOCKER_IMAGE,
307
+ ], DOCKER_COMMAND_TIMEOUT_MS);
308
+ if (created.code !== 0) {
309
+ throw new Error(`コンテナ ${name} を作成できませんでした\n${created.stderr}`);
310
+ }
311
+ }
312
+ async function startDocker(settings) {
313
+ const bin = findDockerBin();
314
+ if (!bin) {
315
+ state = 'not-installed';
316
+ return;
317
+ }
318
+ const name = settings.dockerContainer;
319
+ const info = await inspectContainer(bin, name);
320
+ if (!info) {
321
+ throw new Error('Docker が応答しません。Docker Desktop が起動しているか確認してください');
322
+ }
323
+ if (info.exists && info.hostPort && info.hostPort !== String(settings.port)) {
324
+ throw new Error(`コンテナ ${name} はポート ${info.hostPort} で公開されています(設定は ${settings.port})。` +
325
+ '詳細設定のポートかコンテナ名を変更してください');
326
+ }
327
+ state = 'starting';
328
+ lastError = undefined;
329
+ if (!info.exists) {
330
+ await createContainer(bin, settings);
331
+ }
332
+ else if (!info.running) {
333
+ progressMessage = `コンテナ ${name} を起動中`;
334
+ const started = await runDocker(bin, ['start', name], DOCKER_COMMAND_TIMEOUT_MS);
335
+ if (started.code !== 0) {
336
+ throw new Error(`コンテナ ${name} を起動できませんでした\n${started.stderr}`);
337
+ }
338
+ }
339
+ // 既に動いていたコンテナは tsunagi 外で起動したものなので止めない
340
+ const startedByTsunagi = !info.running;
341
+ if (startedByTsunagi) {
342
+ managedContainer = name;
343
+ fs.mkdirSync(SEARXNG_DIR, { recursive: true });
344
+ fs.writeFileSync(DOCKER_MARKER_FILE, name);
345
+ }
346
+ inspectCache = null;
347
+ progressMessage = 'SearXNG の応答を待機中';
348
+ if (!(await waitHealthy(settings, STARTUP_TIMEOUT_MS))) {
349
+ const logs = await runDocker(bin, ['logs', '--tail', '30', name], DOCKER_COMMAND_TIMEOUT_MS);
350
+ throw new Error(`SearXNG が ${STARTUP_TIMEOUT_MS / 1000} 秒以内に応答しませんでした(コンテナ ${name})` +
351
+ `${logs.stdout || logs.stderr ? `\n${(logs.stdout + '\n' + logs.stderr).trim().slice(-STDERR_TAIL_MAX_CHARS)}` : ''}`);
352
+ }
353
+ progressMessage = undefined;
354
+ state = startedByTsunagi ? 'running' : 'external';
355
+ }
148
356
  async function start(settings) {
149
- if (managedProcess)
357
+ if (managedProcess || managedContainer)
150
358
  return;
151
359
  await killStaleProcess(settings);
360
+ await adoptStaleContainer();
152
361
  if (await isHealthy(settings)) {
153
- state = 'external';
362
+ state = managedContainer ? 'running' : 'external';
154
363
  lastError = undefined;
155
364
  return;
156
365
  }
366
+ // 引き継いだコンテナが応答しない場合は起動し直す
367
+ managedContainer = null;
368
+ const runner = resolveRunner(settings);
369
+ if (!runner) {
370
+ state = 'not-installed';
371
+ lastError = undefined;
372
+ return;
373
+ }
374
+ if (runner === 'docker') {
375
+ await startDocker(settings);
376
+ return;
377
+ }
157
378
  const bin = findSearxngBin(settings);
158
379
  if (!bin) {
159
380
  state = 'not-installed';
@@ -217,7 +438,8 @@ async function start(settings) {
217
438
  managedProcess = null;
218
439
  throw new Error(`SearXNG が ${STARTUP_TIMEOUT_MS / 1000} 秒以内に応答しませんでした`);
219
440
  }
220
- function stop() {
441
+ async function stop() {
442
+ progressMessage = undefined;
221
443
  if (managedProcess) {
222
444
  // exit ハンドラでエラー扱いしないよう先に状態を変える
223
445
  state = 'stopped';
@@ -225,6 +447,20 @@ function stop() {
225
447
  managedProcess = null;
226
448
  fs.rmSync(PID_FILE, { force: true });
227
449
  }
450
+ else if (managedContainer) {
451
+ const name = managedContainer;
452
+ const bin = findDockerBin();
453
+ managedContainer = null;
454
+ fs.rmSync(DOCKER_MARKER_FILE, { force: true });
455
+ inspectCache = null;
456
+ state = 'stopped';
457
+ if (bin) {
458
+ const result = await runDocker(bin, ['stop', name], DOCKER_COMMAND_TIMEOUT_MS);
459
+ if (result.code !== 0) {
460
+ throw new Error(`コンテナ ${name} を停止できませんでした\n${result.stderr}`);
461
+ }
462
+ }
463
+ }
228
464
  else if (state !== 'external') {
229
465
  state = 'stopped';
230
466
  }
@@ -237,28 +473,41 @@ export function syncSearxng() {
237
473
  await start(settings);
238
474
  }
239
475
  else {
240
- stop();
476
+ await stop();
241
477
  }
242
478
  });
243
479
  }
244
480
  /** 手動での再起動(設定変更後やエラーからの復帰) */
245
481
  export function restartSearxng() {
246
482
  return serialize(async () => {
247
- stop();
483
+ await stop();
248
484
  lastError = undefined;
249
485
  await start(await getSearxngSettings());
250
486
  });
251
487
  }
252
488
  export function stopSearxng() {
253
- return serialize(async () => stop());
489
+ return serialize(() => stop());
254
490
  }
255
491
  /** tsunagi 終了時に、自分が起動した SearXNG だけ止める('exit' でも呼べるよう同期処理) */
256
492
  export function stopSearxngOnExit() {
257
- if (!managedProcess)
258
- return;
259
- managedProcess.kill();
260
- managedProcess = null;
261
- fs.rmSync(PID_FILE, { force: true });
493
+ if (managedProcess) {
494
+ managedProcess.kill();
495
+ managedProcess = null;
496
+ fs.rmSync(PID_FILE, { force: true });
497
+ }
498
+ if (managedContainer) {
499
+ const name = managedContainer;
500
+ managedContainer = null;
501
+ const bin = findDockerBin();
502
+ try {
503
+ if (bin)
504
+ execFileSync(bin, ['stop', name], { timeout: 15_000, stdio: 'ignore' });
505
+ fs.rmSync(DOCKER_MARKER_FILE, { force: true });
506
+ }
507
+ catch {
508
+ // 止められなければ記録を残し、次回起動時に引き継ぐ
509
+ }
510
+ }
262
511
  }
263
512
  export async function getSearxngStatus() {
264
513
  const settings = await getSearxngSettings();
@@ -272,11 +521,26 @@ export async function getSearxngStatus() {
272
521
  current = 'stopped';
273
522
  state = current;
274
523
  }
524
+ // 表示する起動方法: tsunagi が起動したもの > 次に起動するときに使うもの
525
+ let runner = managedProcess
526
+ ? 'process'
527
+ : managedContainer
528
+ ? 'docker'
529
+ : resolveRunner(settings);
530
+ const dockerBin = findDockerBin();
531
+ const container = managedContainer ?? settings.dockerContainer;
532
+ const info = dockerBin ? await inspectContainerCached(dockerBin, container) : null;
533
+ // tsunagi 外で起動したもの: 対象コンテナが動いていれば Docker、それ以外は方法不明
534
+ if (current === 'external')
535
+ runner = info?.running ? 'docker' : null;
275
536
  return {
276
537
  state: current,
277
538
  url: baseUrl(settings),
278
539
  required,
279
540
  binPath: findSearxngBin(settings),
541
+ runner,
542
+ docker: { binPath: dockerBin, container, containerExists: info ? info.exists : null },
543
+ message: current === 'starting' ? progressMessage : undefined,
280
544
  error: current === 'error' ? lastError : undefined,
281
545
  };
282
546
  }
@@ -0,0 +1,12 @@
1
+ import type { Todo } from '@minimalcorp/tsunagi-shared';
2
+ /** todos を更新しうるツール */
3
+ export declare const TODO_TOOL_NAMES: ReadonlySet<string>;
4
+ /** DB の todos 列(JSON 文字列)を読む。壊れていれば空扱い */
5
+ export declare function parseTodos(raw: string | null | undefined): Todo[];
6
+ /**
7
+ * PostToolUse hook のツール入出力を現在の todos に適用する。
8
+ * 対象外のツール・解釈できない入出力なら null(todos は変更しない)。
9
+ * Claude Code 側の Task はセッションID単位で永続化され --resume 後も残るため、
10
+ * tsunagi 側も DB 上の todos を起点に差分を当てる(サーバー再起動でも進捗を失わない)
11
+ */
12
+ export declare function applyToolToTodos(current: Todo[], toolName: string | undefined, input: Record<string, unknown> | undefined, response: Record<string, unknown> | undefined): Todo[] | null;