@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
@@ -0,0 +1,141 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { ptyManager } from '../pty-manager.js';
6
+ import { prisma } from './db.js';
7
+ import { getWorktreePath } from './worktree-manager.js';
8
+ import { getEnv } from './repositories/environment.js';
9
+ import { ensureClaudeOnboardingCompleted, removeLocalModelFromUserSettings, watchUserSettingsForLocalModel, } from './claude-config-guard.js';
10
+ import { LOCAL_LLM_UNSET_ENV_KEYS, LOCAL_MODEL_ALIAS, buildLocalLlmEnv } from './local-llm-env.js';
11
+ import { ensureActiveLoaded, getLocalLlmSettings } from './local-llm.js';
12
+ import { LOCAL_LLM_PROXY_URL, buildClaudeCommand, isLocalLlmMode } from './claude-command.js';
13
+ import { resolveClaudeProfileEnv } from './claude-profiles.js';
14
+ // このファイル基準で解決する(cwd 非依存):
15
+ // dev: apps/server/src/lib → apps/server/scripts
16
+ // build: apps/server/dist/lib → apps/server/scripts
17
+ // npm bundle: <pkg>/dist/server/lib → <pkg>/dist/scripts
18
+ const TSUNAGI_EDITOR_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../scripts/monaco-editor.sh');
19
+ // Fastify(API) の公開ポート。index.ts と同じ既定値・同じ環境変数(TSUNAGI_SERVER_PORT)を見る。
20
+ // monaco-editor.sh など PTY 内のプロセスが API を叩く際の同一ホスト向けベース URL に使う。
21
+ const SERVER_PORT = Number(process.env.TSUNAGI_SERVER_PORT) || 2791;
22
+ export class PtyLaunchError extends Error {
23
+ statusCode;
24
+ constructor(message, statusCode) {
25
+ super(message);
26
+ this.statusCode = statusCode;
27
+ }
28
+ }
29
+ /**
30
+ * sessionId(=tab_id) から PTY の作業ディレクトリと環境変数を解決する。
31
+ * 環境変数の優先順位: リクエストで渡されたenv > ローカルLLMのprovider env >
32
+ * DB env(global/owner/repo、Claude プロファイルを CLAUDE_CONFIG_DIR に変換) > tsunagi独自のEDITOR設定
33
+ */
34
+ export async function resolvePtyLaunch(sessionId, request) {
35
+ // Taskに紐づかないタブは Tab レコードが存在しないため null になる。
36
+ const tab = await prisma.tab.findUnique({
37
+ where: { tabId: sessionId },
38
+ include: { task: true },
39
+ });
40
+ // cwd は Task があればサーバー側で worktree パスを導出する(クライアントの state に依存しない)。
41
+ // クライアント指定の cwd は Task に紐づかないタブのフォールバックとしてのみ使う。
42
+ const defaultDir = path.join(os.homedir(), '.tsunagi', 'workspaces');
43
+ let workingDir = tab
44
+ ? getWorktreePath(tab.task.owner, tab.task.repo, tab.task.branch)
45
+ : (request.cwd ?? defaultDir);
46
+ // cwd が存在するか確認。Task のタブは worktree 必須(無ければ起動しない)、
47
+ // それ以外はデフォルトにフォールバック
48
+ try {
49
+ await fs.access(workingDir);
50
+ }
51
+ catch {
52
+ if (tab)
53
+ throw new PtyLaunchError(`Worktree not found at ${workingDir}`, 409);
54
+ workingDir = defaultDir;
55
+ }
56
+ // repo スコープまで階層マージした環境変数を取得する(global → owner → repo、後勝ちで上書き)。
57
+ // Taskに紐づかないタブは global のみになる。
58
+ // 読み込みに失敗したら起動しない(空の環境変数で起動・作り直しすると設定が抜け落ちるため)
59
+ const dbEnv = tab ? await getEnv('repo', tab.task.owner, tab.task.repo) : await getEnv('global');
60
+ const profile = resolveClaudeProfileEnv(dbEnv);
61
+ // ローカルLLMタブ: Anthropic の代わりに tsunagi の中継口(→ Ollama / LM Studio)で claude を動かす。
62
+ // DB の環境変数より後に適用し、Anthropic の認証トークンは環境変数ごと取り除く。
63
+ const mode = tab?.mode;
64
+ let providerEnv = {};
65
+ let unsetKeys = profile.unsetKeys;
66
+ if (isLocalLlmMode(mode)) {
67
+ const { active, extraEnv } = await getLocalLlmSettings();
68
+ if (!active) {
69
+ throw new PtyLaunchError('ローカルLLMのモデルが未設定です。Settings で使用するモデルを選んでください', 400);
70
+ }
71
+ providerEnv = buildLocalLlmEnv({
72
+ proxyUrl: LOCAL_LLM_PROXY_URL,
73
+ contextTokens: active.contextTokens,
74
+ extraEnv,
75
+ });
76
+ unsetKeys = [...new Set([...unsetKeys, ...LOCAL_LLM_UNSET_ENV_KEYS])];
77
+ }
78
+ // tsunagi-editor.sh をデフォルトにすることで Ctrl+G が Monaco Modal を開く。
79
+ // DB / リクエストで EDITOR が明示設定されている場合はそちらが優先される。
80
+ const tsunagiDefaultEnv = {
81
+ EDITOR: TSUNAGI_EDITOR_PATH,
82
+ TSUNAGI_SESSION_ID: sessionId,
83
+ // monaco-editor.sh が API(Fastify) を叩くためのベース URL。サーバーと同一
84
+ // ホストなので localhost。単一ポート化で API は SERVER_PORT(既定 2791)。
85
+ TSUNAGI_API_BASE: `http://localhost:${SERVER_PORT}`,
86
+ };
87
+ return {
88
+ workingDir,
89
+ mode,
90
+ hasTab: Boolean(tab),
91
+ env: { ...tsunagiDefaultEnv, ...profile.env, ...providerEnv, ...request.env },
92
+ unsetKeys,
93
+ };
94
+ }
95
+ /**
96
+ * claude 起動前の補正。claude logout で .claude.json の hasCompletedOnboarding がリセットされて
97
+ * いると、次回 claude 起動時にオンボーディングウィザードが表示され --resume/--session-id を前提にした
98
+ * 自動起動フローが止まるため補正する。監視の取りこぼしに備え、中継口の名前が既定モデルに残って
99
+ * いないかも補正する。対象はタブが使う Claude プロファイルの設定ディレクトリ。
100
+ */
101
+ async function prepareClaudeConfig(env) {
102
+ // PTY は外側 Terminal の環境を継承するため、プロファイル未指定なら外側の CLAUDE_CONFIG_DIR に従う
103
+ const configDir = env.CLAUDE_CONFIG_DIR ?? process.env.CLAUDE_CONFIG_DIR;
104
+ await ensureClaudeOnboardingCompleted(configDir);
105
+ await removeLocalModelFromUserSettings(LOCAL_MODEL_ALIAS, configDir);
106
+ watchUserSettingsForLocalModel(LOCAL_MODEL_ALIAS, configDir);
107
+ }
108
+ /**
109
+ * 解決済みの launch で PTY を作り、claude を起動する。
110
+ * 新規作成(POST /terminal/sessions)と環境変数変更時の作り直し(pty-env-sync)で共用する。
111
+ */
112
+ export async function startPtySession(sessionId, launch, options) {
113
+ await fs.mkdir(launch.workingDir, { recursive: true });
114
+ let command = options.command;
115
+ if (options.launchClaude) {
116
+ if (isLocalLlmMode(launch.mode)) {
117
+ // 最初の発言を待たせないよう読み込みを始めておく(完了は待たない)
118
+ ensureActiveLoaded().catch(() => undefined);
119
+ }
120
+ command = await buildClaudeCommand(sessionId, launch.mode ?? 'claude');
121
+ }
122
+ const session = ptyManager.createSession(sessionId, launch.workingDir, launch.env, launch.unsetKeys, {
123
+ request: options.request,
124
+ launchClaude: options.launchClaude,
125
+ syncEnv: true,
126
+ });
127
+ // コマンドが指定されていればPTY起動後にシェルへ書き込む
128
+ if (command) {
129
+ const cmd = command.endsWith('\n') ? command : command + '\n';
130
+ await prepareClaudeConfig(launch.env);
131
+ // シェルの初期化(プロンプト表示)を待つため少し遅延させて書き込む
132
+ setTimeout(() => {
133
+ session.pty.write(cmd);
134
+ }, 300);
135
+ }
136
+ return session;
137
+ }
138
+ /** 既存 PTY で claude を(再)起動する前の補正。PTY 作成時の環境変数のプロファイルを対象にする */
139
+ export async function prepareClaudeConfigForSession(session) {
140
+ await prepareClaudeConfig(session.appliedEnv);
141
+ }
@@ -21,48 +21,22 @@ export async function getAllEnv(scope, owner, repo) {
21
21
  // 環境変数取得(優先順位: repo > owner > global)
22
22
  export async function getEnv(scope, owner, repo) {
23
23
  const result = {};
24
- // Claude認証トークンのキー定義
25
- const CLAUDE_TOKEN_KEYS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'];
26
- // Helper: Claude認証トークンをクリア
27
- const clearClaudeTokens = () => {
28
- CLAUDE_TOKEN_KEYS.forEach((key) => delete result[key]);
29
- };
30
- // Helper: 環境変数リストにClaude認証トークンが含まれるか
31
- const hasClaudeToken = (envs) => {
32
- return envs.some((env) => CLAUDE_TOKEN_KEYS.includes(env.key));
24
+ const apply = (envs) => {
25
+ envs.forEach((env) => {
26
+ result[env.key] = env.value;
27
+ });
33
28
  };
34
29
  // グローバル変数を先に適用(有効なもののみ)
35
- const globalEnvs = await prisma.environmentVariable.findMany({
36
- where: { scope: 'global', enabled: true },
37
- });
38
- globalEnvs.forEach((env) => {
39
- result[env.key] = env.value;
40
- });
30
+ apply(await prisma.environmentVariable.findMany({ where: { scope: 'global', enabled: true } }));
41
31
  // owner変数を適用(上書き、有効なもののみ)
42
32
  if (scope === 'owner' || scope === 'repo') {
43
- const ownerEnvs = await prisma.environmentVariable.findMany({
44
- where: { scope: 'owner', owner, enabled: true },
45
- });
46
- // Ownerスコープで新しいClaude認証トークンがあれば、Globalのものをクリア
47
- if (hasClaudeToken(ownerEnvs)) {
48
- clearClaudeTokens();
49
- }
50
- ownerEnvs.forEach((env) => {
51
- result[env.key] = env.value;
52
- });
33
+ apply(await prisma.environmentVariable.findMany({ where: { scope: 'owner', owner, enabled: true } }));
53
34
  }
54
35
  // repo変数を適用(上書き、有効なもののみ)
55
36
  if (scope === 'repo') {
56
- const repoEnvs = await prisma.environmentVariable.findMany({
37
+ apply(await prisma.environmentVariable.findMany({
57
38
  where: { scope: 'repo', owner, repo, enabled: true },
58
- });
59
- // Repoスコープで新しいClaude認証トークンがあれば、以前のものをクリア
60
- if (hasClaudeToken(repoEnvs)) {
61
- clearClaudeTokens();
62
- }
63
- repoEnvs.forEach((env) => {
64
- result[env.key] = env.value;
65
- });
39
+ }));
66
40
  }
67
41
  return result;
68
42
  }
@@ -1,8 +1,30 @@
1
1
  import * as pty from 'node-pty';
2
+ /** PTY 作成時の付随情報(環境変数の変更を反映するために作り直すときに使う) */
3
+ export interface PtySessionOptions {
4
+ /** クライアントが指定した cwd / env */
5
+ request?: {
6
+ cwd?: string;
7
+ env?: Record<string, string>;
8
+ };
9
+ /** 作成時に claude を起動したか(作り直し後も claude を --resume で起動する) */
10
+ launchClaude?: boolean;
11
+ /** tsunagi の環境変数の変更を自動で反映する対象か(Claude のログイン用 PTY 等は対象外) */
12
+ syncEnv?: boolean;
13
+ }
2
14
  export interface PtySession {
3
15
  pty: pty.IPty;
4
16
  sessionId: string;
5
17
  cwd: string;
18
+ /** 作成時に渡した環境変数と取り除いたキー(変更検知の比較元) */
19
+ appliedEnv: Record<string, string>;
20
+ appliedUnsetKeys: string[];
21
+ options: PtySessionOptions;
22
+ /** tsunagi の環境変数が変わり、作り直し待ちの状態 */
23
+ envPending: boolean;
24
+ /** 環境変数の反映のために kill 中。onExit で「セッション終了」として扱わない */
25
+ respawning: boolean;
26
+ /** 最後にユーザー入力があった時刻(入力中の作り直しを避けるため) */
27
+ lastInputAt: number;
6
28
  /** PTY出力のリングバッファ(再接続時にまとめて送信) */
7
29
  scrollback: string[];
8
30
  scrollbackSize: number;
@@ -24,7 +46,7 @@ declare class PtyManager {
24
46
  * @param unsetKeys 外側 Terminal や DB 由来でも内部ターミナルに渡さない環境変数キー
25
47
  * (例: Ollama タブでは Anthropic の認証トークンを取り除く)
26
48
  */
27
- createSession(sessionId: string, cwd: string, env?: Record<string, string>, unsetKeys?: string[]): PtySession;
49
+ createSession(sessionId: string, cwd: string, env?: Record<string, string>, unsetKeys?: string[], options?: PtySessionOptions): PtySession;
28
50
  getSession(sessionId: string): PtySession | undefined;
29
51
  deleteSession(sessionId: string): void;
30
52
  /**
@@ -63,7 +63,7 @@ class PtyManager {
63
63
  * @param unsetKeys 外側 Terminal や DB 由来でも内部ターミナルに渡さない環境変数キー
64
64
  * (例: Ollama タブでは Anthropic の認証トークンを取り除く)
65
65
  */
66
- createSession(sessionId, cwd, env, unsetKeys = []) {
66
+ createSession(sessionId, cwd, env = {}, unsetKeys = [], options = {}) {
67
67
  if (this.sessions.has(sessionId)) {
68
68
  throw new Error(`Session already exists: ${sessionId}`);
69
69
  }
@@ -102,6 +102,12 @@ class PtyManager {
102
102
  pty: ptyProcess,
103
103
  sessionId,
104
104
  cwd,
105
+ appliedEnv: env,
106
+ appliedUnsetKeys: unsetKeys,
107
+ options,
108
+ envPending: false,
109
+ respawning: false,
110
+ lastInputAt: 0,
105
111
  scrollback: [],
106
112
  scrollbackSize: 0,
107
113
  lastOutputAt: now,
@@ -0,0 +1,2 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ export declare function claudeProfilesRoutes(fastify: FastifyInstance): Promise<void>;
@@ -0,0 +1,138 @@
1
+ import * as os from 'os';
2
+ import { ptyManager } from '../pty-manager.js';
3
+ import { prisma } from '../lib/db.js';
4
+ import { deleteEnv, getEnv, setEnv } from '../lib/repositories/environment.js';
5
+ import { CLAUDE_PROFILE_ENV_KEY, DEFAULT_CLAUDE_PROFILE, buildClaudeLoginCommand, claudeLoginPtyEnv, claudeProfileExists, createClaudeProfile, deleteClaudeProfile, getClaudeAuthStatus, listClaudeProfiles, } from '../lib/claude-profiles.js';
6
+ import { notifyEnvChanged } from '../lib/pty-env-sync.js';
7
+ /** ログイン用 PTY の sessionId の接頭辞(タブの PTY と区別する) */
8
+ const LOGIN_SESSION_PREFIX = 'claude-login-';
9
+ function validateScope(scope, owner, repo) {
10
+ if (scope !== 'global' && scope !== 'owner' && scope !== 'repo')
11
+ return 'Invalid scope';
12
+ if (scope === 'owner' && !owner)
13
+ return 'owner is required for scope=owner';
14
+ if (scope === 'repo' && (!owner || !repo))
15
+ return 'owner and repo are required for scope=repo';
16
+ return null;
17
+ }
18
+ async function getAssignment(scope, owner, repo) {
19
+ const row = await prisma.environmentVariable.findFirst({
20
+ where: {
21
+ key: CLAUDE_PROFILE_ENV_KEY,
22
+ scope,
23
+ ...(scope !== 'global' && { owner }),
24
+ ...(scope === 'repo' && { repo }),
25
+ },
26
+ });
27
+ const merged = await getEnv(scope, owner, repo);
28
+ const assignment = {
29
+ assigned: row?.value ?? null,
30
+ effective: merged[CLAUDE_PROFILE_ENV_KEY] ?? DEFAULT_CLAUDE_PROFILE,
31
+ };
32
+ return assignment;
33
+ }
34
+ function syncPtyEnv(fastify) {
35
+ notifyEnvChanged().catch((err) => fastify.log.warn(err, 'Failed to sync PTY env'));
36
+ }
37
+ export async function claudeProfilesRoutes(fastify) {
38
+ // GET /claude-profiles - システム既定 + 登録済みプロファイルとログイン状態
39
+ fastify.get('/claude-profiles', async (_request, reply) => {
40
+ const profiles = await listClaudeProfiles();
41
+ const data = await Promise.all([
42
+ getClaudeAuthStatus(DEFAULT_CLAUDE_PROFILE).then((status) => ({
43
+ slug: DEFAULT_CLAUDE_PROFILE,
44
+ name: 'System default',
45
+ // CLAUDE_CONFIG_DIR を付けない(普段ターミナルで claude を使うときと同じ設定)
46
+ configDir: null,
47
+ status,
48
+ })),
49
+ ...profiles.map(async (profile) => ({
50
+ ...profile,
51
+ status: await getClaudeAuthStatus(profile.slug),
52
+ })),
53
+ ]);
54
+ return reply.send({ data: { profiles: data } });
55
+ });
56
+ // GET /claude-profiles/:slug/status - ログイン状態(ログイン用ダイアログで完了を確認する)
57
+ fastify.get('/claude-profiles/:slug/status', async (request, reply) => {
58
+ const { slug } = request.params;
59
+ if (!(await claudeProfileExists(slug))) {
60
+ return reply.status(404).send({ error: 'Profile not found' });
61
+ }
62
+ return reply.send({ data: { status: await getClaudeAuthStatus(slug) } });
63
+ });
64
+ // POST /claude-profiles - プロファイル作成
65
+ fastify.post('/claude-profiles', async (request, reply) => {
66
+ try {
67
+ const profile = await createClaudeProfile(request.body?.name ?? '');
68
+ return reply.status(201).send({ data: { profile } });
69
+ }
70
+ catch (err) {
71
+ return reply.status(400).send({ error: err instanceof Error ? err.message : String(err) });
72
+ }
73
+ });
74
+ // DELETE /claude-profiles/:slug - プロファイル削除(logout してディレクトリごと消す)
75
+ fastify.delete('/claude-profiles/:slug', async (request, reply) => {
76
+ const { slug } = request.params;
77
+ if (slug === DEFAULT_CLAUDE_PROFILE || !(await claudeProfileExists(slug))) {
78
+ return reply.status(404).send({ error: 'Profile not found' });
79
+ }
80
+ await deleteClaudeProfile(slug);
81
+ syncPtyEnv(fastify);
82
+ return reply.status(204).send();
83
+ });
84
+ // POST /claude-profiles/:slug/login - ログイン用 PTY を起動し `claude auth login` を実行する
85
+ fastify.post('/claude-profiles/:slug/login', async (request, reply) => {
86
+ const { slug } = request.params;
87
+ if (!(await claudeProfileExists(slug))) {
88
+ return reply.status(404).send({ error: 'Profile not found' });
89
+ }
90
+ // 同じプロファイルのログイン用 PTY が残っていれば閉じる
91
+ const prefix = `${LOGIN_SESSION_PREFIX}${slug}-`;
92
+ for (const id of ptyManager.listSessions()) {
93
+ if (id.startsWith(prefix))
94
+ ptyManager.deleteSession(id);
95
+ }
96
+ const sessionId = `${prefix}${crypto.randomUUID()}`;
97
+ const { env, unsetKeys } = claudeLoginPtyEnv(slug);
98
+ const session = ptyManager.createSession(sessionId, os.homedir(), env, unsetKeys);
99
+ // シェルの初期化(プロンプト表示)を待つため少し遅延させて書き込む
100
+ setTimeout(() => session.pty.write(`${buildClaudeLoginCommand()}\n`), 300);
101
+ return reply.status(201).send({ data: { sessionId } });
102
+ });
103
+ // DELETE /claude-profiles/login/:sessionId - ログイン用 PTY を閉じる
104
+ fastify.delete('/claude-profiles/login/:sessionId', async (request, reply) => {
105
+ const { sessionId } = request.params;
106
+ if (!sessionId.startsWith(LOGIN_SESSION_PREFIX)) {
107
+ return reply.status(400).send({ error: 'Not a login session' });
108
+ }
109
+ ptyManager.deleteSession(sessionId);
110
+ return reply.status(204).send();
111
+ });
112
+ // GET /claude-profiles/assignment - スコープのプロファイル割り当て
113
+ fastify.get('/claude-profiles/assignment', async (request, reply) => {
114
+ const { scope, owner, repo } = request.query;
115
+ const error = validateScope(scope, owner, repo);
116
+ if (error)
117
+ return reply.status(400).send({ error });
118
+ return reply.send({ data: { assignment: await getAssignment(scope, owner, repo) } });
119
+ });
120
+ // PUT /claude-profiles/assignment - スコープのプロファイル割り当てを変更する
121
+ fastify.put('/claude-profiles/assignment', async (request, reply) => {
122
+ const { scope, owner, repo, slug } = request.body ?? {};
123
+ const error = validateScope(scope, owner, repo);
124
+ if (error)
125
+ return reply.status(400).send({ error });
126
+ if (slug !== null && !(await claudeProfileExists(slug))) {
127
+ return reply.status(400).send({ error: 'Profile not found' });
128
+ }
129
+ if (slug === null) {
130
+ await deleteEnv(CLAUDE_PROFILE_ENV_KEY, scope, owner, repo);
131
+ }
132
+ else {
133
+ await setEnv(CLAUDE_PROFILE_ENV_KEY, slug, scope, owner, repo);
134
+ }
135
+ syncPtyEnv(fastify);
136
+ return reply.send({ data: { assignment: await getAssignment(scope, owner, repo) } });
137
+ });
138
+ }
@@ -1,4 +1,11 @@
1
1
  import * as envRepo from '../lib/repositories/environment.js';
2
+ import { RESERVED_ENV_KEYS } from '../lib/claude-profiles.js';
3
+ import { notifyEnvChanged } from '../lib/pty-env-sync.js';
4
+ const RESERVED_KEY_ERROR = 'Claude の認証は Settings の Claude Profile で設定してください';
5
+ /** 変更後、起動中のターミナルに反映する(失敗しても API 自体は成功扱い) */
6
+ function syncPtyEnv(fastify) {
7
+ notifyEnvChanged().catch((err) => fastify.log.warn(err, 'Failed to sync PTY env'));
8
+ }
2
9
  export async function envRoutes(fastify) {
3
10
  // GET /env
4
11
  fastify.get('/env', async (request, reply) => {
@@ -30,6 +37,9 @@ export async function envRoutes(fastify) {
30
37
  if (!key || !value || !scope) {
31
38
  return reply.status(400).send({ error: 'Missing required fields: key, value, scope' });
32
39
  }
40
+ if (RESERVED_ENV_KEYS.includes(key)) {
41
+ return reply.status(400).send({ error: RESERVED_KEY_ERROR });
42
+ }
33
43
  if (scope === 'owner' && !owner) {
34
44
  return reply.status(400).send({ error: 'Missing required field: owner for scope=owner' });
35
45
  }
@@ -39,6 +49,7 @@ export async function envRoutes(fastify) {
39
49
  });
40
50
  }
41
51
  await envRepo.setEnv(key, value, scope, owner, repo);
52
+ syncPtyEnv(fastify);
42
53
  return reply.status(201).send({ data: { success: true } });
43
54
  }
44
55
  catch (error) {
@@ -53,6 +64,9 @@ export async function envRoutes(fastify) {
53
64
  if (!key || !value || !scope) {
54
65
  return reply.status(400).send({ error: 'Missing required fields: key, value, scope' });
55
66
  }
67
+ if (RESERVED_ENV_KEYS.includes(key)) {
68
+ return reply.status(400).send({ error: RESERVED_KEY_ERROR });
69
+ }
56
70
  if (scope === 'owner' && !owner) {
57
71
  return reply.status(400).send({ error: 'Missing required field: owner for scope=owner' });
58
72
  }
@@ -62,6 +76,7 @@ export async function envRoutes(fastify) {
62
76
  });
63
77
  }
64
78
  await envRepo.setEnv(key, value, scope, owner, repo);
79
+ syncPtyEnv(fastify);
65
80
  return reply.status(200).send({ data: { success: true } });
66
81
  }
67
82
  catch (error) {
@@ -80,6 +95,9 @@ export async function envRoutes(fastify) {
80
95
  error: 'Missing required query parameters: key, scope',
81
96
  });
82
97
  }
98
+ if (RESERVED_ENV_KEYS.includes(key)) {
99
+ return reply.status(400).send({ error: RESERVED_KEY_ERROR });
100
+ }
83
101
  if (scope === 'owner' && !owner) {
84
102
  return reply.status(400).send({
85
103
  error: 'Missing required query parameter: owner for scope=owner',
@@ -94,6 +112,7 @@ export async function envRoutes(fastify) {
94
112
  if (!success) {
95
113
  return reply.status(404).send({ error: 'Environment variable not found' });
96
114
  }
115
+ syncPtyEnv(fastify);
97
116
  return reply.status(200).send({ data: { success: true } });
98
117
  }
99
118
  catch (error) {
@@ -116,7 +135,8 @@ export async function envRoutes(fastify) {
116
135
  error: 'Missing required query parameters: owner, repo for scope=repo',
117
136
  });
118
137
  }
119
- const envVars = await envRepo.getAllEnv(scope, owner || undefined, repo || undefined);
138
+ // 予約キー(Claude プロファイルの割り当て)は専用 UI で扱うため一覧に含めない
139
+ const envVars = (await envRepo.getAllEnv(scope, owner || undefined, repo || undefined)).filter((env) => !RESERVED_ENV_KEYS.includes(env.key));
120
140
  return reply.status(200).send({ data: { envVars } });
121
141
  }
122
142
  catch (error) {
@@ -131,7 +151,11 @@ export async function envRoutes(fastify) {
131
151
  if (!key || !scope || typeof enabled !== 'boolean') {
132
152
  return reply.status(400).send({ error: 'key, scope, and enabled are required' });
133
153
  }
154
+ if (RESERVED_ENV_KEYS.includes(key)) {
155
+ return reply.status(400).send({ error: RESERVED_KEY_ERROR });
156
+ }
134
157
  await envRepo.toggleEnv(key, scope, enabled, owner, repo);
158
+ syncPtyEnv(fastify);
135
159
  return reply.status(200).send({ data: { success: true } });
136
160
  }
137
161
  catch (error) {
@@ -1,15 +1,24 @@
1
- import { getEnv } from '../lib/repositories/environment.js';
2
1
  import { isLocalLlmReady } from '../lib/local-llm.js';
2
+ import { DEFAULT_CLAUDE_PROFILE, getClaudeAuthStatus, listClaudeProfiles, } from '../lib/claude-profiles.js';
3
+ /** システム既定かいずれかの Claude プロファイルがログイン済みか */
4
+ async function isClaudeLoggedIn() {
5
+ if ((await getClaudeAuthStatus(DEFAULT_CLAUDE_PROFILE)).loggedIn)
6
+ return true;
7
+ const profiles = await listClaudeProfiles();
8
+ const statuses = await Promise.all(profiles.map((p) => getClaudeAuthStatus(p.slug)));
9
+ return statuses.some((status) => status.loggedIn);
10
+ }
3
11
  export async function onboardingRoutes(fastify) {
4
12
  // GET /onboarding/status
5
13
  fastify.get('/onboarding/status', async (_request, reply) => {
6
14
  try {
7
- const globalEnv = await getEnv('global');
8
- const hasGlobalToken = Boolean(globalEnv.ANTHROPIC_API_KEY || globalEnv.CLAUDE_CODE_OAUTH_TOKEN);
9
- // 実験的機能のローカルLLM(Ollama / LM Studio)を設定済みなら、Anthropic のトークンがなくても claude を起動できる
10
- const localLlmReady = await isLocalLlmReady();
15
+ const [claudeLoggedIn, localLlmReady] = await Promise.all([
16
+ isClaudeLoggedIn(),
17
+ // 実験的機能のローカルLLM(Ollama / LM Studio)を設定済みなら、Claude にログインしていなくても claude を起動できる
18
+ isLocalLlmReady(),
19
+ ]);
11
20
  return reply.status(200).send({
12
- data: { completed: hasGlobalToken || localLlmReady, hasGlobalToken, localLlmReady },
21
+ data: { completed: claudeLoggedIn || localLlmReady, claudeLoggedIn, localLlmReady },
13
22
  });
14
23
  }
15
24
  catch (error) {