@galda/cli 0.10.22 → 0.10.23

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.
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "galda",
3
+ "description": "Delegate work to Galda and clear your review queue without leaving Claude Code. Type /galda to see everything waiting on you — finished work needing sign-off, questions, blocked goals — and approve, send back, or answer it in place.",
4
+ "author": {
5
+ "name": "Kodo Inc."
6
+ },
7
+ "homepage": "https://galda.app",
8
+ "keywords": [
9
+ "galda",
10
+ "agent-manager",
11
+ "code-review",
12
+ "task-delegation"
13
+ ]
14
+ }
package/.mcp.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "galda": {
4
+ "command": "npx",
5
+ "args": ["-y", "-p", "@galda/cli", "galda-mcp"]
6
+ }
7
+ }
8
+ }
package/SKILL.md ADDED
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: galda
3
+ description: Galda / Agent Manager の玄関口。溜まったレビューを見て返信・承認する、To Do を投げる、状況を見る。galda 関係のことはここから。Invoke with /galda.
4
+ ---
5
+
6
+ <!--
7
+ The plugin's default skill. It MUST keep `name: galda` in the frontmatter —
8
+ without it Claude Code names the skill after the install directory, which for a
9
+ marketplace install is a version string that changes on every update, so the
10
+ command users learned would silently stop existing.
11
+ -->
12
+
13
+ You are the entry point to Galda / Agent Manager. Use the `galda` MCP server.
14
+ Default project: `galda2`, unless the user names another.
15
+ Answer in the user's language, short. Never implement the task yourself — Galda does the work.
16
+
17
+ ## 引数なしで `/galda` と打たれた時
18
+
19
+ Call `manager_inbox` and show what is waiting on the user.
20
+
21
+ - 1件1行。ゴール番号・何を聞かれているか/何を承認するのか・あればテスト結果とPR。
22
+ - 質問には選択肢がついていることがある。**必ず選択肢も見せる**(それが返事のしかたなので)。
23
+ - 出したら止まる。「何をしますか?」とは聞かない——リストが質問。
24
+ - 何も無ければ「今、待ってるものは無い」とだけ言う。
25
+
26
+ > `manager_inbox` が "unknown tool" で落ちたら、繋がっている Galda が古い。
27
+ > `manager_status` にフォールバックし、`[review]` `[needsInput]` `[blocked]` の goal だけを
28
+ > 拾って同じように見せた上で、「Galda が古いので一部しか見えていない(`npx @galda/cli` を
29
+ > 新しくすると全部見える)」と一言添える。黙って少なく見せない。
30
+
31
+ ## 文章つきで `/galda 〜` と打たれた時
32
+
33
+ 何を言っているか判断して振り分ける。
34
+
35
+ - **新しい依頼** → `manager_create_goal`(`deliverable: pr`)。文言はユーザーのまま渡す。
36
+ - **番号への返事**(「456 は別リポジトリで」「440 dismiss」「23 approve」)→ 下の表。
37
+ - **状況を知りたい** → `manager_inbox`(自分待ち)か `manager_status`(全体の流れ)。
38
+ - **判断だけ欲しい/決めてほしい物を積む** → `manager_request_review`。
39
+
40
+ 迷ったら聞かずに `manager_inbox` を出して、そこから会話する。
41
+
42
+ ## 溜まったものを捌く
43
+
44
+ `manager_inbox` が各グループに「次に呼ぶツール」を書いて返す。**それに従う**。要約:
45
+
46
+ | inbox の見出し | 承認する | 却下・差し戻す |
47
+ |---|---|---|
48
+ | NEEDS YOUR ANSWER | `manager_answer(goalId, answer)` | — |
49
+ | PLAN — approve to execute | `manager_approve` → **プランが実行される**(Done にはならない) | `manager_dismiss` |
50
+ | DECISION — no worker ran | `manager_approve` → Done へ | `manager_dismiss` → 捨てる |
51
+ | REVIEW | `manager_approve` → Done へ | `manager_dismiss(text)` → 直しの指示つきで worker に戻す |
52
+ | BLOCKED | — | `manager_reply(text)` で指示 / `manager_dismiss` で捨てる |
53
+
54
+ **NEEDS YOUR ANSWER に `manager_reply` を使わない。** 拒否されないが黙って壊れる——
55
+ ワーカーが起動した上でゴールは質問したまま固まる。必ず `manager_answer`。
56
+
57
+ 承認・差し戻し・回答をした後は、**何がどう変わったかを一言で言う**(「456 に答えた → 再プラン中」)。
58
+ まとめて何件も捌いた時だけ、最後にもう一度 `manager_inbox` を出して残りを見せる。
59
+
60
+ ## Galda が動いていない時
61
+
62
+ `manager_*` が接続エラーで落ちたら、ローカルの Galda が起動していない。
63
+ `npx @galda/cli` を実行するよう伝える(このプラグインは Galda 本体ではなく、その入口)。
package/engine/lib.mjs CHANGED
@@ -2097,6 +2097,113 @@ export function buildAskContext(goals = [], tasks = []) {
2097
2097
  ].join('\n\n');
2098
2098
  }
2099
2099
 
2100
+ // ---- REVIEW INBOX (MCP) ----------------------------------------------------
2101
+ // 「Claude から /galda と打ったら、galda が自分待ちで止まっている物が全部見えて、
2102
+ // その場で捌ける」ための一覧。manager_status は goal を status 無関係に直近12件で
2103
+ // 切っていたので、レビューに入ったまま古くなった goal は MCP から**黙って消えて
2104
+ // いた**——「溜まったレビュー」がまさに見えない、という逆をやっていた。ここは
2105
+ // status で引くので件数では落ちない。
2106
+ //
2107
+ // ラベルの流儀は buildAskContext と同じ(reviewSummary.check → plan[0] → text)。
2108
+ // 人間待ちは1種類ではないので kind で分ける——同じ「レビュー」でも次の一手が違う:
2109
+ // plan … 承認するとプランが**実行される**(done にはならない。nextAfterPlanApprove)
2110
+ // decision … reviewOnly。worker は走っていない。Dismiss は skipped で捨てるだけ
2111
+ // review … 通常。Approve→done / Dismiss→running に戻して修正指示を返信タスクに積む
2112
+ // question … needsInput。/reply は 409。専用の /answer に answer を送る
2113
+ // blocked … worker のスレッドが無いので Dismiss は skipped。修正指示は reply
2114
+ const inboxLabel = (g) => (g.reviewSummary?.check || g.plan?.[0] || g.text || '').replace(/\s+/g, ' ').trim().slice(0, 100);
2115
+
2116
+ export function inboxKind(goal) {
2117
+ if (!goal) return null;
2118
+ if (isPlanReview(goal)) return 'plan';
2119
+ if (goal.status === 'review') return goal.reviewOnly ? 'decision' : 'review';
2120
+ if (goal.status === 'needsInput') return 'question';
2121
+ if (goal.status === 'blocked') return 'blocked';
2122
+ return null;
2123
+ }
2124
+
2125
+ // kind ごとの「次の一手」。MCP のツール名で書く——読むのが人間ではなく Claude で、
2126
+ // そのまま呼べる形にしておかないと、承認と差し戻しを取り違える。
2127
+ // question に「reply は使うな」と明記しているのは実測の結果: POST /reply は
2128
+ // stacked/planning しか弾かないので needsInput の goal にも**通ってしまい**、答えを
2129
+ // 畳み込んで再プランする代わりにワーカータスクを積んだ上、goal は needsInput のまま
2130
+ // 質問し続ける(running に戻す分岐にも入らない)。弾かれるより悪い=黙って違うことを
2131
+ // するので、ここで行き先を固定する。
2132
+ export const INBOX_ACTIONS = {
2133
+ plan: 'manager_approve → runs the approved plan (does NOT file to Done) / manager_dismiss → sends it back',
2134
+ decision: 'manager_approve → files it to Done / manager_dismiss → drops it (no worker runs either way)',
2135
+ review: 'manager_approve → files it to Done / manager_dismiss(text) → sends it back to the worker with your fix note',
2136
+ question: 'manager_answer(answer) → answers it; the goal re-plans and continues. NOT manager_reply — it is accepted but queues a worker instead of answering, and the goal stays stuck asking.',
2137
+ blocked: 'manager_reply(text) → tells the worker what to do / manager_dismiss → drops it',
2138
+ };
2139
+
2140
+ export function buildReviewInbox(goals = [], { project } = {}) {
2141
+ return goals
2142
+ .filter((g) => !project || g.projectId === project)
2143
+ .map((g) => ({ g, kind: inboxKind(g) }))
2144
+ .filter((x) => x.kind)
2145
+ // 古い順。「溜まった」物こそ本題なので、新しい方から数えて切り捨てない。
2146
+ .sort((a, b) => a.g.id - b.g.id)
2147
+ .map(({ g, kind }) => {
2148
+ const tr = g.testResult;
2149
+ const opts = g.question?.options;
2150
+ return {
2151
+ goalId: g.id,
2152
+ projectId: g.projectId ?? null,
2153
+ kind,
2154
+ label: inboxLabel(g),
2155
+ tests: tr?.ran ? { passed: tr.passed ?? 0, failed: tr.failed ?? 0 } : null,
2156
+ pr: g.pr || null,
2157
+ question: kind === 'question' && g.question?.text
2158
+ ? {
2159
+ text: String(g.question.text).replace(/\s+/g, ' ').trim().slice(0, 200),
2160
+ options: Array.isArray(opts) ? opts.map((o) => String(o).replace(/\s+/g, ' ').trim()).slice(0, 8) : [],
2161
+ }
2162
+ : null,
2163
+ blockedReason: kind === 'blocked'
2164
+ ? String(g.blocked?.reason ?? 'blocked').replace(/\s+/g, ' ').trim().slice(0, 120)
2165
+ : null,
2166
+ };
2167
+ });
2168
+ }
2169
+
2170
+ const INBOX_KIND_HEADING = {
2171
+ question: 'NEEDS YOUR ANSWER',
2172
+ plan: 'PLAN — approve to execute',
2173
+ decision: 'DECISION — no worker ran',
2174
+ review: 'REVIEW',
2175
+ blocked: 'BLOCKED',
2176
+ };
2177
+ // 止まっているものから先に。question/plan は答えるまで前に進まないので上。
2178
+ const INBOX_KIND_ORDER = ['question', 'plan', 'decision', 'review', 'blocked'];
2179
+
2180
+ // kind ごとにまとめ、「次の一手」は1グループ1回だけ書く。これを item ごとに繰り返すと、
2181
+ // レビューが20件溜まった時に同じ一文を20回読ませることになる——読み手は Claude なので
2182
+ // それはそのままトークンの無駄。
2183
+ export function formatReviewInbox(items = []) {
2184
+ if (!items.length) return 'Nothing is waiting on you.';
2185
+ const sections = INBOX_KIND_ORDER
2186
+ .map((kind) => [kind, items.filter((it) => it.kind === kind)])
2187
+ .filter(([, list]) => list.length)
2188
+ .map(([kind, list]) => {
2189
+ const lines = [`## ${INBOX_KIND_HEADING[kind]} — ${list.length}`, INBOX_ACTIONS[kind]];
2190
+ for (const it of list) {
2191
+ const tail = [
2192
+ it.tests ? `tests ${it.tests.passed}✓${it.tests.failed ? `/${it.tests.failed}✗` : ''}` : null,
2193
+ it.pr ? `PR: ${it.pr}` : null,
2194
+ it.blockedReason ? `blocked: ${it.blockedReason}` : null,
2195
+ ].filter(Boolean);
2196
+ lines.push(`[goal ${it.goalId}] (${it.projectId}) ${it.label}${tail.length ? ` — ${tail.join(' — ')}` : ''}`);
2197
+ if (it.question) {
2198
+ lines.push(` Q: ${it.question.text}`);
2199
+ if (it.question.options.length) lines.push(` options: ${it.question.options.join(' | ')}`);
2200
+ }
2201
+ }
2202
+ return lines.join('\n');
2203
+ });
2204
+ return [`${items.length} waiting on you (oldest first within each group):`, ...sections].join('\n\n');
2205
+ }
2206
+
2100
2207
  // Goal statuses that count as "finished" for showing the goal-level
2101
2208
  // conclusion summary in chat (task 86) — mirrored in app/index.html's
2102
2209
  // goalRow (GOAL_COMPLETE_STATUSES) since that inline script can't import
@@ -3113,7 +3220,7 @@ export function relayReconnectDecision(state, event) {
3113
3220
  }
3114
3221
  }
3115
3222
 
3116
- // --- Relay-client single-instance guard (2026-07-17, five incidents deep) -----
3223
+ // --- Relay-client single-instance guard (2026-07-17, six incidents deep) -----
3117
3224
  // Every relay mutual-kick incident so far (#86/#88/#106, and the one that
3118
3225
  // prompted this) traces back to the SAME root: nothing has ever stopped more
3119
3226
  // than one relay-client process from authenticating as the same account at
@@ -3126,13 +3233,36 @@ export function relayReconnectDecision(state, event) {
3126
3233
  // closes that gap at the one point it can be closed structurally: refuse to
3127
3234
  // start a second live process against the same DATA_DIR at all, the same
3128
3235
  // pattern tools/lane.mjs uses for hot-file locks (fail at start time, not
3129
- // after the fact). `alive` is passed in (not computed here) so this stays a
3130
- // pure decision — the caller does the actual process.kill(pid, 0) liveness
3131
- // check (real I/O, not testable as a pure function).
3132
- export function shouldRefuseRelayClientStart({ existingLock, alive } = {}) {
3133
- if (!existingLock || !Number.isFinite(existingLock.pid)) return { refuse: false, reason: 'no-lock' };
3134
- if (!alive) return { refuse: false, reason: 'stale-lock' };
3135
- return { refuse: true, reason: 'already-running', holderPid: existingLock.pid };
3236
+ // after the fact).
3237
+ //
3238
+ // First cut just refused outright, full stop. Masa (2026-07-17): that is
3239
+ // unfriendly Figma/Notion/ChatGPT all let you have multiple accounts, and
3240
+ // signing into the "wrong" one by habit is a completely normal thing to do.
3241
+ // A flat refusal makes a NORMAL action (switch accounts on this device) look
3242
+ // exactly like an ERROR, with no path forward except reading a hint and
3243
+ // typing a different command by hand. So: classify the conflict instead of
3244
+ // just flagging it. Same account already connected here → still refuse,
3245
+ // nothing useful to offer (switching to yourself is a no-op). A DIFFERENT
3246
+ // account → this is a legitimate everyday action, not a fault condition, so
3247
+ // offer to switch right there instead of just describing how to (relay-
3248
+ // client.mjs prompts interactively and takes over on 'y').
3249
+ // `alive` is passed in (not computed here) so this stays a pure decision —
3250
+ // the caller does the actual process.kill(pid, 0) liveness check (real I/O,
3251
+ // not testable as a pure function).
3252
+ export function classifyRelayClientConflict({ existingLock, alive, newIdentity } = {}) {
3253
+ if (!existingLock || !Number.isFinite(existingLock.pid)) return { action: 'proceed', reason: 'no-lock' };
3254
+ if (!alive) return { action: 'proceed', reason: 'stale-lock' };
3255
+ const holderIdentity = existingLock.identity || null;
3256
+ const holderPid = existingLock.pid;
3257
+ if (holderIdentity && newIdentity && holderIdentity === newIdentity) {
3258
+ return { action: 'refuse', reason: 'same-account-already-connected', holderPid, holderIdentity };
3259
+ }
3260
+ if (holderIdentity && newIdentity && holderIdentity !== newIdentity) {
3261
+ return { action: 'offer-switch', reason: 'different-account', holderPid, holderIdentity };
3262
+ }
3263
+ // Identity unknown on one/both sides (e.g. not signed in yet) — can't tell
3264
+ // whether switching is meaningful, so default to the safe, old behavior.
3265
+ return { action: 'refuse', reason: 'unknown-identity', holderPid, holderIdentity };
3136
3266
  }
3137
3267
 
3138
3268
  // --- Connect-gate account-mismatch escalation (2026-07-17) ------------------
package/engine/mcp.mjs CHANGED
@@ -14,6 +14,7 @@ import { readFileSync, existsSync } from 'node:fs';
14
14
  import { resolve, dirname, join } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { createInterface } from 'node:readline';
17
+ import { buildReviewInbox, formatReviewInbox } from './lib.mjs';
17
18
 
18
19
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
19
20
  const DATA_DIR = process.env.MANAGER_HOME
@@ -81,7 +82,7 @@ const TOOLS = [
81
82
  },
82
83
  {
83
84
  name: 'manager_reply',
84
- description: 'Reply in a goal thread. The reply resumes the worker session for that goal, so context carries over.',
85
+ description: 'Reply in a goal thread. The reply resumes the worker session for that goal, so context carries over. Do NOT use this on a goal awaiting a question ("Needs your answer" / needsInput) — the server accepts it but it queues a worker instead of answering, leaving the goal stuck; use manager_answer there.',
85
86
  inputSchema: { type: 'object', properties: {
86
87
  goalId: { type: 'number' }, text: { type: 'string' },
87
88
  }, required: ['goalId', 'text'] },
@@ -91,6 +92,59 @@ const TOOLS = [
91
92
  return `Reply queued as task ${t.id} on goal ${a.goalId}.`;
92
93
  },
93
94
  },
95
+ {
96
+ name: 'manager_inbox',
97
+ description: 'Show everything in Manager that is waiting on the HUMAN right now — the Review lane (work finished, needs a sign-off), plan approvals, review-only decisions, unanswered questions ("Needs your answer"), and blocked goals. Use this whenever the user asks what needs their attention / what is in review / what is waiting / "anything for me?". Unlike manager_status (which lists only the 12 most recent goals regardless of status, so old pending reviews silently vanish), this is filtered by status and never truncated. Each item tells you which follow-up tool to call.',
98
+ inputSchema: { type: 'object', properties: {
99
+ project: { type: 'string', description: 'optional project id filter (e.g. galda1, galda2)' },
100
+ } },
101
+ run: async (a) => {
102
+ const s = await api('/api/state');
103
+ return formatReviewInbox(buildReviewInbox(s.goals ?? [], { project: a.project }));
104
+ },
105
+ },
106
+ {
107
+ name: 'manager_approve',
108
+ description: 'Approve a goal that is sitting in the Review lane. For a normal review this files the goal to Done. For a PLAN review (kind "plan" in manager_inbox) it does NOT file to Done — it runs the approved plan, i.e. the worker now actually makes the edits. Check manager_inbox for the kind before telling the user what approving will do.',
109
+ inputSchema: { type: 'object', properties: {
110
+ goalId: { type: 'number' },
111
+ }, required: ['goalId'] },
112
+ run: async (a) => {
113
+ const g = await api(`/api/goals/${a.goalId}/approve`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
114
+ return g.status === 'done'
115
+ ? `Approved: goal ${a.goalId} filed to Done.`
116
+ : `Approved: goal ${a.goalId} is now [${g.status}] — the approved plan is executing.`;
117
+ },
118
+ },
119
+ {
120
+ name: 'manager_dismiss',
121
+ description: 'Dismiss a goal in the Review lane. For a normal review this sends it BACK to the worker to be fixed (pass `text` with what to fix — it is delivered as a thread reply and the worker resumes). For a review-only decision or a blocked goal there is no worker thread, so this just drops the item (filed as skipped) and `text` is ignored. Not an "approve" — never use this to sign something off.',
122
+ inputSchema: { type: 'object', properties: {
123
+ goalId: { type: 'number' },
124
+ text: { type: 'string', description: 'what to fix — sent to the worker. Ignored for review-only / blocked items.' },
125
+ }, required: ['goalId'] },
126
+ run: async (a) => {
127
+ const r = await api(`/api/goals/${a.goalId}/dismiss`, { method: 'POST', headers: { 'content-type': 'application/json' },
128
+ body: JSON.stringify({ text: a.text }) });
129
+ const status = r.goal?.status ?? r.status;
130
+ return status === 'skipped'
131
+ ? `Dismissed: goal ${a.goalId} dropped (skipped). No worker ran.`
132
+ : `Sent back: goal ${a.goalId} is now [${status}] — the worker is revising it.`;
133
+ },
134
+ },
135
+ {
136
+ name: 'manager_answer',
137
+ description: 'Answer a goal that is waiting on a question ("Needs your answer" / kind "question" in manager_inbox). The answer is folded into the goal, which then re-plans and continues — no new goal is created. Always use this, never manager_reply, for a question goal: manager_reply is NOT rejected for them, it is silently wrong — it queues a worker task with your text while the goal stays stuck asking the same question. If manager_inbox listed options, prefer answering with one of them.',
138
+ inputSchema: { type: 'object', properties: {
139
+ goalId: { type: 'number' },
140
+ answer: { type: 'string', description: 'the answer, natural language (or one of the listed options)' },
141
+ }, required: ['goalId', 'answer'] },
142
+ run: async (a) => {
143
+ const g = await api(`/api/goals/${a.goalId}/answer`, { method: 'POST', headers: { 'content-type': 'application/json' },
144
+ body: JSON.stringify({ answer: a.answer }) });
145
+ return `Answered: goal ${a.goalId} is now [${g.status}] in ${g.projectId}.`;
146
+ },
147
+ },
94
148
  ];
95
149
 
96
150
  function reply(id, result) { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n'); }
@@ -12,7 +12,9 @@ import { readFileSync, writeFileSync, existsSync, watch, unlinkSync } from 'node
12
12
  import { resolve, dirname, join } from 'node:path';
13
13
  import { homedir } from 'node:os';
14
14
  import { fileURLToPath } from 'node:url';
15
- import { licenseTokenIdentity, relayReconnectDecision, shouldRefuseRelayClientStart } from './lib.mjs';
15
+ import { createInterface } from 'node:readline/promises';
16
+ import { setTimeout as sleep } from 'node:timers/promises';
17
+ import { licenseTokenIdentity, relayReconnectDecision, classifyRelayClientConflict } from './lib.mjs';
16
18
 
17
19
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
18
20
  const DATA_DIR = process.env.MANAGER_HOME
@@ -182,12 +184,17 @@ function handleTakeover() {
182
184
  if (d.dial) { console.log('[agent] taking over this account on this device (user requested)'); redialNow(); }
183
185
  }
184
186
 
185
- // Single-instance guard (2026-07-17): refuse to start a second relay-client
186
- // against the same DATA_DIR. See lib.mjs shouldRefuseRelayClientStart for why
187
- // this is the one point that actually stops the "N processes, one account"
188
- // mutual-kick class of incident, rather than making each process behave
189
- // better once it's already one of several. RELAY_CLIENT_FORCE=1 overrides for
190
- // the rare legitimate case (you know two are up and you're taking over by hand).
187
+ // Single-instance guard (2026-07-17): refuse a second relay-client against the
188
+ // same DATA_DIR for the SAME account (nothing to offer that's still exactly
189
+ // the mutual-kick precondition); OFFER TO SWITCH for a DIFFERENT account. See
190
+ // lib.mjs classifyRelayClientConflict for the decision and why a flat refusal
191
+ // (the first cut, same night) wasn't good enough: Masa — "Figma/Notion/ChatGPT
192
+ // all let you have multiple accounts... signing into the wrong one by habit
193
+ // happens... a silent infinite wait doesn't tell the user anything." Switching
194
+ // accounts on a device you already use Galda on is a normal action, not an
195
+ // error, so it gets a normal confirm-and-go prompt, not a wall of text to
196
+ // decode. RELAY_CLIENT_FORCE=1 skips the prompt entirely (scripts, CI, "I
197
+ // already know, just do it").
191
198
  const LOCK_FILE = join(DATA_DIR, 'relay-client.lock');
192
199
  function readLock() {
193
200
  try { return JSON.parse(readFileSync(LOCK_FILE, 'utf8')); } catch { return null; }
@@ -197,7 +204,7 @@ function isPidAlive(pid) {
197
204
  try { process.kill(pid, 0); return true; } catch { return false; }
198
205
  }
199
206
  function writeLock() {
200
- try { writeFileSync(LOCK_FILE, JSON.stringify({ pid: process.pid, startedAt: Date.now() })); } catch { /* best-effort */ }
207
+ try { writeFileSync(LOCK_FILE, JSON.stringify({ pid: process.pid, startedAt: Date.now(), identity: currentIdentity || null })); } catch { /* best-effort */ }
201
208
  }
202
209
  function releaseLock() {
203
210
  try {
@@ -206,16 +213,70 @@ function releaseLock() {
206
213
  } catch { /* already gone */ }
207
214
  }
208
215
 
209
- if (!process.env.RELAY_CLIENT_NO_START) {
216
+ // The actual takeover mechanics (kill the old holder, wait for its lock to
217
+ // really clear) — split out from the interactive prompt around it so this
218
+ // part is testable without a real TTY (engine/test/relay-standdown.test.mjs
219
+ // exercises this directly against two real processes; the y/N prompt itself
220
+ // needs a pty, which isn't worth a new dependency for).
221
+ async function takeOverFromHolder(holderPid) {
222
+ try { process.kill(holderPid, 'SIGTERM'); } catch { /* already gone */ }
223
+ // The old process's own SIGTERM handler releases its lock; wait for that
224
+ // rather than racing to write ours the instant the signal is sent.
225
+ const deadline = Date.now() + 3000;
226
+ while (Date.now() < deadline) {
227
+ const l = readLock();
228
+ if (!l || !isPidAlive(l.pid)) return true;
229
+ await sleep(150);
230
+ }
231
+ return !isPidAlive(readLock()?.pid);
232
+ }
233
+
234
+ // Prompts (only when this is a real terminal — never blocks a script/CI run)
235
+ // to switch, then hands off to takeOverFromHolder(). Returns true iff we
236
+ // should proceed as the new identity; false means "stay refused, exit".
237
+ async function offerAccountSwitch(conflict) {
238
+ if (!process.stdin.isTTY) {
239
+ console.error(`[agent] relay-client: Galda is already connected on this device as ${conflict.holderIdentity} (pid ${conflict.holderPid}).`);
240
+ console.error(`[agent] This sign-in is for a different account (${currentIdentity}). Re-run in an interactive terminal to be prompted to switch, or set RELAY_CLIENT_FORCE=1.`);
241
+ return false;
242
+ }
243
+ console.log(`[agent] Galda is already connected on this device as ${conflict.holderIdentity}.`);
244
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
245
+ let answer;
246
+ try { answer = (await rl.question(`[agent] Switch this device to ${currentIdentity} instead? [y/N] `)).trim().toLowerCase(); }
247
+ finally { rl.close(); }
248
+ if (answer !== 'y' && answer !== 'yes') {
249
+ console.log('[agent] Keeping the existing connection — nothing changed.');
250
+ return false;
251
+ }
252
+ console.log(`[agent] Switching... stopping the previous session (${conflict.holderIdentity}, pid ${conflict.holderPid})`);
253
+ await takeOverFromHolder(conflict.holderPid);
254
+ console.log(`[agent] Connecting as ${currentIdentity}...`);
255
+ return true;
256
+ }
257
+
258
+ async function acquireLockOrExit() {
259
+ if (process.env.RELAY_CLIENT_FORCE) { writeLock(); return; }
210
260
  const existingLock = readLock();
211
- const guard = shouldRefuseRelayClientStart({ existingLock, alive: existingLock ? isPidAlive(existingLock.pid) : false });
212
- if (guard.refuse && !process.env.RELAY_CLIENT_FORCE) {
213
- console.error(`[agent] relay-client: another instance is already running for this account (pid ${guard.holderPid}, ${DATA_DIR}).`);
261
+ const conflict = classifyRelayClientConflict({ existingLock, alive: existingLock ? isPidAlive(existingLock.pid) : false, newIdentity: currentIdentity || null });
262
+ if (conflict.action === 'proceed') { writeLock(); return; }
263
+ if (conflict.action === 'offer-switch') {
264
+ if (await offerAccountSwitch(conflict)) { writeLock(); return; }
265
+ process.exit(1);
266
+ }
267
+ // action === 'refuse'
268
+ if (conflict.reason === 'same-account-already-connected') {
269
+ console.error(`[agent] relay-client: already connected on this device as ${conflict.holderIdentity} (pid ${conflict.holderPid}) — nothing to do.`);
270
+ } else {
271
+ console.error(`[agent] relay-client: another instance is already running for this account (pid ${conflict.holderPid}, ${DATA_DIR}).`);
214
272
  console.error('[agent] Running two at once is exactly what causes the relay mutual-kick loop — stop the other one first.');
215
273
  console.error('[agent] If you are certain (e.g. taking over by hand), set RELAY_CLIENT_FORCE=1 and re-run.');
216
- process.exit(1);
217
274
  }
218
- writeLock();
275
+ process.exit(1);
276
+ }
277
+
278
+ if (!process.env.RELAY_CLIENT_NO_START) {
279
+ await acquireLockOrExit();
219
280
  process.on('exit', releaseLock);
220
281
  for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { releaseLock(); process.exit(0); });
221
282
 
@@ -248,4 +309,4 @@ if (!process.env.RELAY_CLIENT_NO_START) {
248
309
  } catch { /* fs.watch unavailable on this platform */ }
249
310
  }
250
311
 
251
- export { redialNow, handleTakeover };
312
+ export { redialNow, handleTakeover, takeOverFromHolder };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.22",
3
+ "version": "0.10.23",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {
@@ -13,13 +13,17 @@
13
13
  },
14
14
  "license": "UNLICENSED",
15
15
  "bin": {
16
- "galda": "bin/manager-for-ai.mjs"
16
+ "galda": "bin/manager-for-ai.mjs",
17
+ "galda-mcp": "engine/mcp.mjs"
17
18
  },
18
19
  "files": [
19
20
  "app/",
20
21
  "engine/",
21
22
  "bin/manager-for-ai.mjs",
22
23
  "examples/",
24
+ ".claude-plugin/",
25
+ "SKILL.md",
26
+ ".mcp.json",
23
27
  "README.md",
24
28
  "CLAUDE.md",
25
29
  "!engine/chat-runs",