@galda/cli 0.10.113 → 0.10.115

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,33 @@
1
+ # Galda CLI license
2
+
3
+ Copyright (c) 2026 Kodo Inc. All rights reserved.
4
+
5
+ This software is proprietary and source-available. The source is distributed
6
+ so that you can read, audit, and verify what runs on your machine. It is not
7
+ open source.
8
+
9
+ ## You may
10
+
11
+ - Install and run this software, for personal or commercial purposes, subject
12
+ to a valid Galda subscription where one is required.
13
+ - Read, inspect, and audit the source code, including for security review.
14
+ - Modify it locally for your own use.
15
+
16
+ ## You may not
17
+
18
+ - Redistribute, resell, sublicense, or host this software, in whole or in
19
+ part, whether modified or unmodified.
20
+ - Use the source code to build or operate a competing product or service.
21
+ - Remove or obscure this notice.
22
+
23
+ ## No warranty
24
+
25
+ This software is provided "as is", without warranty of any kind, express or
26
+ implied, including but not limited to the warranties of merchantability,
27
+ fitness for a particular purpose, and non-infringement. In no event shall the
28
+ copyright holder be liable for any claim, damages, or other liability arising
29
+ from, out of, or in connection with this software or its use.
30
+
31
+ ## Contact
32
+
33
+ https://galda.app
package/app/index.html CHANGED
@@ -2,7 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
6
6
  <title>Galda</title>
7
7
  <script>
8
8
  // app/index.html is a server-backed SPA, not a standalone artifact. Rendering the
@@ -31,7 +31,7 @@
31
31
  /* Design tokens + light/dark palette live in app/theme.css (design/CDO role).
32
32
  Everything below consumes those tokens; never hard-code colors here. */
33
33
  *{box-sizing:border-box}
34
- html,body{height:100%}
34
+ html,body{height:100%;overflow-x:hidden;overscroll-behavior:none}
35
35
  body{margin:0;background:var(--canvas);color:var(--ink);font-family:var(--ui);
36
36
  font-size:14px;font-variant-numeric:tabular-nums;-webkit-font-smoothing:antialiased;display:flex;flex-direction:column;overflow:hidden}
37
37
  button{font:inherit}
@@ -333,6 +333,14 @@
333
333
  color:var(--ink2);font:inherit;font-size:12px;cursor:pointer;text-align:left;width:100%}
334
334
  .appopt:hover{background:rgba(var(--tint),.06);color:var(--ink)}
335
335
  .appopt.active{color:var(--ink);background:var(--hair2)}
336
+ /* A chooser is already open somewhere: dim the glyph, no surface, and no hover
337
+ answer — the row must not look pressable while it cannot open a second one. */
338
+ .appopt:disabled{cursor:default}
339
+ .appopt:disabled,.appopt:disabled .nm{color:var(--ink3)}
340
+ .appopt:disabled:hover{background:transparent}
341
+ /* the row's own rule sets .nm to full --ink, so say it again at that weight */
342
+ .ctxmenu .appopt.ctxopen:disabled .nm{color:var(--ink3)}
343
+ .ctxmenu .appopt.ctxopen:disabled svg{opacity:.45}
336
344
  .appopt svg{width:13px;height:13px;stroke:currentColor;stroke-width:1.7;fill:none;stroke-linecap:round;stroke-linejoin:round}
337
345
  .modelsel{position:relative}
338
346
  .modelbtn{font-family:var(--mono);font-size:10.5px;text-transform:capitalize}
@@ -2582,7 +2590,7 @@
2582
2590
  magnification / uniform brighten (§5.5: whole-panel uniform, no dark tint).
2583
2591
  DOM panel backgrounds/shadows/blur are stripped; text reads via R2 "wells"
2584
2592
  (local re-blur + tint behind dense text groups) + --tsh. ---- */
2585
- #glassgl{position:fixed;inset:0;width:100vw;height:100vh;display:none;z-index:-1;pointer-events:none}
2593
+ #glassgl{position:fixed;inset:0;width:100dvw;height:100dvh;display:none;z-index:-1;pointer-events:none}
2586
2594
  body[data-theme="glass"]{background:#0a0a0b}
2587
2595
  body[data-theme="glass"][data-layout="flagship"]{background:transparent} /* WebGL draws the bg (photo is NOT the CSS #themebg here) */
2588
2596
  body[data-theme="glass"][data-layout="flagship"] #glassgl{display:block}
@@ -2654,7 +2662,7 @@
2654
2662
  /* ---- TB縁アニメ canvas (§6.5 確定: 既定=パルス×2番目色・TBのみ周回・Queは含まない).
2655
2663
  Same overlay recipe as the flagship #fx: fixed, pointer-transparent, drawn by the
2656
2664
  TBEDGE rAF loop (JS) which runs only while an animation is selected. ---- */
2657
- #fx{position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:9}
2665
+ #fx{position:fixed;inset:0;width:100dvw;height:100dvh;pointer-events:none;z-index:9}
2658
2666
  /* ---- TB面 (compbg) × flagship: the layout/theme base rules above outrank the generic
2659
2667
  preset rule (cascade order), so re-layer the tint over each base surface here at the
2660
2668
  end of the sheet. §6.5: TB面は全テーマに重ねられる. ---- */
@@ -2834,7 +2842,7 @@
2834
2842
  moves Review/To Do into a right drawer, and exposes project switching from
2835
2843
  the top-left current-project button. Desktop flagship (>640px) is untouched. ---- */
2836
2844
  @media (max-width: 640px){
2837
- html,body{height:100dvh}
2845
+ html,body{height:100dvh;overflow:hidden;overscroll-behavior:none;touch-action:pan-y}
2838
2846
  body[data-layout="flagship"]{overflow:hidden}
2839
2847
  body[data-layout="flagship"] #fsRoot{height:100dvh;flex-direction:column;padding:8px 24px max(14px, calc(env(safe-area-inset-bottom) + 10px));gap:8px;
2840
2848
  overflow:hidden;position:relative;--fscenterw:100%} /* Masa: side margins — narrower cards (8→18px) */
@@ -2886,7 +2894,7 @@
2886
2894
  /* Masa: composer was too tall — compact it (ChatGPT-like). Controls + send stay on ONE row
2887
2895
  (nowrap; the model/agent chips shrink first, the send never shrinks). */
2888
2896
  body[data-layout="flagship"] #fsRoot .composer{padding:8px 10px;gap:6px;border-radius:14px}
2889
- body[data-layout="flagship"] #fsRoot .composer textarea{font-size:15px;line-height:1.3;padding:1px 2px 0;max-height:90px}
2897
+ body[data-layout="flagship"] #fsRoot .composer textarea{font-size:16px;line-height:1.3;padding:1px 2px 0;max-height:90px}
2890
2898
  body[data-layout="flagship"] #fsRoot .crow{flex-wrap:nowrap;gap:6px}
2891
2899
  /* collapse the agent button to its icon (drop the "Claude Code" label) so the row fits
2892
2900
  one line with the send fully visible; model + effort keep their text. */
@@ -11476,7 +11484,9 @@ function renderCtxBar(){
11476
11484
  // LRM, so the rtl clipping trims the HEAD of the path without reversing the text
11477
11485
  + `<span class="pt">\u200e${esc(folderShort(r.dir))}</span></button>`)
11478
11486
  .join('');
11479
- const openRow = `<button type="button" class="appopt ctxopen" id="ctxopenfolder" title="Open the OS folder chooser"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><path d="M12 11v6M9 14h6"/></svg><span class="nm">Open folder…</span></button>`;
11487
+ // Redrawing the menu must not hand back a second chooser: carry the disabled
11488
+ // state that setFolderPickerBusy() put on the live button.
11489
+ const openRow = `<button type="button" class="appopt ctxopen${folderPickerBusy ? ' busy' : ''}" id="ctxopenfolder"${folderPickerBusy ? ' disabled' : ''} title="Open the OS folder chooser"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><path d="M12 11v6M9 14h6"/></svg><span class="nm">Open folder…</span></button>`;
11480
11490
  menu.innerHTML = `${opts}${openRow}<div class="ctxmsg" id="ctxdirmsg"></div>`;
11481
11491
  }
11482
11492
  // Workspace mode is fixed to the isolated worktree (clean separate checkout). The
@@ -11522,9 +11532,22 @@ async function setProjectFolder(dir){
11522
11532
  // project at the chosen path. This is also correct through the authenticated
11523
11533
  // relay: it forwards the request to the account owner's loopback engine, so the
11524
11534
  // picker opens on that owner's Mac rather than on the hosted relay.
11535
+ // One chooser at a time. The dialog opens over in the engine process and can take
11536
+ // a moment to appear (and may not come to the front at all), so a second tap used
11537
+ // to open a second dialog: ten taps left someone with ten dialogs to dismiss. The
11538
+ // request stays in flight for as long as the dialog is up, so "a request is out"
11539
+ // is exactly "a dialog is open" — no timer to guess with.
11540
+ let folderPickerBusy = false;
11541
+ function setFolderPickerBusy(busy){
11542
+ folderPickerBusy = busy;
11543
+ const btn = $('ctxopenfolder');
11544
+ if (btn) { btn.disabled = busy; btn.classList.toggle('busy', busy); }
11545
+ }
11525
11546
  async function openFolderNative(){
11547
+ if (folderPickerBusy) return;
11526
11548
  const msg = $('ctxdirmsg');
11527
11549
  const say = (t, bad) => { if (msg) { msg.textContent = t; msg.classList.toggle('bad', Boolean(bad)); } };
11550
+ setFolderPickerBusy(true);
11528
11551
  say('Opening the folder chooser…');
11529
11552
  try {
11530
11553
  const r = await fetch(withKey('/api/choose-folder'), { method: 'POST' });
@@ -11534,6 +11557,7 @@ async function openFolderNative(){
11534
11557
  if (body.unsupported) { say('This engine can’t open a folder dialog on this system.', true); return; }
11535
11558
  say(body.error || 'Could not open the folder chooser.', true);
11536
11559
  } catch { say('Could not reach the engine.', true); }
11560
+ finally { setFolderPickerBusy(false); }
11537
11561
  }
11538
11562
  $('ctxdirbtn').onclick = (e) => {
11539
11563
  e.stopPropagation();
@@ -149,21 +149,56 @@ await import(pathToFileURL(join(ROOT, 'engine', 'server.mjs')).href);
149
149
  // stable `<id>.<domain>` from any device. Local-first is preserved — the agent
150
150
  // still runs here; the relay is only a door. Opt-in via RELAY_URL for now; the
151
151
  // public build will default it to the deployed relay. See relay/DEPLOY.md.
152
+ // The user can turn remote access OFF in Settings (docs/SECURITY.md). That is
153
+ // the discoverable form of `RELAY_URL=`: no relay child, no outbound socket.
154
+ // The switch is written by the SERVER process, so we read the file here and
155
+ // watch it, letting the toggle take effect without restarting the CLI.
156
+ const { readRelayPref, resolveRelayEnabled, RELAY_PREF_FILE } =
157
+ await import(pathToFileURL(join(ROOT, 'engine', 'relay-pref.mjs')).href);
158
+ // Same resolution the server and relay-client use, spelled out here because the
159
+ // DATA_DIR above is scoped to the single-instance check.
160
+ const RELAY_DATA_DIR = process.env.MANAGER_HOME
161
+ ?? (existsSync(join(ROOT, '.git')) ? join(ROOT, 'engine') : join(homedir(), '.manager-for-ai'));
162
+
152
163
  if (process.env.RELAY_URL) {
153
164
  const { spawn } = await import('node:child_process');
154
165
  const { superviseRelay } = await import(pathToFileURL(join(ROOT, 'engine', 'relay-supervisor.mjs')).href);
166
+ const { watch } = await import('node:fs');
167
+
168
+ let sup = null; // non-null exactly while the relay child is supervised
169
+
155
170
  // Supervise the relay-client: if the CHILD exits (uncaught throw, single-
156
171
  // instance lock refuse) we bring it back with backoff, otherwise the app goes
157
172
  // silently offline ("Unsent"). A tight crash loop (a config/lock error) trips a
158
173
  // circuit breaker so we stop hammering and say why. relay-client heals its own
159
174
  // dropped socket; this heals the whole process dying. See engine/relay-supervisor.mjs.
160
- const sup = superviseRelay({
161
- spawn: () => spawn(process.execPath, [join(ROOT, 'engine', 'relay-client.mjs')], { stdio: 'inherit', env: process.env }),
162
- log: ({ code, signal, delayMs }) => say(`relay-client exited (code ${code ?? 'n/a'}${signal ? `, ${signal}` : ''}) — restarting in ${Math.round(delayMs / 1000)}s`),
163
- onGiveUp: () => say('relay-client keeps exiting immediately giving up; run `npx @galda/cli --signin` or check RELAY_URL'),
164
- });
165
- const stop = () => sup.stop();
166
- process.on('exit', stop);
167
- for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { stop(); process.exit(0); });
168
- say(`relay: connecting to ${process.env.RELAY_URL} — your fixed URL goes live once you sign in`);
175
+ const startRelay = () => {
176
+ if (sup) return;
177
+ sup = superviseRelay({
178
+ spawn: () => spawn(process.execPath, [join(ROOT, 'engine', 'relay-client.mjs')], { stdio: 'inherit', env: process.env }),
179
+ log: ({ code, signal, delayMs }) => say(`relay-client exited (code ${code ?? 'n/a'}${signal ? `, ${signal}` : ''}) — restarting in ${Math.round(delayMs / 1000)}s`),
180
+ onGiveUp: () => say('relay-client keeps exiting immediately — giving up; run `npx @galda/cli --signin` or check RELAY_URL'),
181
+ });
182
+ say(`relay: connecting to ${process.env.RELAY_URL} your fixed URL goes live once you sign in`);
183
+ };
184
+ const stopRelay = () => { sup?.stop(); sup = null; };
185
+
186
+ const applyPref = () => {
187
+ const want = resolveRelayEnabled({ relayUrl: process.env.RELAY_URL, pref: readRelayPref(RELAY_DATA_DIR) });
188
+ if (want && !sup) startRelay();
189
+ else if (!want && sup) { stopRelay(); say('relay: remote access turned off in Settings — no outbound connection'); }
190
+ };
191
+
192
+ applyPref();
193
+ if (!sup) say('relay: remote access is off (Settings) — running fully local');
194
+
195
+ // The switch is written by the SERVER process (POST /api/relay-pref), so the
196
+ // only way we learn about a click is to watch the file. Same cross-process
197
+ // idiom as relay-status.json / relay-takeover.req.
198
+ try {
199
+ watch(RELAY_DATA_DIR, (_e, name) => { if (name === RELAY_PREF_FILE) applyPref(); }).unref();
200
+ } catch { /* watching is a nicety: the choice still applies on next start */ }
201
+
202
+ process.on('exit', stopRelay);
203
+ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { stopRelay(); process.exit(0); });
169
204
  }
@@ -0,0 +1,183 @@
1
+ # Security and permissions
2
+
3
+ What Galda runs on your machine, what it can touch, and what leaves it.
4
+
5
+ Every claim below is a statement about the code in this package. Source
6
+ references are given so you can check them yourself in the published tarball
7
+ (`npm pack @galda/cli`).
8
+
9
+ 日本語の要約は末尾の「日本語」節にあります。
10
+
11
+ ## What Galda is
12
+
13
+ Galda does not contain an AI model and does not talk to one. It starts the
14
+ Claude Code or Codex CLI that is already installed and signed in on your
15
+ machine, and gives you a place to queue work and review the result.
16
+
17
+ That has a direct consequence for your code: **your code is never uploaded to
18
+ Galda for inference.** Claude Code and Codex connect to Anthropic or OpenAI
19
+ directly, on your own subscription. Galda is not in that path and never sees
20
+ that traffic.
21
+
22
+ ## Installation
23
+
24
+ **Galda has no install scripts.** `package.json` declares only `start` and
25
+ `test`; there is no `preinstall`, `install`, `postinstall`, or `prepare`.
26
+ Nothing in this package executes as a side effect of `npm install` or `npx`.
27
+ Code runs only when you start Galda yourself.
28
+
29
+ To read the code before running it:
30
+
31
+ ```
32
+ npm pack @galda/cli # downloads the tarball, runs nothing
33
+ tar -xzf galda-cli-*.tgz # everything Galda ships is plain JavaScript
34
+ ```
35
+
36
+ ## The local server
37
+
38
+ Galda runs an HTTP server on your machine.
39
+
40
+ - It binds to **loopback only** (`127.0.0.1` and `::1`), so it is not reachable
41
+ from your network. See `engine/server.mjs`.
42
+ - Every request requires an access key stored at `~/.manager-for-ai/secret.key`.
43
+ - All state lives in `~/.manager-for-ai/`. Nothing is written elsewhere.
44
+
45
+ To remove Galda completely:
46
+
47
+ ```
48
+ rm -rf ~/.manager-for-ai
49
+ ```
50
+
51
+ That is the whole uninstall. Galda installs no daemons, no login items, no
52
+ kernel extensions, and no browser extensions.
53
+
54
+ ## What the agent is allowed to do
55
+
56
+ Galda does not hand Claude Code or Codex a blank cheque. The tool set is an
57
+ explicit allowlist (`engine/lib.mjs`, `WORKER_TOOLS`).
58
+
59
+ **The agent can:**
60
+
61
+ - Read and edit files, through the `Edit` / `Write` / `Read` / `Glob` / `Grep`
62
+ tools, inside the working copy for the task
63
+ - Run named commands only: language runtimes and package managers (`node`,
64
+ `npm`, `npx`, `pnpm`, `yarn`, `bun`, `deno`, `python`, `pip`, `go`, `cargo`,
65
+ `ruby`, `make`), test and lint runners (`pytest`, `jest`, `vitest`, `tsc`,
66
+ `eslint`, `prettier`), and read-only shell utilities (`cat`, `ls`, `grep`,
67
+ `find`, `diff`, and similar)
68
+ - Read the web (`WebFetch`, `WebSearch`)
69
+
70
+ **The agent cannot:**
71
+
72
+ - Run arbitrary shell commands. `Bash` is not granted; only the named commands
73
+ above are, each as an explicit allowlist entry
74
+ - **Commit, push, checkout, reset, or otherwise change your repository state.**
75
+ Git is granted read-only: `git status`, `git log`, `git diff`, `git show`,
76
+ `git branch`, `git blame`, `git ls-files`, `git rev-parse`. Nothing else
77
+ - Edit files through the shell. File changes go through the `Edit` / `Write`
78
+ tools, so every change appears in the diff you review
79
+
80
+ **Work is isolated from your working tree.** Each task runs in a throwaway git
81
+ worktree, not in the checkout you have open. Your branch, your staged changes,
82
+ and your uncommitted work are not touched.
83
+
84
+ ## What leaves your machine
85
+
86
+ Three separate paths. They are worth keeping apart.
87
+
88
+ ### 1. Model traffic — does not involve Galda
89
+
90
+ Claude Code and Codex talk to Anthropic and OpenAI directly, using the
91
+ subscription already configured on your machine. Galda neither proxies nor
92
+ observes this. Their data handling is governed by their terms, not ours.
93
+
94
+ ### 2. The relay — on by default, and you can turn it off
95
+
96
+ By default Galda opens an outbound WebSocket to `app.galda.app`. This is what
97
+ lets you open your board from your phone without a tunnel, port forwarding, or
98
+ ngrok.
99
+
100
+ Be clear about what this means. The relay is a reverse HTTP tunnel: a request
101
+ made from `app.galda.app` is forwarded over that socket, replayed against your
102
+ local server, and the response body is streamed back
103
+ (`engine/relay-client.mjs`).
104
+
105
+ **Therefore everything your board displays passes through Galda's relay
106
+ server:** task text, activity logs, review summaries, diffs, and proof
107
+ screenshots.
108
+
109
+ The connection is TLS-encrypted in transit. **It is not end-to-end encrypted**,
110
+ which means Galda's relay server is in the path and could read what crosses it.
111
+ We would rather write that down than let you assume otherwise. End-to-end
112
+ encryption is on the roadmap and is not implemented today.
113
+
114
+ **To turn the relay off entirely:**
115
+
116
+ ```
117
+ RELAY_URL= npx @galda/cli
118
+ ```
119
+
120
+ With no relay URL, the relay client is never started and no outbound socket is
121
+ opened (`bin/manager-for-ai.mjs`). You lose remote access from your phone.
122
+ Everything else works exactly the same.
123
+
124
+ ### 3. Your Galda account — licensing
125
+
126
+ Galda checks your license. The verifying public key is compiled into the
127
+ package, so the check itself is offline, but the token is refreshed
128
+ periodically against the billing API. This carries your account identity, not
129
+ your code.
130
+
131
+ ## Analytics
132
+
133
+ Galda records a small set of product events so we can see how the tool is
134
+ actually used and improve it. The list is exhaustive. If it is not named here,
135
+ we do not collect it.
136
+
137
+ **What we record:**
138
+
139
+ | Event | Purpose |
140
+ |---|---|
141
+ | `signup`, `signin` | Account lifecycle |
142
+ | `task_created`, `task_completed`, `task_failed`, `task_interrupted` | Completion and failure rates, so we can find what breaks |
143
+ | `review_approved`, `review_dismissed` | Whether results are actually usable |
144
+ | Which agent ran (Claude Code or Codex) | Where to spend engineering time |
145
+ | App version, OS, Node version | Compatibility and regression tracking |
146
+ | Crash and error events | Fixing crashes |
147
+
148
+ Each carries a timestamp and a pseudonymous account id, and nothing else.
149
+
150
+ **What we never record:**
151
+
152
+ - The text of your requests
153
+ - Your code, diffs, file contents, or file names
154
+ - Repository names or paths
155
+ - Proof screenshots and recordings
156
+
157
+ **Paid business plans: analytics are off.** On business plans no product events
158
+ are collected at all, the way Cursor forces Privacy Mode on for teams. You do
159
+ not have to configure anything.
160
+
161
+ **With the relay off, nothing is collected.** Not reduced, not anonymized:
162
+ nothing. A mode sold as "nothing leaves your machine" that quietly reported
163
+ usage would invalidate every other claim on this page, so it does not.
164
+
165
+ ## Reporting a vulnerability
166
+
167
+ <!-- TODO(Masa): security contact address. -->
168
+
169
+ ## 日本語
170
+
171
+ - Galda は AI モデルを内蔵せず、モデルと通信もしません。すでにあなたのマシンに
172
+ 入っている Claude Code / Codex を起動するだけです。**あなたのコードが推論の
173
+ ために Galda へ送られることはありません。**
174
+ - **インストール時に何も実行しません**(`postinstall` 等が存在しません)。
175
+ - ローカルサーバは **`127.0.0.1` と `::1` にのみ bind** し、ネットワークからは
176
+ 到達できません。アクセスキーが必須です。
177
+ - エージェントに渡している **git は読み取り専用**です。`commit` `push`
178
+ `checkout` `reset` を持ちません。任意のシェル実行もできません。
179
+ - 各タスクは**使い捨ての git worktree** で動き、あなたの作業ツリーには触れません。
180
+ - 既定では、スマホから使えるように **ボードの表示内容が `app.galda.app` の
181
+ relay を通ります**(TLS 保護、**E2E 暗号化は未実装**=経路上に Galda の
182
+ サーバがいます)。`RELAY_URL= npx @galda/cli` で relay を完全に切れます。
183
+ - アンインストールは `rm -rf ~/.manager-for-ai` だけです。
package/engine/lib.mjs CHANGED
@@ -897,7 +897,14 @@ export function parseStreamEvents(line) {
897
897
  const arg = i.file_path ?? i.path ?? i.pattern ?? i.command ?? i.url ?? i.query ?? '';
898
898
  out.push({ kind: 'activity', text: `${p.name} ${String(arg)}`.trim().slice(0, 140) });
899
899
  } else if (p.type === 'text' && p.text?.trim()) {
900
- out.push({ kind: 'activity', text: `✎ ${p.text.trim().replace(/\s+/g, ' ').slice(0, 110)}` });
900
+ // The worker talking, as opposed to the worker working. It was flattened
901
+ // into the same ✎-prefixed, 110-char activity line as every tool call,
902
+ // which is why a person who sent a request sat watching a tool log with
903
+ // nothing in it that reads as an answer (Masa's friend, 2026-07-31: three
904
+ // minutes and "no reply"). Kept whole and kept separate here; the caller
905
+ // still writes the short activity line, and can also show the first one as
906
+ // what it is — the agent's first words back.
907
+ out.push({ kind: 'say', text: p.text.trim() });
901
908
  }
902
909
  }
903
910
  return out;
@@ -4858,20 +4865,14 @@ export function shouldEmitSetupCompleted({ isMcpClient, alreadySeen, flagExists
4858
4865
  return Boolean(isMcpClient) && !alreadySeen && !flagExists;
4859
4866
  }
4860
4867
 
4861
- // Onboarding funnel: a goal can only produce real changes if it runs inside a
4862
- // real code repo. When the agent is started ad-hoc (e.g. `node bin/...` in the
4863
- // user's HOME), the seeded default project points at a non-repo dir the
4864
- // worker no-ops (nothing to edit) yet the goal is marked "done" the exact
4865
- // blocker for "実装が始まる". This decides, BEFORE running, whether we must ask
4866
- // the user which folder to work in. `isRepo` is passed in (the git probe is
4867
- // I/O, done by the caller) so this stays pure/testable. Home is never a valid
4868
- // work root even a git-init'd home dir is "no project chosen yet".
4869
- export function needsProjectFolder({ dir, homeDir, isRepo } = {}) {
4870
- if (!dir) return true;
4871
- const norm = (p) => String(p).replace(/\/+$/, '');
4872
- if (homeDir && norm(dir) === norm(homeDir)) return true;
4873
- return !isRepo;
4874
- }
4868
+ // `needsProjectFolder` lived here until 2026-07-31. It decided, before running,
4869
+ // that a folder was not good enough to work in (HOME, or not a git repo) and
4870
+ // that the person had to answer a question first. Masa removed the question:
4871
+ // `claude -p` in a plain folder simply works measuredso Galda asking is a
4872
+ // toll gate of our own invention, and the folder is now whatever the CLI was
4873
+ // started in, changeable on the composer chip by whoever cares. The builders
4874
+ // below stay: an install that was mid-question when it updated must still be
4875
+ // able to answer the one it already has.
4875
4876
 
4876
4877
  // The repo's own name: the last segment of its path. The composer chip prints this and
4877
4878
  // the folder menu prints it again on every row, so it lives here as one function — two
@@ -0,0 +1,57 @@
1
+ // Agent Manager — remote-access preference (the relay on/off switch)
2
+ //
3
+ // WHY THIS EXISTS: the relay is what lets you open your board from your phone,
4
+ // and it is on by default. But everything the board displays crosses Galda's
5
+ // relay server (see docs/SECURITY.md), so some people cannot or will not run
6
+ // it — a security policy that forbids outbound connections, or simply not
7
+ // wanting a third party in the path.
8
+ //
9
+ // That escape hatch already existed as `RELAY_URL= npx @galda/cli`, which is
10
+ // an env var nobody discovers. This makes it a setting, the way Cursor puts
11
+ // Privacy Mode in Settings rather than on the command line.
12
+ //
13
+ // The preference lives in a file in DATA_DIR because the two halves are
14
+ // SEPARATE PROCESSES: the server (:4400) takes the click, the launcher owns
15
+ // the relay child. Same idiom as relay-status.json / relay-takeover.req.
16
+
17
+ import { readFileSync, writeFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+
20
+ export const RELAY_PREF_FILE = 'relay-pref.json';
21
+
22
+ export function relayPrefPath(dataDir) {
23
+ return join(dataDir, RELAY_PREF_FILE);
24
+ }
25
+
26
+ // Should the relay run? Pure — no clock, no I/O, no env.
27
+ //
28
+ // relayUrl : the configured relay endpoint ('' / undefined = none built in)
29
+ // pref : the user's saved choice (true | false | null when never set)
30
+ //
31
+ // Default is ON: a fresh install gets remote access, because that is the
32
+ // product. Only an explicit `false` turns it off, so a corrupt or missing
33
+ // preference file can never silently disable the feature.
34
+ export function resolveRelayEnabled({ relayUrl, pref } = {}) {
35
+ if (!relayUrl) return false;
36
+ return pref !== false;
37
+ }
38
+
39
+ // Read the saved preference. Returns null when the user has never chosen, so
40
+ // callers can tell "unset" from "explicitly on" — resolveRelayEnabled treats
41
+ // both as on, but the UI may want to say so differently.
42
+ export function readRelayPref(dataDir) {
43
+ try {
44
+ const raw = JSON.parse(readFileSync(relayPrefPath(dataDir), 'utf8'));
45
+ return typeof raw?.enabled === 'boolean' ? raw.enabled : null;
46
+ } catch {
47
+ return null; // never set, unreadable, or corrupt — all mean "no choice made"
48
+ }
49
+ }
50
+
51
+ // Save the preference. Throws on a real write failure so the endpoint can
52
+ // report it rather than telling the user it saved something it did not.
53
+ export function writeRelayPref(dataDir, enabled) {
54
+ const value = enabled === true;
55
+ writeFileSync(relayPrefPath(dataDir), JSON.stringify({ enabled: value, at: Date.now() }) + '\n');
56
+ return value;
57
+ }
package/engine/server.mjs CHANGED
@@ -20,10 +20,11 @@ import { resolve, dirname, join, basename } from 'node:path';
20
20
  import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
23
- import { needsProjectFolder, folderLabel, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse, isCommandOnPath, hostReadiness, parseRequestedWorkspaceDir } from './lib.mjs';
23
+ import { folderLabel, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse, isCommandOnPath, hostReadiness, parseRequestedWorkspaceDir } from './lib.mjs';
24
24
  import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildReviewAttemptLedger, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, buildQueueWaits, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, isNothingVerifiableForPR, isSuspiciouslyIncompleteDone, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, REQUIREMENT_EVIDENCE_FILE, parseRequirementEvidenceDocument, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession, parseApprovalRequest } from './lib.mjs';
25
25
  import { migrateRequirementModel, validateRequirementModel } from './requirement-model.mjs';
26
26
  import { buildManagerVerificationChecks, buildRequirementVerificationEvidence, mergeRequirementVerificationEvidence } from './requirement-verification.mjs';
27
+ import { readRelayPref, writeRelayPref, resolveRelayEnabled } from './relay-pref.mjs';
27
28
  import { createSerialQueue } from './lib.mjs';
28
29
  import { wantsPullRequest } from './lib.mjs';
29
30
  import { parseRequirementEvidence, unmappedChangedFiles, previewOpensSomethingElse } from './lib.mjs';
@@ -957,6 +958,26 @@ function addGoalMessage(goal, { from, via, text, at } = {}) {
957
958
  saveGoal(goal);
958
959
  return msg;
959
960
  }
961
+ // The agent's first words back, put where a person looks for an answer.
962
+ //
963
+ // A request used to produce nothing that reads as a reply until the run ended —
964
+ // minutes later. The output was never missing (every tool call streams into the
965
+ // run log), but the run log is a log: `✎ …` lines clipped to 110 characters,
966
+ // interleaved with `Edit app.js`. Masa's friend sent one message and waited three
967
+ // minutes for something that never came, because the thing that came did not look
968
+ // like an answer.
969
+ //
970
+ // So the FIRST thing the worker says on a run is recorded as its message on the
971
+ // goal, verbatim — the same place its final report already lands. Galda writes
972
+ // nothing of its own here and picks nothing: what to open with is the agent's
973
+ // choice, and this only carries it.
974
+ const workerOpenedWith = new Set();
975
+ function sayFromWorker(goal, task, text) {
976
+ if (!goal || !task || workerOpenedWith.has(task.id)) return;
977
+ workerOpenedWith.add(task.id);
978
+ addGoalMessage(goal, { from: 'ai', via: task.reply ? 'thread' : 'composer', text });
979
+ }
980
+
960
981
  function readGoalMessages(goalId) {
961
982
  try { return parseGoalMessages(readFileSync(goalMessagesFile(goalId), 'utf8')); }
962
983
  catch { return []; }
@@ -1270,7 +1291,7 @@ function sendTodos(t, todos) {
1270
1291
  }
1271
1292
 
1272
1293
  // ---- claude helpers --------------------------------------------------------
1273
- function runClaude({ prompt, cwd, tools, onEvent, onTodos, resume, model, effort, permissionMode = 'acceptEdits', onChild }) {
1294
+ function runClaude({ prompt, cwd, tools, onEvent, onTodos, onSay, resume, model, effort, permissionMode = 'acceptEdits', onChild }) {
1274
1295
  return new Promise((done) => {
1275
1296
  const args = [
1276
1297
  '-p', prompt,
@@ -1298,6 +1319,13 @@ function runClaude({ prompt, cwd, tools, onEvent, onTodos, resume, model, effort
1298
1319
  if (ev.kind === 'result') { result = ev.text; usage = ev.usage; }
1299
1320
  else if (ev.kind === 'session') sessionId = ev.id;
1300
1321
  else if (ev.kind === 'todos') onTodos?.(ev.todos);
1322
+ else if (ev.kind === 'say') {
1323
+ // Two things from one event: the short line the run log has always
1324
+ // shown, and the words themselves, so the caller can put the first
1325
+ // ones in front of the person as an actual reply.
1326
+ const short = `✎ ${ev.text.replace(/\s+/g, ' ').slice(0, 110)}`;
1327
+ lastAct = short; onEvent?.(short); onSay?.(ev.text);
1328
+ }
1301
1329
  else { if (ev.text?.trim()) lastAct = ev.text.trim(); onEvent?.(ev.text); }
1302
1330
  }
1303
1331
  }
@@ -1313,7 +1341,7 @@ function runClaude({ prompt, cwd, tools, onEvent, onTodos, resume, model, effort
1313
1341
  // The Codex half of the worker bridge. Same contract as runClaude, because
1314
1342
  // nothing downstream may care which agent ran: live activity, the agent's own
1315
1343
  // to-dos (the board), the thread id to talk to it again, usage, final message.
1316
- function runCodex({ prompt, cwd, onEvent, onTodos, resume, permissionMode = 'acceptEdits', model, effort, images, onChild }) {
1344
+ function runCodex({ prompt, cwd, onEvent, onTodos, onSay, resume, permissionMode = 'acceptEdits', model, effort, images, onChild }) {
1317
1345
  return new Promise((done) => {
1318
1346
  const sandbox = permissionMode === 'plan' ? 'read-only' : 'workspace-write';
1319
1347
  const args = buildCodexArgs({ model: workerModel('codex', model), effort: workerEffort('codex', effort), sandbox, cwd, resume, images });
@@ -1342,6 +1370,7 @@ function runCodex({ prompt, cwd, onEvent, onTodos, resume, permissionMode = 'acc
1342
1370
  else if (ev.kind === 'message') {
1343
1371
  result = ev.text;
1344
1372
  onEvent?.(`✎ ${ev.text.trim().replace(/\s+/g, ' ').slice(0, 500)}`);
1373
+ onSay?.(ev.text);
1345
1374
  } else if (ev.kind === 'error') {
1346
1375
  err += `${ev.text}\n`;
1347
1376
  onEvent?.(`Error ${ev.text}`);
@@ -3003,6 +3032,7 @@ async function runTask(task) {
3003
3032
  model: runModel, effort: runEffort, onEvent: (line) => sendAct(task, line),
3004
3033
  images: goal?.images,
3005
3034
  onTodos: (todos) => sendTodos(task, todos),
3035
+ onSay: (text) => sayFromWorker(goal, task, text),
3006
3036
  onChild: (c) => trackGoalWorker(goal?.id, c),
3007
3037
  });
3008
3038
  if (goal?.cancelled) return; // goal deleted mid-run → don't resurrect the task / run the gate
@@ -3092,6 +3122,7 @@ async function runTask(task) {
3092
3122
  model: runModel, effort: runEffort, onEvent: (line) => sendAct(task, line),
3093
3123
  images: goal?.images,
3094
3124
  onTodos: (todos) => sendTodos(task, todos),
3125
+ onSay: (text) => sayFromWorker(goal, task, text),
3095
3126
  onChild: (c) => trackGoalWorker(goal?.id, c),
3096
3127
  });
3097
3128
  if (goal?.cancelled) return; // deleted mid-run → stop, don't run verify/PR
@@ -3121,6 +3152,7 @@ async function runTask(task) {
3121
3152
  model: runModel, effort: runEffort, onEvent: (line) => sendAct(task, line),
3122
3153
  images: goal?.images,
3123
3154
  onTodos: (todos) => sendTodos(task, todos),
3155
+ onSay: (text) => sayFromWorker(goal, task, text),
3124
3156
  onChild: (c) => trackGoalWorker(goal?.id, c),
3125
3157
  });
3126
3158
  if (goal?.cancelled) return;
@@ -4436,6 +4468,41 @@ const server = createServer(async (req, res) => {
4436
4468
  catch (e) { return json(res, 500, { error: String(e?.message ?? e) }); }
4437
4469
  return json(res, 200, { ok: true });
4438
4470
  }
4471
+ // GET/POST /api/relay-pref — the remote-access switch in Settings.
4472
+ // Remote access (the relay) is ON by default. Turning it off is the
4473
+ // discoverable form of `RELAY_URL= npx @galda/cli`: with it off no outbound
4474
+ // socket is opened and nothing your board displays crosses our server
4475
+ // (docs/SECURITY.md). The launcher owns the relay child in ANOTHER process,
4476
+ // so we persist the choice to a file it watches — same shape as
4477
+ // relay-takeover.req above.
4478
+ if (url.pathname === '/api/relay-pref' && req.method === 'GET') {
4479
+ const pref = readRelayPref(DATA_DIR);
4480
+ return json(res, 200, {
4481
+ pref,
4482
+ enabled: resolveRelayEnabled({ relayUrl: process.env.RELAY_URL, pref }),
4483
+ configured: Boolean(process.env.RELAY_URL),
4484
+ });
4485
+ }
4486
+ if (url.pathname === '/api/relay-pref' && req.method === 'POST') {
4487
+ let body = '';
4488
+ req.on('data', (d) => { body += d; });
4489
+ req.on('end', () => {
4490
+ let input;
4491
+ try { input = JSON.parse(body || '{}'); } catch { return json(res, 400, { error: 'bad json' }); }
4492
+ // A boolean and nothing else. "off"/0/undefined are how a security
4493
+ // setting silently becomes the opposite of what was clicked.
4494
+ if (typeof input?.enabled !== 'boolean') return json(res, 400, { error: 'enabled must be a boolean' });
4495
+ let saved;
4496
+ try { saved = writeRelayPref(DATA_DIR, input.enabled); }
4497
+ catch (e) { return json(res, 500, { error: String(e?.message ?? e) }); }
4498
+ return json(res, 200, {
4499
+ pref: saved,
4500
+ enabled: resolveRelayEnabled({ relayUrl: process.env.RELAY_URL, pref: saved }),
4501
+ configured: Boolean(process.env.RELAY_URL),
4502
+ });
4503
+ });
4504
+ return;
4505
+ }
4439
4506
  // POST /api/signout — forget the local license so the settings panel can
4440
4507
  // offer a plain "Log out" instead of requiring the `--signin` CLI flag to
4441
4508
  // switch Google accounts. Clears the on-disk token and the in-memory cache;
@@ -4672,19 +4739,21 @@ const server = createServer(async (req, res) => {
4672
4739
  try {
4673
4740
  const input = JSON.parse(body || '{}');
4674
4741
  if (typeof input.name === 'string' && input.name.trim()) project.name = input.name.trim().slice(0, 80);
4675
- // Moving a project to another folder. Refused loudly when the path is not
4676
- // somewhere work can happen: a folder that does not exist, or is HOME itself,
4677
- // or is not a git repository. Accepting it here would look like it worked and
4678
- // then fail silently on the next goal — the worst of the two failures, and the
4679
- // reason the "which folder?" card exists at all.
4742
+ // Moving a project to another folder. The only thing refused is a folder
4743
+ // that is not there a path with nothing behind it is a typo, and every
4744
+ // later failure would be reported somewhere far away from the mistake.
4745
+ //
4746
+ // "Not a git repository" used to be refused here too. It is not our call
4747
+ // (Masa 2026-07-31): `claude -p` in a plain folder simply works, and a
4748
+ // person who points Galda at one has said what they want. Such a folder
4749
+ // gets no isolated worktree and can produce no pull request; that is a
4750
+ // property of the folder, said where those things happen, not a reason to
4751
+ // reject the choice.
4680
4752
  if (input.dir !== undefined) {
4681
4753
  const dir = String(input.dir ?? '').trim();
4682
4754
  if (!dir) return json(res, 400, { error: 'folder is required' });
4683
4755
  const abs = dir.startsWith('~') ? join(HOME, dir.slice(1)) : resolve(dir);
4684
4756
  if (!existsSync(abs)) return json(res, 400, { error: 'no such folder', dir: abs });
4685
- if (needsProjectFolder({ dir: abs, homeDir: HOME, isRepo: isGitRepo(abs) })) {
4686
- return json(res, 400, { error: 'not a git repository', dir: abs });
4687
- }
4688
4757
  project.dir = abs;
4689
4758
  }
4690
4759
  saveProjects();
@@ -4823,24 +4892,30 @@ const server = createServer(async (req, res) => {
4823
4892
  // no PR) — the reviewer just Approves (files it) or Dismisses (drops
4824
4893
  // it). This is how "needs my judgment" reaches the phone without kicking
4825
4894
  // off an implementation run.
4826
- // Onboarding funnel (docs/HANDOFF-ONBOARDING-conversational.md): a real
4827
- // implementation goal can only produce changes inside a real code repo.
4828
- // If the active project points at a non-repo (e.g. the HOME dir when the
4829
- // agent was started ad-hoc), running would no-op — nothing to edit — yet
4830
- // report "done". Instead of that fake "fixed", ask which folder first,
4831
- // reusing the needsInput clarifying card; the answer (POST /answer)
4832
- // rebinds this goal to the chosen repo, then planning proceeds. Skipped
4833
- // for pending/review items (they never spawn a worker).
4895
+ // No folder question (Masa 2026-07-31: "フォルダは先に聞かないでいい…選びたい
4896
+ // 人が選べばいい", and asked for parity with Claude Code, which was then
4897
+ // measured: `claude -p` in a non-git folder just runs it never asks which
4898
+ // folder and never git-inits one).
4899
+ //
4900
+ // The first thing a new person met used to be an interrogation: which
4901
+ // folder, then whether they wanted PRs, before a single word of their
4902
+ // request was acted on. The folder half is gone. The folder is whatever
4903
+ // the CLI was started in, shown on the composer chip, and changeable
4904
+ // there by anyone who cares — which is the only place it was ever a real
4905
+ // choice rather than a toll gate.
4906
+ //
4907
+ // The old worry (a non-repo folder means the worker edits nothing and we
4908
+ // still say "done") is not answered by asking, and never was: goalWorkDir
4909
+ // already falls back to the project folder itself when there is no git
4910
+ // repo to cut a worktree from, so the work happens where the person is
4911
+ // looking, exactly as Claude Code does. What such a folder cannot do is
4912
+ // produce a pull request, and that is stated where PRs are decided, not
4913
+ // guarded here.
4834
4914
  const runnable = !pending && !review && !approval;
4835
- const needsFolder = runnable && needsProjectFolder({ dir: project.dir, homeDir: HOME, isRepo: isGitRepo(project.dir) });
4836
4915
  const lang = /[぀-ヿ㐀-鿿]/.test(text) ? 'ja' : 'en';
4837
- const folderQuestion = needsFolder ? buildFolderQuestion(detectRepos(HOME), { lang }) : undefined;
4838
- // Task 5 (PR運用ヒアリング): once the folder is settled, ask — once per
4839
- // project, before its first runnable goal whether this project wants
4840
- // PRs. Skipped this round if a folder question already claimed the
4841
- // needsInput slot; that goal gets rebound to a real repo via /answer
4842
- // and the NEXT goal for that project is what actually asks this.
4843
- const needsReviewPref = runnable && !needsFolder && needsReviewPreference({ askedBefore: project.reviewPrefAsked });
4916
+ // Task 5 (PR運用ヒアリング): ask once per project, before its first
4917
+ // runnable goal whether this project wants PRs.
4918
+ const needsReviewPref = runnable && needsReviewPreference({ askedBefore: project.reviewPrefAsked });
4844
4919
  const reviewPrefQuestion = needsReviewPref ? buildReviewPreferenceQuestion({ lang }) : undefined;
4845
4920
  if (needsReviewPref) { project.reviewPrefAsked = true; saveProjects(); }
4846
4921
  // Clamp to whatever agent CLIs are actually installed on this machine
@@ -4856,8 +4931,8 @@ const server = createServer(async (req, res) => {
4856
4931
  if (review && existingPr && !importedPr) return json(res, 400, { error: 'existingPr must be a GitHub pull request URL' });
4857
4932
  const goal = {
4858
4933
  id: nextId++, projectId, text: text.trim().slice(0, 8000),
4859
- status: (needsFolder || needsReviewPref) ? 'needsInput' : approval ? 'needsApproval' : review ? 'review' : pending ? 'pending' : 'stacked',
4860
- question: folderQuestion || reviewPrefQuestion,
4934
+ status: needsReviewPref ? 'needsInput' : approval ? 'needsApproval' : review ? 'review' : pending ? 'pending' : 'stacked',
4935
+ question: reviewPrefQuestion,
4861
4936
  pauseReason: approval ? 'approval_required' : undefined,
4862
4937
  approvalRequest: approval ? {
4863
4938
  type: 'approval_required',
@@ -4911,7 +4986,7 @@ const server = createServer(async (req, res) => {
4911
4986
  // pending = deliberately shelved (Phase 2 material): never planned,
4912
4987
  // never queued, until POST /api/goals/:id/activate. review-only items
4913
4988
  // are terminal-until-human too, so they also skip planGoal.
4914
- if (runnable && !needsFolder && !needsReviewPref) startNextStacked(goal.projectId);
4989
+ if (runnable && !needsReviewPref) startNextStacked(goal.projectId);
4915
4990
  json(res, 200, goal);
4916
4991
  } catch { json(res, 400, { error: 'bad json' }); }
4917
4992
  });
@@ -5991,7 +6066,11 @@ async function answerGoalQuestion(goal, question) {
5991
6066
  const handoffText = crossProject ? [
5992
6067
  `[Cross-project implementation handoff from #${goal.id}]`,
5993
6068
  `Original request: ${goal.text}`,
5994
- goal.reviewSummary?.summary ? `Reviewed result: ${goal.reviewSummary.summary}` : '',
6069
+ // `.changed` is the reviewed result summarizeForReview returns
6070
+ // {title, check, changed} and never a `.summary`, so this line read a
6071
+ // key that has never existed and dropped out of every handoff. The
6072
+ // digest builder above already spells it `g.reviewSummary?.changed`.
6073
+ goal.reviewSummary?.changed ? `Reviewed result: ${goal.reviewSummary.changed}` : '',
5995
6074
  latest?.result ? `Latest worker result: ${String(latest.result).slice(0, 3000)}` : '',
5996
6075
  `User's implementation request: ${text}`,
5997
6076
  ].filter(Boolean).join('\n\n') : text;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.113",
3
+ "version": "0.10.115",
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": {
@@ -11,7 +11,7 @@
11
11
  "puppeteer-core": "^23.11.1",
12
12
  "ws": "^8.21.0"
13
13
  },
14
- "license": "UNLICENSED",
14
+ "license": "SEE LICENSE IN LICENSE.md",
15
15
  "bin": {
16
16
  "cli": "bin/manager-for-ai.mjs",
17
17
  "galda": "bin/manager-for-ai.mjs",
@@ -26,6 +26,8 @@
26
26
  "SKILL.md",
27
27
  ".mcp.json",
28
28
  "README.md",
29
+ "LICENSE.md",
30
+ "docs/SECURITY.md",
29
31
  "CLAUDE.md",
30
32
  "!engine/chat-runs",
31
33
  "!engine/test",
@@ -41,10 +43,6 @@
41
43
  "engines": {
42
44
  "node": ">=20"
43
45
  },
44
- "repository": {
45
- "type": "git",
46
- "url": "https://github.com/kodo-inc/agent-manager.git"
47
- },
48
46
  "keywords": [
49
47
  "claude",
50
48
  "claude-code",
@@ -53,5 +51,9 @@
53
51
  "manager",
54
52
  "verification",
55
53
  "proof"
56
- ]
54
+ ],
55
+ "homepage": "https://galda.app",
56
+ "bugs": {
57
+ "url": "https://galda.app/support"
58
+ }
57
59
  }