@minimalcorp/tsunagi 0.2.0 → 0.3.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.
Files changed (137) 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.js.nft.json +1 -1
  12. package/.next/standalone/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  13. package/.next/standalone/apps/web/.next/server/app/_not-found.html +1 -1
  14. package/.next/standalone/apps/web/.next/server/app/_not-found.rsc +14 -14
  15. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  16. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  17. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_index.segment.rsc +9 -9
  18. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  19. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  20. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  21. package/.next/standalone/apps/web/.next/server/app/index.html +1 -1
  22. package/.next/standalone/apps/web/.next/server/app/index.rsc +16 -16
  23. package/.next/standalone/apps/web/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  24. package/.next/standalone/apps/web/.next/server/app/index.segments/_full.segment.rsc +16 -16
  25. package/.next/standalone/apps/web/.next/server/app/index.segments/_head.segment.rsc +4 -4
  26. package/.next/standalone/apps/web/.next/server/app/index.segments/_index.segment.rsc +9 -9
  27. package/.next/standalone/apps/web/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  28. package/.next/standalone/apps/web/.next/server/app/page.js.nft.json +1 -1
  29. package/.next/standalone/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  30. package/.next/standalone/apps/web/.next/server/app/settings/page.js.nft.json +1 -1
  31. package/.next/standalone/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  32. package/.next/standalone/apps/web/.next/server/app/settings.html +1 -1
  33. package/.next/standalone/apps/web/.next/server/app/settings.rsc +17 -16
  34. package/.next/standalone/apps/web/.next/server/app/settings.segments/_full.segment.rsc +17 -16
  35. package/.next/standalone/apps/web/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  36. package/.next/standalone/apps/web/.next/server/app/settings.segments/_index.segment.rsc +9 -9
  37. package/.next/standalone/apps/web/.next/server/app/settings.segments/_tree.segment.rsc +3 -2
  38. package/.next/standalone/apps/web/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +5 -4
  39. package/.next/standalone/apps/web/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  40. package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
  41. package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__00qk65u._.js +3 -0
  43. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__02o_btn._.js +2 -2
  44. package/.next/standalone/apps/web/.next/server/chunks/ssr/_03vftq7._.js +7 -0
  45. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0835zmv._.js +14 -0
  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/_0g79jqo._.js +20 -0
  48. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0kflgfj._.js +20 -0
  49. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0w2dm3r._.js +3 -0
  50. package/.next/standalone/apps/web/.next/server/chunks/ssr/_10gk93.._.js +3 -0
  51. package/.next/standalone/apps/web/.next/server/chunks/ssr/{_053072t._.js → _139_l.u._.js} +2 -2
  52. package/.next/standalone/apps/web/.next/server/chunks/ssr/_13w5ytx._.js +7 -0
  53. package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_settings_page_tsx_0i6itlb._.js +2 -2
  54. package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_tasks_[id]_page_tsx_0jshsm2._.js +4 -21
  55. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0bfjoue._.js → node_modules_09d2oqj._.js} +1 -1
  56. package/.next/standalone/apps/web/.next/server/chunks/ssr/node_modules_0a57phw._.js +3 -0
  57. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0a9pqes._.js → node_modules_0ak_3rk._.js} +1 -1
  58. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0mpkxmv._.js → node_modules_0cv326~._.js} +1 -1
  59. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0zstt1y._.js → node_modules_0h.vgyn._.js} +1 -1
  60. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0~2ubpz._.js → node_modules_0npujym._.js} +1 -1
  61. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0_1re~9._.js → node_modules_0q_5qo~._.js} +1 -1
  62. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0e.hm5q._.js → node_modules_0sn0lqb._.js} +1 -1
  63. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0-g_j5j._.js → node_modules_11-z65a._.js} +1 -1
  64. package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +3 -3
  65. package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
  66. package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
  67. package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
  68. package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
  69. package/.next/standalone/apps/web/.next/static/chunks/0.gcmzq82~36i.js +1 -0
  70. package/.next/standalone/apps/web/.next/static/chunks/06gfh91..uydi.js +5 -0
  71. package/.next/standalone/apps/web/.next/static/chunks/08e.dbdz18nd2.js +18 -0
  72. package/.next/standalone/apps/web/.next/static/chunks/0b90ys7wq-p.4.css +4 -0
  73. package/.next/standalone/apps/web/.next/static/chunks/0crn03y2tj0lu.js +6 -0
  74. package/.next/standalone/apps/web/.next/static/chunks/{0s7g4uwmm49t..js → 0l83q95s~tih4.js} +2 -2
  75. package/.next/standalone/apps/web/.next/static/chunks/0lvl3ec93cab_.js +7 -0
  76. package/.next/standalone/apps/web/.next/static/chunks/0lxvbkhh7amha.js +1 -0
  77. package/.next/standalone/apps/web/.next/static/chunks/0mqupo29wme26.js +7 -0
  78. package/.next/standalone/apps/web/.next/static/chunks/0rn1kcmsfzjmz.js +12 -0
  79. package/.next/standalone/apps/web/.next/static/chunks/0whl1y16cvn5q.js +1 -0
  80. package/.next/standalone/apps/web/.next/static/chunks/0z60ewb40i2ce.js +1 -0
  81. package/.next/standalone/apps/web/.next/static/chunks/0~qk-51yuw4mm.js +18 -0
  82. package/.next/standalone/apps/web/.next/static/chunks/{0v3hfoggxwsov.js → 149sia28cskw9.js} +1 -1
  83. package/.next/standalone/apps/web/.next/static/chunks/17~93fal1fw_d.js +5 -0
  84. package/dist/prisma/migrations/20261007000000_add_claude_profiles/migration.sql +10 -0
  85. package/dist/prisma/schema.prisma +9 -0
  86. package/dist/server/generated/prisma/browser.d.ts +5 -0
  87. package/dist/server/generated/prisma/client.d.ts +5 -0
  88. package/dist/server/generated/prisma/internal/class.d.ts +11 -0
  89. package/dist/server/generated/prisma/internal/class.js +4 -4
  90. package/dist/server/generated/prisma/internal/prismaNamespace.d.ts +83 -1
  91. package/dist/server/generated/prisma/internal/prismaNamespace.js +6 -0
  92. package/dist/server/generated/prisma/internal/prismaNamespaceBrowser.d.ts +7 -0
  93. package/dist/server/generated/prisma/internal/prismaNamespaceBrowser.js +6 -0
  94. package/dist/server/generated/prisma/models/ClaudeProfile.d.ts +939 -0
  95. package/dist/server/generated/prisma/models/ClaudeProfile.js +1 -0
  96. package/dist/server/generated/prisma/models.d.ts +1 -0
  97. package/dist/server/index.js +17 -0
  98. package/dist/server/lib/claude-config-guard.d.ts +7 -5
  99. package/dist/server/lib/claude-config-guard.js +31 -12
  100. package/dist/server/lib/claude-profiles.d.ts +49 -0
  101. package/dist/server/lib/claude-profiles.js +168 -0
  102. package/dist/server/lib/pty-env-sync.d.ts +12 -0
  103. package/dist/server/lib/pty-env-sync.js +139 -0
  104. package/dist/server/lib/pty-launch.d.ts +36 -0
  105. package/dist/server/lib/pty-launch.js +141 -0
  106. package/dist/server/lib/repositories/environment.js +8 -34
  107. package/dist/server/pty-manager.d.ts +23 -1
  108. package/dist/server/pty-manager.js +7 -1
  109. package/dist/server/routes/claude-profiles.d.ts +2 -0
  110. package/dist/server/routes/claude-profiles.js +138 -0
  111. package/dist/server/routes/env.js +25 -1
  112. package/dist/server/routes/onboarding.js +15 -6
  113. package/dist/server/routes/terminal.js +34 -120
  114. package/npm-shrinkwrap.json +2 -2
  115. package/package.json +1 -1
  116. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__03~.ax1._.js +0 -3
  117. package/.next/standalone/apps/web/.next/server/chunks/ssr/_07i72iz._.js +0 -3
  118. package/.next/standalone/apps/web/.next/server/chunks/ssr/_09eooml._.js +0 -7
  119. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0bru-z.._.js +0 -3
  120. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0i8ojdw._.js +0 -14
  121. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0mtw02l._.js +0 -3
  122. package/.next/standalone/apps/web/.next/server/chunks/ssr/_109pmo2._.js +0 -3
  123. package/.next/standalone/apps/web/.next/server/chunks/ssr/_12aa-fs._.js +0 -7
  124. package/.next/standalone/apps/web/.next/static/chunks/0.qz1zt1c.y.n.js +0 -5
  125. package/.next/standalone/apps/web/.next/static/chunks/01mfp5lo0gy.q.js +0 -23
  126. package/.next/standalone/apps/web/.next/static/chunks/0_by0zpp68~r7.js +0 -1
  127. package/.next/standalone/apps/web/.next/static/chunks/0ajw1mjy3owa4.js +0 -1
  128. package/.next/standalone/apps/web/.next/static/chunks/0fe4v5dh3whem.js +0 -5
  129. package/.next/standalone/apps/web/.next/static/chunks/0gzt~9euv6u2y.js +0 -12
  130. package/.next/standalone/apps/web/.next/static/chunks/0jcorj3-aambm.js +0 -1
  131. package/.next/standalone/apps/web/.next/static/chunks/0wnto9g4i0s3h.css +0 -4
  132. package/.next/standalone/apps/web/.next/static/chunks/0wzu~340lh7o~.js +0 -7
  133. package/.next/standalone/apps/web/.next/static/chunks/0xa_n_zp.qw-b.js +0 -1
  134. package/.next/standalone/apps/web/.next/static/chunks/0~_c.mwboavrv.js +0 -7
  135. /package/.next/standalone/apps/web/.next/static/{OXWK1FxsZh6lcEQawkL6h → Qv73X7lHL0PkQ5DtK6UEF}/_buildManifest.js +0 -0
  136. /package/.next/standalone/apps/web/.next/static/{OXWK1FxsZh6lcEQawkL6h → Qv73X7lHL0PkQ5DtK6UEF}/_clientMiddlewareManifest.js +0 -0
  137. /package/.next/standalone/apps/web/.next/static/{OXWK1FxsZh6lcEQawkL6h → Qv73X7lHL0PkQ5DtK6UEF}/_ssgManifest.js +0 -0
@@ -2,5 +2,6 @@ export type * from './models/Repository.js';
2
2
  export type * from './models/Task.js';
3
3
  export type * from './models/Tab.js';
4
4
  export type * from './models/EnvironmentVariable.js';
5
+ export type * from './models/ClaudeProfile.js';
5
6
  export type * from './models/AppSetting.js';
6
7
  export type * from './commonInputTypes.js';
@@ -10,6 +10,7 @@ import { envRoutes } from './routes/env.js';
10
10
  import { worktreesRoutes } from './routes/worktrees.js';
11
11
  import { commandsRoutes } from './routes/commands.js';
12
12
  import { onboardingRoutes } from './routes/onboarding.js';
13
+ import { claudeProfilesRoutes } from './routes/claude-profiles.js';
13
14
  import { internalRoutes } from './routes/internal.js';
14
15
  import { hooksRoutes } from './routes/hooks.js';
15
16
  import { mcpRoutes } from './routes/mcp.js';
@@ -45,8 +46,11 @@ const extraOrigins = (process.env.TSUNAGI_EXTRA_CORS_ORIGINS ?? '')
45
46
  const corsOrigins = [`http://localhost:${NEXT_PORT}`, ...extraOrigins];
46
47
  // TSUNAGI_BASIC_AUTH_USER / TSUNAGI_BASIC_AUTH_PASSWORD が両方ある時だけ有効。
47
48
  const basicAuth = createBasicAuth();
49
+ const SHUTDOWN_TIMEOUT_MS = 20_000;
48
50
  async function start() {
49
51
  const fastify = Fastify({
52
+ // 終了時に処理中のリクエスト(プロキシ中・ロングポーリング等)も閉じ、close() が待ち続けないようにする
53
+ forceCloseConnections: true,
50
54
  logger: {
51
55
  level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'error' : 'info'),
52
56
  },
@@ -93,6 +97,12 @@ async function start() {
93
97
  pingTimeout: 15000,
94
98
  });
95
99
  fastify.decorate('io', io);
100
+ // fastify.close() は upgrade 済みの WebSocket を閉じず、engine.io は http server の close を待って
101
+ // 閉じるため、互いに待ち合って close() が返らない(ブラウザが開いていると再起動が止まる)。先に閉じる
102
+ fastify.addHook('preClose', (done) => {
103
+ io.close();
104
+ done();
105
+ });
96
106
  fastify.get('/health', async () => ({ status: 'ok' }));
97
107
  await fastify.register(tasksRoutes, { prefix: '/api' });
98
108
  await fastify.register(reposRoutes, { prefix: '/api' });
@@ -100,6 +110,7 @@ async function start() {
100
110
  await fastify.register(worktreesRoutes, { prefix: '/api' });
101
111
  await fastify.register(commandsRoutes, { prefix: '/api' });
102
112
  await fastify.register(onboardingRoutes, { prefix: '/api' });
113
+ await fastify.register(claudeProfilesRoutes, { prefix: '/api' });
103
114
  await fastify.register(internalRoutes, { prefix: '/api' });
104
115
  await fastify.register(hooksRoutes, { prefix: '/api' });
105
116
  await fastify.register(mcpRoutes, { prefix: '/api' });
@@ -171,6 +182,12 @@ async function start() {
171
182
  return;
172
183
  shuttingDown = true;
173
184
  console.log(`[server] Received ${signal}, shutting down...`);
185
+ // 終了処理がどこかで止まっても終了する。restart は apps/cli の app 側に強制終了がないため必須。
186
+ // unloadOnShutdown の上限(10秒)より長く、app の CHILD_EXIT_TIMEOUT_MS(30秒)より短くする
187
+ setTimeout(() => {
188
+ console.error(`[server] Shutdown timed out after ${SHUTDOWN_TIMEOUT_MS}ms, exiting`);
189
+ process.exit(exitCode);
190
+ }, SHUTDOWN_TIMEOUT_MS).unref();
174
191
  stopUpdateCheck();
175
192
  stopAutoUpdate();
176
193
  // PTY(claude を含む)は SIGHUP 任せにせず、ここで止める
@@ -5,17 +5,19 @@
5
5
  * どこか1セッションで logout すると以降全タブでオンボーディングウィザード
6
6
  * (対話UIではなく初回セットアップ画面)が起動してしまい、--resume/--session-id
7
7
  * を前提にした自動起動フローが止まる。claude 起動直前にこのフラグだけ補正する。
8
+ * configDir はタブが使う Claude プロファイルの CLAUDE_CONFIG_DIR(未指定は ~/.claude.json)。
8
9
  */
9
- export declare function ensureClaudeOnboardingCompleted(): Promise<void>;
10
+ export declare function ensureClaudeOnboardingCompleted(configDir?: string): Promise<void>;
10
11
  /**
11
12
  * ローカルLLMタブで /model の一覧から「ローカルLLM」を Enter で選ぶと、Claude Code は同じモデルへの
12
13
  * 切り替えとみなして PreModelSwitch フックを通さずに ~/.claude/settings.json の model に
13
14
  * 中継口の名前(localModelAlias)を保存する。そのままだと通常の Claude タブがその名前で起動して
14
- * 壊れるため取り除く。
15
+ * 壊れるため取り除く。configDir はタブが使う Claude プロファイルの CLAUDE_CONFIG_DIR。
15
16
  */
16
- export declare function removeLocalModelFromUserSettings(localModelAlias: string): Promise<void>;
17
+ export declare function removeLocalModelFromUserSettings(localModelAlias: string, configDir?: string): Promise<void>;
17
18
  /**
18
- * ~/.claude/settings.json の変更を監視し、中継口の名前が保存されたらすぐ取り除く。
19
+ * settings.json の変更を監視し、中継口の名前が保存されたらすぐ取り除く。
19
20
  * Claude Code は tmp ファイル経由で置き換えることがあるため、ファイルではなくディレクトリを監視する。
21
+ * configDir は Claude プロファイルの CLAUDE_CONFIG_DIR(未指定は外側 Terminal の設定 or ~/.claude)。
20
22
  */
21
- export declare function watchUserSettingsForLocalModel(localModelAlias: string): void;
23
+ export declare function watchUserSettingsForLocalModel(localModelAlias: string, configDir?: string): void;
@@ -2,6 +2,13 @@ import { watch } from 'fs';
2
2
  import * as fs from 'fs/promises';
3
3
  import * as os from 'os';
4
4
  import * as path from 'path';
5
+ /** Claude Code の設定ファイルの場所。CLAUDE_CONFIG_DIR 指定時は .claude.json もその中に置かれる */
6
+ function claudeJsonPath(configDir) {
7
+ return configDir ? path.join(configDir, '.claude.json') : path.join(os.homedir(), '.claude.json');
8
+ }
9
+ function userSettingsDir(configDir) {
10
+ return configDir ?? path.join(os.homedir(), '.claude');
11
+ }
5
12
  /**
6
13
  * `claude logout` は ~/.claude.json の hasCompletedOnboarding を含む
7
14
  * first-launch setup state をリセットする。Tsunagi は全タブ/タスクの
@@ -9,9 +16,10 @@ import * as path from 'path';
9
16
  * どこか1セッションで logout すると以降全タブでオンボーディングウィザード
10
17
  * (対話UIではなく初回セットアップ画面)が起動してしまい、--resume/--session-id
11
18
  * を前提にした自動起動フローが止まる。claude 起動直前にこのフラグだけ補正する。
19
+ * configDir はタブが使う Claude プロファイルの CLAUDE_CONFIG_DIR(未指定は ~/.claude.json)。
12
20
  */
13
- export async function ensureClaudeOnboardingCompleted() {
14
- const configPath = path.join(os.homedir(), '.claude.json');
21
+ export async function ensureClaudeOnboardingCompleted(configDir) {
22
+ const configPath = claudeJsonPath(configDir);
15
23
  let raw;
16
24
  try {
17
25
  raw = await fs.readFile(configPath, 'utf-8');
@@ -42,16 +50,17 @@ export async function ensureClaudeOnboardingCompleted() {
42
50
  await fs.rm(tmpPath, { force: true }).catch(() => { });
43
51
  }
44
52
  }
45
- const USER_SETTINGS_DIR = path.join(os.homedir(), '.claude');
46
53
  const USER_SETTINGS_FILE = 'settings.json';
54
+ /** 監視中の設定ディレクトリ(同じディレクトリを二重に監視しない) */
55
+ const watchedSettingsDirs = new Set();
47
56
  /**
48
57
  * ローカルLLMタブで /model の一覧から「ローカルLLM」を Enter で選ぶと、Claude Code は同じモデルへの
49
58
  * 切り替えとみなして PreModelSwitch フックを通さずに ~/.claude/settings.json の model に
50
59
  * 中継口の名前(localModelAlias)を保存する。そのままだと通常の Claude タブがその名前で起動して
51
- * 壊れるため取り除く。
60
+ * 壊れるため取り除く。configDir はタブが使う Claude プロファイルの CLAUDE_CONFIG_DIR。
52
61
  */
53
- export async function removeLocalModelFromUserSettings(localModelAlias) {
54
- const settingsPath = path.join(USER_SETTINGS_DIR, USER_SETTINGS_FILE);
62
+ export async function removeLocalModelFromUserSettings(localModelAlias, configDir) {
63
+ const settingsPath = path.join(userSettingsDir(configDir), USER_SETTINGS_FILE);
55
64
  let settings;
56
65
  try {
57
66
  settings = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
@@ -72,21 +81,31 @@ export async function removeLocalModelFromUserSettings(localModelAlias) {
72
81
  }
73
82
  }
74
83
  /**
75
- * ~/.claude/settings.json の変更を監視し、中継口の名前が保存されたらすぐ取り除く。
84
+ * settings.json の変更を監視し、中継口の名前が保存されたらすぐ取り除く。
76
85
  * Claude Code は tmp ファイル経由で置き換えることがあるため、ファイルではなくディレクトリを監視する。
86
+ * configDir は Claude プロファイルの CLAUDE_CONFIG_DIR(未指定は外側 Terminal の設定 or ~/.claude)。
77
87
  */
78
- export function watchUserSettingsForLocalModel(localModelAlias) {
88
+ export function watchUserSettingsForLocalModel(localModelAlias, configDir) {
89
+ const effectiveDir = configDir ?? process.env.CLAUDE_CONFIG_DIR;
90
+ const dir = userSettingsDir(effectiveDir);
91
+ if (watchedSettingsDirs.has(dir))
92
+ return;
79
93
  let watcher;
80
94
  try {
81
- watcher = watch(USER_SETTINGS_DIR, (_event, filename) => {
82
- if (filename === USER_SETTINGS_FILE)
83
- void removeLocalModelFromUserSettings(localModelAlias);
95
+ watcher = watch(dir, (_event, filename) => {
96
+ if (filename === USER_SETTINGS_FILE) {
97
+ void removeLocalModelFromUserSettings(localModelAlias, effectiveDir);
98
+ }
84
99
  });
85
100
  }
86
101
  catch {
87
102
  // ~/.claude がまだない(claude 未実行)場合は監視しない。起動前の補正で対応する
88
103
  return;
89
104
  }
90
- watcher.on('error', () => watcher.close());
105
+ watchedSettingsDirs.add(dir);
106
+ watcher.on('error', () => {
107
+ watcher.close();
108
+ watchedSettingsDirs.delete(dir);
109
+ });
91
110
  watcher.unref();
92
111
  }
@@ -0,0 +1,49 @@
1
+ import type { ClaudeAuthStatus, ClaudeProfile } from '@minimalcorp/tsunagi-shared';
2
+ /**
3
+ * タブで使う Claude プロファイルの割り当てを保存する予約キー。値はプロファイルの slug か
4
+ * DEFAULT_CLAUDE_PROFILE。環境変数と同じ global → owner → repo の継承で解決し、PTY 起動時に
5
+ * CLAUDE_CONFIG_DIR へ変換する(resolveClaudeProfileEnv)。
6
+ */
7
+ export declare const CLAUDE_PROFILE_ENV_KEY = "TSUNAGI_CLAUDE_PROFILE";
8
+ /** システム既定(CLAUDE_CONFIG_DIR を付けず ~/.claude を使う) */
9
+ export declare const DEFAULT_CLAUDE_PROFILE = "default";
10
+ /**
11
+ * Claude Code が /login のログインより優先する認証情報の環境変数。
12
+ * プロファイルを選んだタブでは、外側 Terminal から継承した値も取り除く(残るとプロファイルの
13
+ * ログインではなくこちらが使われるため)。
14
+ */
15
+ export declare const CLAUDE_AUTH_ENV_KEYS: string[];
16
+ /** 汎用の環境変数として登録させないキー(認証は Claude プロファイルで扱う) */
17
+ export declare const RESERVED_ENV_KEYS: string[];
18
+ export declare function getClaudeProfilesDir(): string;
19
+ /** プロファイルの CLAUDE_CONFIG_DIR。システム既定は undefined(~/.claude) */
20
+ export declare function getClaudeProfileConfigDir(slug: string): string | undefined;
21
+ /** 空白を `-` にし、小文字にする(macOS のファイルシステムは大文字小文字を区別しないため) */
22
+ export declare function toClaudeProfileSlug(name: string): string;
23
+ /** プロファイル名の検証。問題があればエラーメッセージを返す */
24
+ export declare function validateClaudeProfileName(name: string): string | null;
25
+ export declare function listClaudeProfiles(): Promise<ClaudeProfile[]>;
26
+ export declare function claudeProfileExists(slug: string): Promise<boolean>;
27
+ export declare function createClaudeProfile(name: string): Promise<ClaudeProfile>;
28
+ /**
29
+ * プロファイルを削除する。ログイン情報(macOS は Keychain)を残さないよう先に logout し、
30
+ * このプロファイルを割り当てていたスコープは割り当てを外して親の設定を継承させる。
31
+ */
32
+ export declare function deleteClaudeProfile(slug: string): Promise<void>;
33
+ /** `claude auth status --json` でプロファイルのログイン状態を取得する */
34
+ export declare function getClaudeAuthStatus(slug: string): Promise<ClaudeAuthStatus>;
35
+ /** ログイン用 PTY に書き込むコマンド */
36
+ export declare function buildClaudeLoginCommand(): string;
37
+ /** ログイン用 PTY の環境変数(PtyManager.createSession の env / unsetKeys) */
38
+ export declare function claudeLoginPtyEnv(slug: string): {
39
+ env: Record<string, string>;
40
+ unsetKeys: string[];
41
+ };
42
+ /**
43
+ * 継承解決済みの環境変数に含まれるプロファイル割り当てを CLAUDE_CONFIG_DIR に変換する。
44
+ * 予約キー自体は PTY に渡さない。
45
+ */
46
+ export declare function resolveClaudeProfileEnv(env: Record<string, string>): {
47
+ env: Record<string, string>;
48
+ unsetKeys: string[];
49
+ };
@@ -0,0 +1,168 @@
1
+ import { execFile } from 'child_process';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ import { promisify } from 'util';
5
+ import { prisma } from './db.js';
6
+ import { getTsunagiDataDir } from './data-path.js';
7
+ const execFileAsync = promisify(execFile);
8
+ /**
9
+ * タブで使う Claude プロファイルの割り当てを保存する予約キー。値はプロファイルの slug か
10
+ * DEFAULT_CLAUDE_PROFILE。環境変数と同じ global → owner → repo の継承で解決し、PTY 起動時に
11
+ * CLAUDE_CONFIG_DIR へ変換する(resolveClaudeProfileEnv)。
12
+ */
13
+ export const CLAUDE_PROFILE_ENV_KEY = 'TSUNAGI_CLAUDE_PROFILE';
14
+ /** システム既定(CLAUDE_CONFIG_DIR を付けず ~/.claude を使う) */
15
+ export const DEFAULT_CLAUDE_PROFILE = 'default';
16
+ /**
17
+ * Claude Code が /login のログインより優先する認証情報の環境変数。
18
+ * プロファイルを選んだタブでは、外側 Terminal から継承した値も取り除く(残るとプロファイルの
19
+ * ログインではなくこちらが使われるため)。
20
+ */
21
+ export const CLAUDE_AUTH_ENV_KEYS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'];
22
+ /** 汎用の環境変数として登録させないキー(認証は Claude プロファイルで扱う) */
23
+ export const RESERVED_ENV_KEYS = [
24
+ CLAUDE_PROFILE_ENV_KEY,
25
+ 'CLAUDE_CONFIG_DIR',
26
+ ...CLAUDE_AUTH_ENV_KEYS,
27
+ ];
28
+ const PROFILE_NAME_PATTERN = /^[A-Za-z0-9 _-]+$/;
29
+ export function getClaudeProfilesDir() {
30
+ return path.join(getTsunagiDataDir(), 'claude-profiles');
31
+ }
32
+ /** プロファイルの CLAUDE_CONFIG_DIR。システム既定は undefined(~/.claude) */
33
+ export function getClaudeProfileConfigDir(slug) {
34
+ if (slug === DEFAULT_CLAUDE_PROFILE)
35
+ return undefined;
36
+ return path.join(getClaudeProfilesDir(), slug);
37
+ }
38
+ /** 空白を `-` にし、小文字にする(macOS のファイルシステムは大文字小文字を区別しないため) */
39
+ export function toClaudeProfileSlug(name) {
40
+ return name.trim().replace(/\s+/g, '-').toLowerCase();
41
+ }
42
+ /** プロファイル名の検証。問題があればエラーメッセージを返す */
43
+ export function validateClaudeProfileName(name) {
44
+ const trimmed = name.trim();
45
+ if (!trimmed)
46
+ return 'Name is required';
47
+ if (!PROFILE_NAME_PATTERN.test(trimmed)) {
48
+ return 'Use only letters, numbers, spaces, "-" and "_"';
49
+ }
50
+ if (toClaudeProfileSlug(trimmed) === DEFAULT_CLAUDE_PROFILE) {
51
+ return `"${DEFAULT_CLAUDE_PROFILE}" is reserved`;
52
+ }
53
+ return null;
54
+ }
55
+ export async function listClaudeProfiles() {
56
+ const rows = await prisma.claudeProfile.findMany({ orderBy: { createdAt: 'asc' } });
57
+ return rows.map((row) => ({
58
+ slug: row.slug,
59
+ name: row.name,
60
+ configDir: path.join(getClaudeProfilesDir(), row.slug),
61
+ }));
62
+ }
63
+ export async function claudeProfileExists(slug) {
64
+ if (slug === DEFAULT_CLAUDE_PROFILE)
65
+ return true;
66
+ return (await prisma.claudeProfile.count({ where: { slug } })) > 0;
67
+ }
68
+ export async function createClaudeProfile(name) {
69
+ const error = validateClaudeProfileName(name);
70
+ if (error)
71
+ throw new Error(error);
72
+ const trimmed = name.trim();
73
+ const slug = toClaudeProfileSlug(trimmed);
74
+ if (await claudeProfileExists(slug)) {
75
+ throw new Error(`Profile "${slug}" already exists`);
76
+ }
77
+ const configDir = path.join(getClaudeProfilesDir(), slug);
78
+ await fs.mkdir(configDir, { recursive: true });
79
+ await prisma.claudeProfile.create({ data: { slug, name: trimmed } });
80
+ return { slug, name: trimmed, configDir };
81
+ }
82
+ /**
83
+ * プロファイルを削除する。ログイン情報(macOS は Keychain)を残さないよう先に logout し、
84
+ * このプロファイルを割り当てていたスコープは割り当てを外して親の設定を継承させる。
85
+ */
86
+ export async function deleteClaudeProfile(slug) {
87
+ const configDir = getClaudeProfileConfigDir(slug);
88
+ if (!configDir)
89
+ throw new Error('The system default profile cannot be deleted');
90
+ await runClaude(['auth', 'logout'], configDir).catch(() => undefined);
91
+ await prisma.environmentVariable.deleteMany({
92
+ where: { key: CLAUDE_PROFILE_ENV_KEY, value: slug },
93
+ });
94
+ await prisma.claudeProfile.delete({ where: { slug } });
95
+ await fs.rm(configDir, { recursive: true, force: true });
96
+ }
97
+ /**
98
+ * claude を実行するときの環境変数。プロファイルのログインを見るよう CLAUDE_CONFIG_DIR を付け、
99
+ * ログインより優先される認証情報の環境変数は取り除く。システム既定は外側 Terminal の環境のまま
100
+ * (普段ターミナルで claude を使うときと同じ)。
101
+ */
102
+ function claudeEnv(configDir) {
103
+ const env = { ...process.env };
104
+ if (configDir) {
105
+ env.CLAUDE_CONFIG_DIR = configDir;
106
+ for (const key of CLAUDE_AUTH_ENV_KEYS)
107
+ delete env[key];
108
+ }
109
+ return env;
110
+ }
111
+ async function runClaude(args, configDir) {
112
+ const { stdout } = await execFileAsync('claude', args, {
113
+ env: claudeEnv(configDir),
114
+ timeout: 15_000,
115
+ });
116
+ return stdout;
117
+ }
118
+ /** `claude auth status --json` でプロファイルのログイン状態を取得する */
119
+ export async function getClaudeAuthStatus(slug) {
120
+ try {
121
+ const raw = JSON.parse(await runClaude(['auth', 'status', '--json'], getClaudeProfileConfigDir(slug)));
122
+ const str = (v) => (typeof v === 'string' ? v : undefined);
123
+ return {
124
+ loggedIn: raw.loggedIn === true,
125
+ authMethod: str(raw.authMethod),
126
+ email: str(raw.email),
127
+ orgName: str(raw.orgName),
128
+ subscriptionType: str(raw.subscriptionType),
129
+ };
130
+ }
131
+ catch (err) {
132
+ // claude auth status は未ログイン時に非 0 で終了するが、stdout には JSON が出る
133
+ const stdout = err.stdout;
134
+ if (stdout) {
135
+ try {
136
+ const raw = JSON.parse(stdout);
137
+ return { loggedIn: raw.loggedIn === true, authMethod: raw.authMethod };
138
+ }
139
+ catch {
140
+ // fallthrough
141
+ }
142
+ }
143
+ return { loggedIn: false, error: err instanceof Error ? err.message : String(err) };
144
+ }
145
+ }
146
+ /** ログイン用 PTY に書き込むコマンド */
147
+ export function buildClaudeLoginCommand() {
148
+ return 'claude auth login';
149
+ }
150
+ /** ログイン用 PTY の環境変数(PtyManager.createSession の env / unsetKeys) */
151
+ export function claudeLoginPtyEnv(slug) {
152
+ const configDir = getClaudeProfileConfigDir(slug);
153
+ if (!configDir)
154
+ return { env: {}, unsetKeys: [] };
155
+ return { env: { CLAUDE_CONFIG_DIR: configDir }, unsetKeys: CLAUDE_AUTH_ENV_KEYS };
156
+ }
157
+ /**
158
+ * 継承解決済みの環境変数に含まれるプロファイル割り当てを CLAUDE_CONFIG_DIR に変換する。
159
+ * 予約キー自体は PTY に渡さない。
160
+ */
161
+ export function resolveClaudeProfileEnv(env) {
162
+ const { [CLAUDE_PROFILE_ENV_KEY]: slug, ...rest } = env;
163
+ const configDir = slug ? getClaudeProfileConfigDir(slug) : undefined;
164
+ // システム既定: 外側 Terminal の環境のまま(普段ターミナルで claude を使うときと同じ)
165
+ if (!configDir)
166
+ return { env: rest, unsetKeys: [] };
167
+ return { env: { ...rest, CLAUDE_CONFIG_DIR: configDir }, unsetKeys: CLAUDE_AUTH_ENV_KEYS };
168
+ }
@@ -0,0 +1,12 @@
1
+ import type { Server as SocketIOServer } from 'socket.io';
2
+ /** 起動時に一度呼ぶ。反映待ちの PTY を定期的に確認する */
3
+ export declare function initPtyEnvSync(server: SocketIOServer): void;
4
+ /**
5
+ * tsunagi の環境変数(Claude プロファイルの割り当て・プロファイル削除を含む)を変更した後に呼ぶ。
6
+ * 起動中の PTY ごとに環境変数を解決し直し、作成時と違えば反映待ちにする。
7
+ */
8
+ export declare function notifyEnvChanged(): Promise<void>;
9
+ /** 反映待ちの PTY を、ユーザー操作で今すぐ作り直す */
10
+ export declare function respawnSessionNow(sessionId: string): Promise<boolean>;
11
+ /** join したクライアントに現在の反映待ち状態を伝える */
12
+ export declare function getEnvPending(sessionId: string): boolean;
@@ -0,0 +1,139 @@
1
+ import { execFile } from 'child_process';
2
+ import { ptyManager } from '../pty-manager.js';
3
+ import { prisma } from './db.js';
4
+ import { resolvePtyLaunch, startPtySession } from './pty-launch.js';
5
+ /**
6
+ * tsunagi の環境変数(Claude プロファイルの割り当てを含む)の変更を、起動中の PTY に反映する。
7
+ *
8
+ * 動いているプロセスの環境変数は外から書き換えられないため、PTY を作り直して反映する
9
+ * (claude タブは --resume で同じ会話に戻る)。作業を壊さないよう、claude が応答中・許可待ちの間や
10
+ * シェルでコマンドを実行中の間、直近にユーザー入力があった間は作り直さず「反映待ち」にしておき、
11
+ * 安全になった時点で作り直す。
12
+ */
13
+ /** 直近の入力からこの時間が経つまでは作り直さない(入力中の文字を消さないため) */
14
+ const INPUT_IDLE_MS = 15_000;
15
+ /** 反映待ちの PTY を作り直せるか確認する間隔 */
16
+ const CHECK_INTERVAL_MS = 5_000;
17
+ /** claude がこの状態の間は作り直さない(hooks が Tab.status に記録する) */
18
+ const BUSY_TAB_STATUSES = new Set(['running', 'waiting']);
19
+ let io = null;
20
+ let checking = false;
21
+ function emitPending(session) {
22
+ io?.to(`tab:${session.sessionId}`).emit('env-pending', {
23
+ sessionId: session.sessionId,
24
+ pending: session.envPending,
25
+ });
26
+ }
27
+ /** 環境変数の比較用キー(キー順に依存しない) */
28
+ function launchKey(env, unsetKeys) {
29
+ const sorted = Object.keys(env)
30
+ .sort()
31
+ .map((key) => [key, env[key]]);
32
+ return JSON.stringify([sorted, [...unsetKeys].sort()]);
33
+ }
34
+ async function resolveFor(session) {
35
+ try {
36
+ return await resolvePtyLaunch(session.sessionId, session.options.request ?? {});
37
+ }
38
+ catch (err) {
39
+ console.warn(`[pty-env-sync] Failed to resolve env for ${session.sessionId}:`, err);
40
+ return null;
41
+ }
42
+ }
43
+ /** シェル配下でコマンドが動いているか(pgrep は該当なしのとき exit 1) */
44
+ function hasChildProcess(pid) {
45
+ return new Promise((resolve) => {
46
+ execFile('pgrep', ['-P', String(pid)], (err) => resolve(!err));
47
+ });
48
+ }
49
+ async function canRespawnNow(session) {
50
+ if (Date.now() - session.lastInputAt < INPUT_IDLE_MS)
51
+ return false;
52
+ const tab = await prisma.tab
53
+ .findUnique({ where: { tabId: session.sessionId }, select: { mode: true, status: true } })
54
+ .catch(() => null);
55
+ if (tab && tab.mode !== 'terminal')
56
+ return !BUSY_TAB_STATUSES.has(tab.status);
57
+ return !(await hasChildProcess(session.pty.pid));
58
+ }
59
+ /** PTY を最新の環境変数で作り直す。クライアントには pty-respawned で再接続させる */
60
+ async function respawn(session) {
61
+ const launch = await resolveFor(session);
62
+ if (!launch)
63
+ return;
64
+ const { sessionId } = session;
65
+ // 判定から作り直しまでの間に別経路で作り直し・削除されていれば何もしない
66
+ if (ptyManager.getSession(sessionId) !== session)
67
+ return;
68
+ session.respawning = true;
69
+ ptyManager.deleteSession(sessionId);
70
+ try {
71
+ await startPtySession(sessionId, launch, {
72
+ request: session.options.request ?? {},
73
+ launchClaude: session.options.launchClaude ?? false,
74
+ });
75
+ }
76
+ catch (err) {
77
+ console.error(`[pty-env-sync] Failed to respawn ${sessionId}:`, err);
78
+ io?.to(`tab:${sessionId}`).emit('exit', { exitCode: -1 });
79
+ return;
80
+ }
81
+ console.log(`[pty-env-sync] Respawned ${sessionId} with updated env`);
82
+ io?.to(`tab:${sessionId}`).emit('pty-respawned', { sessionId });
83
+ }
84
+ /** 反映待ちの PTY のうち、安全なものを作り直す */
85
+ async function respawnPendingSessions() {
86
+ if (checking)
87
+ return;
88
+ checking = true;
89
+ try {
90
+ for (const sessionId of ptyManager.listSessions()) {
91
+ const session = ptyManager.getSession(sessionId);
92
+ if (!session?.envPending || session.respawning)
93
+ continue;
94
+ if (await canRespawnNow(session))
95
+ await respawn(session);
96
+ }
97
+ }
98
+ finally {
99
+ checking = false;
100
+ }
101
+ }
102
+ /** 起動時に一度呼ぶ。反映待ちの PTY を定期的に確認する */
103
+ export function initPtyEnvSync(server) {
104
+ io = server;
105
+ setInterval(() => void respawnPendingSessions(), CHECK_INTERVAL_MS).unref();
106
+ }
107
+ /**
108
+ * tsunagi の環境変数(Claude プロファイルの割り当て・プロファイル削除を含む)を変更した後に呼ぶ。
109
+ * 起動中の PTY ごとに環境変数を解決し直し、作成時と違えば反映待ちにする。
110
+ */
111
+ export async function notifyEnvChanged() {
112
+ for (const sessionId of ptyManager.listSessions()) {
113
+ const session = ptyManager.getSession(sessionId);
114
+ if (!session?.options.syncEnv || session.respawning)
115
+ continue;
116
+ const launch = await resolveFor(session);
117
+ if (!launch)
118
+ continue;
119
+ const pending = launchKey(launch.env, launch.unsetKeys) !==
120
+ launchKey(session.appliedEnv, session.appliedUnsetKeys);
121
+ if (pending !== session.envPending) {
122
+ session.envPending = pending;
123
+ emitPending(session);
124
+ }
125
+ }
126
+ await respawnPendingSessions();
127
+ }
128
+ /** 反映待ちの PTY を、ユーザー操作で今すぐ作り直す */
129
+ export async function respawnSessionNow(sessionId) {
130
+ const session = ptyManager.getSession(sessionId);
131
+ if (!session || session.respawning)
132
+ return false;
133
+ await respawn(session);
134
+ return true;
135
+ }
136
+ /** join したクライアントに現在の反映待ち状態を伝える */
137
+ export function getEnvPending(sessionId) {
138
+ return ptyManager.getSession(sessionId)?.envPending ?? false;
139
+ }
@@ -0,0 +1,36 @@
1
+ import type { TabMode } from '@minimalcorp/tsunagi-shared';
2
+ import { type PtySession } from '../pty-manager.js';
3
+ /** PTY 起動時にクライアントが指定する値(環境変数の自動反映で作り直すときも同じ値を使う) */
4
+ export interface PtyLaunchRequest {
5
+ cwd?: string;
6
+ env?: Record<string, string>;
7
+ }
8
+ export interface PtyLaunch {
9
+ workingDir: string;
10
+ mode: TabMode | undefined;
11
+ /** タブ(Task)に紐づくか。紐づくタブは worktree が必須 */
12
+ hasTab: boolean;
13
+ env: Record<string, string>;
14
+ unsetKeys: string[];
15
+ }
16
+ export declare class PtyLaunchError extends Error {
17
+ readonly statusCode: number;
18
+ constructor(message: string, statusCode: number);
19
+ }
20
+ /**
21
+ * sessionId(=tab_id) から PTY の作業ディレクトリと環境変数を解決する。
22
+ * 環境変数の優先順位: リクエストで渡されたenv > ローカルLLMのprovider env >
23
+ * DB env(global/owner/repo、Claude プロファイルを CLAUDE_CONFIG_DIR に変換) > tsunagi独自のEDITOR設定
24
+ */
25
+ export declare function resolvePtyLaunch(sessionId: string, request: PtyLaunchRequest): Promise<PtyLaunch>;
26
+ /**
27
+ * 解決済みの launch で PTY を作り、claude を起動する。
28
+ * 新規作成(POST /terminal/sessions)と環境変数変更時の作り直し(pty-env-sync)で共用する。
29
+ */
30
+ export declare function startPtySession(sessionId: string, launch: PtyLaunch, options: {
31
+ request: PtyLaunchRequest;
32
+ launchClaude: boolean;
33
+ command?: string;
34
+ }): Promise<PtySession>;
35
+ /** 既存 PTY で claude を(再)起動する前の補正。PTY 作成時の環境変数のプロファイルを対象にする */
36
+ export declare function prepareClaudeConfigForSession(session: PtySession): Promise<void>;