@galda/cli 0.10.60 → 0.10.65

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/app/index.html CHANGED
@@ -2020,6 +2020,12 @@
2020
2020
  .sa-item.lg .sa-act{margin-top:13px;border-top:1px solid var(--hair);padding-top:13px}
2021
2021
  .sa-chatwrap{flex:1;min-width:150px;position:relative;display:flex;align-items:center}
2022
2022
  .sa-chatwrap .sa-chat{padding-right:42px}
2023
+ .sa-replybox{flex:1;min-width:150px;display:flex;flex-direction:column;gap:6px}
2024
+ .sa-replyattach{display:none;gap:6px;flex-wrap:wrap;min-height:0}
2025
+ .sa-replyattach.has{display:flex}
2026
+ .sa-replythumb{position:relative;width:44px;height:44px;border:1px solid var(--hair2);border-radius:7px;overflow:hidden;background:var(--canvas)}
2027
+ .sa-replythumb img{width:100%;height:100%;object-fit:cover;display:block}
2028
+ .sa-replythumb button{position:absolute;right:2px;top:2px;width:17px;height:17px;border:0;border-radius:50%;background:rgba(0,0,0,.72);color:#fff;font:500 12px/17px var(--ui);padding:0;cursor:pointer}
2023
2029
  /* send-back button = the spinning prism ring (§1.5-6), same palette source as the
2024
2030
  main composer's --ringgrad; centre stays transparent so it sits into the row */
2025
2031
  @keyframes iringspin{to{transform:rotate(360deg)}}
@@ -2227,6 +2233,19 @@
2227
2233
  .redesign .ghfoot{margin-top:8px;font-family:var(--mono);font-size:11.5px;color:var(--ink3);display:flex;gap:2px;align-items:center;flex-wrap:wrap}
2228
2234
  .redesign .ghfoot .ghopen{color:var(--ink2);text-decoration:none}
2229
2235
  .redesign .ghfoot .ghopen:hover{color:var(--blue)}
2236
+ /* Keep the useful summary visible, but fold long diffs so the reply box stays nearby. */
2237
+ .redesign .ghwrap{position:relative}
2238
+ .redesign .ghwrap.clip .rdiff2{max-height:264px}
2239
+ .redesign .ghfade{display:none;position:absolute;left:0;right:0;bottom:0;height:52px;pointer-events:none;
2240
+ border-radius:0 0 8px 8px;background:linear-gradient(to bottom,rgba(0,0,0,0),var(--panel))}
2241
+ .redesign .ghwrap.clip .ghfade{display:block}
2242
+ .redesign .ghmore{margin-top:8px;display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);
2243
+ font-size:11.5px;color:var(--ink3);background:transparent;border:0;padding:5px 9px 5px 7px;border-radius:8px;cursor:pointer}
2244
+ .redesign .ghmore:hover{background:var(--hover);color:var(--ink)}
2245
+ .redesign .ghmore .chev{width:11px;height:11px;flex:0 0 auto;transition:transform .16s}
2246
+ .redesign .ghmore .chev path{stroke:currentColor;stroke-width:1.8;fill:none;stroke-linecap:round;stroke-linejoin:round}
2247
+ .redesign .ghmore.open .chev{transform:rotate(180deg)}
2248
+ .redesign .ghmore .n{color:var(--ink2);font-variant-numeric:tabular-nums}
2230
2249
  /* meta links carry NO underline — PR is a real <a href> (spec §5, Masa 2026-07-20) */
2231
2250
  .redesign .rmeta a{text-decoration:none}
2232
2251
  /* Request / What-to-check = full contrast, same ink as the title (spec §2, no fade) */
@@ -3354,7 +3373,7 @@ const savedTheme = (() => {
3354
3373
  if (v !== s) localStorage.setItem('theme', v);
3355
3374
  return v;
3356
3375
  })();
3357
- const state = { repos: null, repoHome: '', /* GET /api/repos — the folders a project can point at (Context Bar) */ projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: localStorage.getItem('sel:project') || 'default', editing: null, attach: [], connectCommand: '', models: ['sonnet'], agentModels: null, agentEfforts: null, agents: ['claude-code', 'codex'], expanded: new Set(), openThreads: new Set(), queueOrder: {}, externalActivity: [], selectedGoal: null, workflowColumns: {}, wfDraft: null, reviewDefinitions: {}, rdDraft: null, connectedApp: localStorage.getItem('sel:agent') || 'claude-code', reviewOpenGoal: null, reviewApprovals: {}, goalDetailOpen: null, qreplyOpen: new Set(), logCollapsed: localStorage.getItem('logCollapsed') !== '0', logEntries: [], layout: localStorage.getItem('layout') || 'flagship', theme: savedTheme, boardskin: localStorage.getItem('boardskin') || 'auto', summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null, projectRules: {}, rulesOpen: false,
3376
+ const state = { repos: null, repoHome: '', /* GET /api/repos — the folders a project can point at (Context Bar) */ projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: localStorage.getItem('sel:project') || 'default', editing: null, attach: [], connectCommand: '', models: ['sonnet'], agentModels: null, agentEfforts: null, agents: ['claude-code', 'codex'], auth: null, expanded: new Set(), openThreads: new Set(), queueOrder: {}, externalActivity: [], selectedGoal: null, workflowColumns: {}, wfDraft: null, reviewDefinitions: {}, rdDraft: null, connectedApp: localStorage.getItem('sel:agent') || 'claude-code', reviewOpenGoal: null, reviewApprovals: {}, goalDetailOpen: null, qreplyOpen: new Set(), logCollapsed: localStorage.getItem('logCollapsed') !== '0', logEntries: [], layout: localStorage.getItem('layout') || 'flagship', theme: savedTheme, boardskin: localStorage.getItem('boardskin') || 'auto', summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null, projectRules: {}, rulesOpen: false,
3358
3377
  // per-message send overrides set via slash-commands (reset after each send,
3359
3378
  // except model which is sticky in localStorage). palette = open command list.
3360
3379
  msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
@@ -7694,29 +7713,67 @@ function showBillingBlocked(blocked){
7694
7713
  billingBlockedActive = true;
7695
7714
  }
7696
7715
 
7697
- // Worker sign-in banner. The server preflights `claude` at boot (/api/state.auth
7698
- // + SSE ev:'auth'); if it isn't signed in, every worker would fail with "Not
7699
- // logged in", so the queue is held server-side and this standing banner tells
7700
- // the user how to fix it. Reuses #errbar + the .bx-ask card (bordered, red as a
7701
- // left rule only — no new chrome), but WITHOUT an auto-hide timer since the
7702
- // condition persists until the user relaunches signed-in. Chrome is English to
7703
- // match the rest of the app.
7704
- const AUTH_REASON_TEXT = {
7705
- 'logged-out': 'Not signed in to claude. Run `claude` in a terminal and /login, then relaunch the manager from that terminal — tasks are paused until then.',
7706
- 'bad-credentials': 'Worker sign-in failed. Check ANTHROPIC_API_KEY, or run `claude` and /login, then relaunch the manager from that terminal.',
7707
- 'probe-failed': 'Couldn’t verify claude sign-in. Ensure the `claude` CLI is installed and signed in, then relaunch the manager.',
7708
- 'rate-limited': 'Claude is temporarily unavailable. Your Claude tasks are safely waiting. Tap “Run with Codex” beside a task to start it now.',
7716
+ const AUTH_COPY = {
7717
+ ja: {
7718
+ connectTitle: '接続が必要です', connectBody: 'Claude Codeとの接続が切れています。ボタンから接続してください。', connect: 'Claude Codeを接続', retry: '接続を確認',
7719
+ limitTitle: 'Claudeは利用上限です', limitBody: '作業は安全に待機しています。すぐ続ける場合は、待機中のタスクをCodexへ切り替えられます。', limitWait: '作業は安全に待機しています。Claudeが再び使えるようになると自動で再開します。', codex: 'Codexで続ける',
7720
+ },
7721
+ en: {
7722
+ connectTitle: 'Connect Claude Code', connectBody: 'Claude Code is disconnected. Use the button to reconnect and continue.', connect: 'Connect Claude Code', retry: 'Check connection',
7723
+ limitTitle: 'Claude limit reached', limitBody: 'Your work is safely waiting. Switch the waiting tasks to Codex if you want to continue now.', limitWait: 'Your work is safely waiting and will resume automatically when Claude is available.', codex: 'Continue with Codex',
7724
+ },
7709
7725
  };
7710
7726
  function renderAuthBanner(auth){
7711
7727
  const bar = $('errbar');
7712
- if (auth && auth.ok === false){
7728
+ state.auth = auth ?? state.auth;
7729
+ if (state.connectedApp === 'claude-code' && state.auth?.ok === false){
7713
7730
  authDown = true;
7714
- const text = AUTH_REASON_TEXT[auth.reason] || AUTH_REASON_TEXT['logged-out'];
7731
+ const copy = AUTH_COPY[billingLang()];
7732
+ const limited = state.auth.reason === 'rate-limited';
7733
+ const canUseCodex = (state.agents || []).includes('codex');
7715
7734
  bar.classList.add('billing'); // strips #errbar's error fill so the card stands alone
7716
- bar.innerHTML = `<div class="bx-ask"><div class="bx-qt"><div class="bx-lb">Sign in</div>`
7717
- + `<div class="bx-q">${esc(text)}</div></div></div>`;
7735
+ bar.innerHTML = `<div class="bx-ask${limited ? ' info' : ''}"><div class="bx-qt"><div class="bx-lb">${limited ? copy.limitTitle : copy.connectTitle}</div>`
7736
+ + `<div class="bx-q">${limited ? (canUseCodex ? copy.limitBody : copy.limitWait) : copy.connectBody}</div></div>`
7737
+ + `<div class="bx-act">${limited && canUseCodex
7738
+ ? `<button class="bx-cta" id="bxUseCodex">${copy.codex}</button>`
7739
+ : limited ? '' : `<button class="bx-cta" id="bxConnectClaude">${copy.connect}</button><button class="bx-cta" id="bxRetryClaude">${copy.retry}</button>`}</div></div>`;
7718
7740
  bar.style.display = 'block';
7719
7741
  clearTimeout(errTimer); // this banner persists — no auto-hide
7742
+ const codex = bar.querySelector('#bxUseCodex');
7743
+ if (codex) codex.onclick = async () => {
7744
+ codex.disabled = true;
7745
+ try {
7746
+ const r = await fetch(withKey('/api/worker-auth/run-with-codex'), { method: 'POST' });
7747
+ const body = await r.json().catch(() => ({}));
7748
+ if (!r.ok) throw new Error(body.error || 'Could not switch to Codex.');
7749
+ renderAuthBanner({ ok: true });
7750
+ showToast(copy === AUTH_COPY.ja ? `${body.changed}件をCodexで再開しました。` : `${body.changed} task${body.changed === 1 ? '' : 's'} resumed with Codex.`);
7751
+ await refresh();
7752
+ } catch (e) { showErr(e.message || 'Could not switch to Codex.'); }
7753
+ finally { codex.disabled = false; }
7754
+ };
7755
+ const connect = bar.querySelector('#bxConnectClaude');
7756
+ if (connect) connect.onclick = async () => {
7757
+ connect.disabled = true;
7758
+ try {
7759
+ const r = await fetch(withKey('/api/worker-auth/connect'), { method: 'POST' });
7760
+ if (!r.ok) throw new Error();
7761
+ showToast(copy === AUTH_COPY.ja ? '接続画面を開きました。完了後に「接続を確認」を押してください。' : 'Connection opened. When finished, choose “Check connection”.');
7762
+ } catch { showErr(copy === AUTH_COPY.ja ? '接続画面を開けませんでした。' : 'Could not open the connection.'); }
7763
+ finally { connect.disabled = false; }
7764
+ };
7765
+ const retry = bar.querySelector('#bxRetryClaude');
7766
+ if (retry) retry.onclick = async () => {
7767
+ retry.disabled = true;
7768
+ try {
7769
+ const r = await fetch(withKey('/api/worker-auth/retry'), { method: 'POST' });
7770
+ const body = await r.json().catch(() => ({}));
7771
+ if (!r.ok) throw new Error();
7772
+ renderAuthBanner(body.auth);
7773
+ if (body.auth?.ok) showToast(copy === AUTH_COPY.ja ? '接続できました。' : 'Connected.');
7774
+ } catch { showErr(copy === AUTH_COPY.ja ? 'まだ接続できていません。' : 'Not connected yet.'); }
7775
+ finally { retry.disabled = false; }
7776
+ };
7720
7777
  } else if (authDown) {
7721
7778
  authDown = false;
7722
7779
  bar.classList.remove('billing');
@@ -7968,7 +8025,7 @@ async function refresh(){
7968
8025
  state.connectCommand = s.connectCommand ?? ''; state.models = s.models ?? ['sonnet']; state.agentModels = s.agentModels ?? null; state.agentEfforts = s.agentEfforts ?? null; state.agents = s.agents ?? ['claude-code', 'codex']; state.uiVersion = s.uiVersion;
7969
8026
  state.billing = s.billing ?? { licensed: false, email: null };
7970
8027
  renderAppSelect(); // picker follows the server's installed-agent set; may correct a stale connectedApp
7971
- renderAuthBanner(s.auth); // show/clear the worker sign-in banner from the boot preflight
8028
+ renderAuthBanner(s.auth); // only the selected worker's auth can raise a banner
7972
8029
  setAgentModel(state.connectedApp);
7973
8030
  setAgentEffort(state.connectedApp);
7974
8031
  renderModelSelect();
@@ -8065,7 +8122,7 @@ function connectSSE(){
8065
8122
  if (!state.projects.some((p) => p.id === state.active)) state.active = state.projects[0]?.id;
8066
8123
  }
8067
8124
  else if (m.ev === 'ping') { checkUiVersion(m.uiVersion); return; }
8068
- else if (m.ev === 'auth') { renderAuthBanner(m.auth); return; } // worker sign-in preflight result
8125
+ else if (m.ev === 'auth') { renderAuthBanner(m.auth); return; } // selected worker's sign-in preflight result
8069
8126
  scheduleRender();
8070
8127
  };
8071
8128
  es.onerror = () => {
@@ -8700,7 +8757,23 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
8700
8757
  // 4 archived / 5 reverted — session-local; the server round-trip is what
8701
8758
  // settles it (see saSync's "returned to review" reset).
8702
8759
  // ============================================================================
8703
- const SA = { open: false, items: [], diffs: {}, actLog: {}, msgs: {}, convOpen: new Set(), shotBox: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null, solo: null };
8760
+ const SA = { open: false, items: [], diffs: {}, actLog: {}, msgs: {}, convOpen: new Set(), shotBox: {}, detOpen: new Set(), diffOpen: new Set(), attachments: {}, focusGid: null, focusScrolled: false, focusTimer: null, cur: null, solo: null };
8761
+ function saReplyAttachmentsHtml(goalId){
8762
+ return (SA.attachments[goalId] || []).map((a, i) => `<span class="sa-replythumb"><img src="${esc(a.url)}" alt="Attached image"><button type="button" data-saarm="${goalId}:${i}" aria-label="Remove image">×</button></span>`).join('');
8763
+ }
8764
+ function saPaintAttachments(goalId){
8765
+ const host = document.querySelector(`#seeall [data-saattach="${goalId}"]`);
8766
+ if (!host) return;
8767
+ host.innerHTML = saReplyAttachmentsHtml(goalId);
8768
+ host.classList.toggle('has', Boolean(SA.attachments[goalId]?.length));
8769
+ }
8770
+ async function saAttachImage(goalId, file){
8771
+ if (!file || !String(file.type).startsWith('image/')) return;
8772
+ const uploaded = await uploadReturn(file);
8773
+ if (!uploaded) return;
8774
+ (SA.attachments[goalId] ||= []).push(uploaded);
8775
+ saPaintAttachments(goalId);
8776
+ }
8704
8777
  window.SA = SA; // top-level const isn't a window property; external checks probe window.SA (same convention as window.state above)
8705
8778
  const SA_SVG = {
8706
8779
  ok: '<svg viewBox="0 0 24 24"><path d="M4 12l5 5L20 6"/></svg>',
@@ -8982,13 +9055,13 @@ function saMarkWordSpans(rows){
8982
9055
  }
8983
9056
  return rows;
8984
9057
  }
8985
- function saGhDiffHtml(diff, pr){
9058
+ function saGhDiffHtml(diff, pr, goalId){
8986
9059
  const rows = saMarkWordSpans(saUnifiedToGh(diff));
8987
9060
  if (!rows.length) return '';
8988
9061
  // Say what was left out. Before this, a 5000-line change rendered its first 80 rows
8989
9062
  // and stopped — a quiet lie, and the one thing SPEC §7 names as forbidden.
8990
9063
  const more = Math.max(0, saDiffRowCount(diff) - rows.length);
8991
- return saGhDiffRowsHtml(rows, more, pr || '');
9064
+ return saGhDiffRowsHtml(rows, more, pr || '', goalId);
8992
9065
  }
8993
9066
  // One Activity transcript row (spec §3): You / tool (mono, bold name) / res (green) /
8994
9067
  // Claude. Mirrors the flagship artifact's tx mapping so the pulldown pixel-matches.
@@ -9067,9 +9140,10 @@ function saMsgRowsHtml(msgs, expanded){
9067
9140
  // word-level highlight (h) and, for a huge change, a "…+N 行 · GitHub で開く ↗"
9068
9141
  // footer (only linking to GitHub when a PR exists). Mirrors the flagship artifact's
9069
9142
  // ghDiffHtml so the app pixel-matches. `more` = extra unshown line count; `pr` = url.
9070
- function saGhDiffRowsHtml(rows, more, pr){
9143
+ const SA_DIFF_FOLD_OVER = 14;
9144
+ function saGhDiffRowsHtml(rows, more, pr, goalId){
9071
9145
  if (!rows || !rows.length) return '';
9072
- const body = '<div class="rdiff2">' + rows.map((l) => {
9146
+ const inner = rows.map((l) => {
9073
9147
  const cls = l.t === 'add' ? 'gh-add' : l.t === 'del' ? 'gh-del' : 'gh-ctx';
9074
9148
  const mk = l.t === 'add' ? '+' : l.t === 'del' ? '−' : '';
9075
9149
  let code = esc(l.c);
@@ -9080,9 +9154,17 @@ function saGhDiffRowsHtml(rows, more, pr){
9080
9154
  code = `${esc(l.c.slice(0, a))}<span class="gh-hi">${esc(l.c.slice(a, b))}</span>${esc(l.c.slice(b))}`;
9081
9155
  } else if (l.h) { const h = esc(l.h); code = code.replace(h, `<span class="gh-hi">${h}</span>`); }
9082
9156
  return `<div class="ghl ${cls}"><span class="gho">${l.o ?? ''}</span><span class="ghn">${l.n ?? ''}</span><span class="ghm">${mk}</span><span class="ghc">${code}</span></div>`;
9083
- }).join('') + '</div>';
9157
+ }).join('');
9084
9158
  const foot = more ? `<div class="ghfoot"><span>…+${more} 行</span>${pr ? ` · <a class="ghopen" href="${esc(pr)}" target="_blank" rel="noopener">GitHub で開く ↗</a>` : ''}</div>` : '';
9085
- return body + foot;
9159
+ if (goalId != null && rows.length > SA_DIFF_FOLD_OVER) {
9160
+ const open = SA.diffOpen.has(goalId);
9161
+ const label = open ? 'Collapse' : `Show all <span class="n">${rows.length}</span> lines`;
9162
+ return `<div class="ghwrap${open ? '' : ' clip'}"><div class="rdiff2">${inner}</div><div class="ghfade"></div></div>`
9163
+ + `<button type="button" class="ghmore${open ? ' open' : ''}" data-ghmore="${goalId}" data-total="${rows.length}" aria-expanded="${open}">`
9164
+ + `<svg class="chev" viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 4.5 6 8l3.5-3.5"/></svg><span class="ghlabel">${label}</span></button>`
9165
+ + foot;
9166
+ }
9167
+ return `<div class="rdiff2">${inner}</div>` + foot;
9086
9168
  }
9087
9169
  // Screen-recording player for Proof (REVIEW-CARD-SPEC §6). A real file (recordingUrl)
9088
9170
  // renders a native <video controls>; otherwise a self-contained SVG player mockup
@@ -9287,8 +9369,8 @@ function saItemHtml(it, i){
9287
9369
  : (d && d.files.length) ? `<div class="rfiles">${d.files.map((f) => `${esc(f.path)} <i class="a">+${f.add}</i> <i class="d">−${f.del}</i>`).join('<br>')}</div>`
9288
9370
  : (clientDiff && filePaths.length) ? `<div class="rfiles">${filePaths.map((p) => esc(p)).join('<br>')}</div>` : '';
9289
9371
  const diffHtml = it.ghRows
9290
- ? saGhDiffRowsHtml(it.ghRows, it.diffTotal || 0, it.pr || '')
9291
- : hasGitDiff ? saGhDiffHtml(d.diff, it.pr) : clientDiff ? saGhDiffHtml(clientDiff, it.pr) : '';
9372
+ ? saGhDiffRowsHtml(it.ghRows, it.diffTotal || 0, it.pr || '', gk)
9373
+ : hasGitDiff ? saGhDiffHtml(d.diff, it.pr, gk) : clientDiff ? saGhDiffHtml(clientDiff, it.pr, gk) : '';
9292
9374
  const changedBody = isConv ? '' : (changedDescHtml + filesListHtml + diffHtml);
9293
9375
  // ── sensitive-area note (real: classifyChangeRisk forced the pause) ──
9294
9376
  const risk = it.risk;
@@ -9387,7 +9469,7 @@ function saItemHtml(it, i){
9387
9469
  ${conversationSec}
9388
9470
  ${activitySec}
9389
9471
  ${S('Workflow', saWorkflowHtml(it))}
9390
- <div class="sa-act">${attn ? '' : `<button class="sa-app" data-saact="app:${i}">${SA_SVG.ok}Approve</button><button class="sa-dis" data-saact="dis:${i}">Dismiss</button>`}<div class="sa-chatwrap"><input class="sa-chat" data-i="${i}" data-goal="${it.goalId}" placeholder="Reply, ask, or send back to the AI…"><button class="sa-send" data-saact="send:${i}" title="Send"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div><button class="sa-ic" title="Archive" data-saact="arch:${i}">${SA_SVG.arch}</button><button class="sa-ic" title="Revert" data-saact="rev:${i}">${SA_SVG.trash}</button></div>
9472
+ <div class="sa-act">${attn ? '' : `<button class="sa-app" data-saact="app:${i}">${SA_SVG.ok}Approve</button><button class="sa-dis" data-saact="dis:${i}">Dismiss</button>`}<div class="sa-replybox"><div class="sa-replyattach${SA.attachments[it.goalId]?.length ? ' has' : ''}" data-saattach="${it.goalId}">${saReplyAttachmentsHtml(it.goalId)}</div><div class="sa-chatwrap"><input class="sa-chat" data-i="${i}" data-goal="${it.goalId}" placeholder="Reply, ask, or paste an image…"><button class="sa-send" data-saact="send:${i}" title="Send"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div></div><button class="sa-ic" title="Archive" data-saact="arch:${i}">${SA_SVG.arch}</button><button class="sa-ic" title="Revert" data-saact="rev:${i}">${SA_SVG.trash}</button></div>
9391
9473
  </div>`;
9392
9474
  }
9393
9475
  // Scroll the See-all body so a card's top edge (.rhead) sits at the top of the visible
@@ -9629,6 +9711,24 @@ async function saReplyCall(it, text, outcome){
9629
9711
  if (!r?.ok) throw new Error('reply failed');
9630
9712
  return r.json().catch(() => null);
9631
9713
  }
9714
+ // Once feedback becomes rework, remove it from Review immediately and repaint the board
9715
+ // from the response. A queued reply appears in To Do; the next worker event promotes it to
9716
+ // Doing. Questions/unsure replies keep status=review and never come through this function.
9717
+ function saMoveFeedbackOutOfReview(it, body){
9718
+ const goal = body?.goal;
9719
+ const task = body?.task;
9720
+ if (goal?.id) upsert(state.goals, goal);
9721
+ if (task?.id) upsert(state.tasks, task);
9722
+ const at = SA.items.findIndex((row) => row.goalId === it.goalId);
9723
+ const next = at >= 0 ? (SA.items[at + 1] || SA.items[at - 1]) : null;
9724
+ if (at >= 0) SA.items.splice(at, 1);
9725
+ render();
9726
+ if (SA.solo === it.goalId || !SA.items.length) { saRender(); saClose(); return; }
9727
+ SA.cur = next?.goalId ?? SA.items[0]?.goalId ?? null;
9728
+ SA.focusGid = SA.cur;
9729
+ SA.focusScrolled = false;
9730
+ saRender();
9731
+ }
9632
9732
  function saCardEl(goalId){ return document.querySelector(`#seeall .sa-item[data-gid="${goalId}"]`); }
9633
9733
  // Flip one card to its "Working on it" head without redrawing anything else (決定2).
9634
9734
  function saFlipWorking(goalId){
@@ -9657,11 +9757,18 @@ function saPaintCard(goalId){
9657
9757
  // instruction (continue).
9658
9758
  async function saSendReply(it, i, text){
9659
9759
  const goalId = it.goalId;
9760
+ const attached = (SA.attachments[goalId] || []).slice();
9761
+ const displayText = text || (attached.length === 1 ? 'Attached an image.' : `Attached ${attached.length} images.`);
9762
+ const workerText = attached.length
9763
+ ? `${text}${text ? '\n\n' : ''}Attached images (read these absolute paths): ${attached.map((a) => a.path).join(', ')}`
9764
+ : text;
9765
+ text = displayText;
9660
9766
  await saEnsureThread(it); // fold in any existing history BEFORE this reply (so it isn't hidden)
9661
9767
  const seq = saNextSeq(); // reserve send order NOW, so a late-resolving route keeps its place
9662
9768
  // Clear the box in place — keep it FOCUSED, not blurred (saSync skips a rebuild while an
9663
9769
  // input in #seeall has focus, which is what keeps an SSE tick from flashing the panel).
9664
9770
  document.querySelectorAll(`#seeall .sa-item[data-gid="${goalId}"] .sa-chat`).forEach((inp) => { inp.value = ''; });
9771
+ delete SA.attachments[goalId]; saPaintAttachments(goalId);
9665
9772
  // Append the line to the card's own thread immediately — this is what the person sees, and
9666
9773
  // its `seq` fixes its order regardless of when any round trip returns.
9667
9774
  it.rthread = [...(it.rthread || []), { who: 'you', text, seq }];
@@ -9674,7 +9781,7 @@ async function saSendReply(it, i, text){
9674
9781
  it.st = 3; it.rstatus = 'working'; SA.cur = goalId;
9675
9782
  saPaintCard(goalId);
9676
9783
  try {
9677
- const r = await fetch(withKey(`/api/goals/${goalId}/reply`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) });
9784
+ const r = await fetch(withKey(`/api/goals/${goalId}/reply`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: workerText }) });
9678
9785
  if (!r.ok) throw new Error('reply failed');
9679
9786
  if (isAttn) {
9680
9787
  // A Needs-you reply picks the goal back up → it belongs in Doing, not lingering in
@@ -9685,6 +9792,7 @@ async function saSendReply(it, i, text){
9685
9792
  render(); // saSync skips the focused card, so this only refreshes the lane behind it
9686
9793
  }
9687
9794
  } catch {
9795
+ SA.attachments[goalId] = attached; saPaintAttachments(goalId);
9688
9796
  it.rthread = it.rthread.filter((m) => m.seq !== seq); // roll back just this line
9689
9797
  if (!it.rthread.some((m) => m.who === 'you')) { it.st = 0; it.rstatus = null; }
9690
9798
  saPaintCard(goalId); showErr('Reply failed.');
@@ -9697,7 +9805,7 @@ async function saSendReply(it, i, text){
9697
9805
  it.inflight = true; it.rstatus = 'thinking'; SA.cur = goalId;
9698
9806
  saPaintCard(goalId);
9699
9807
  try {
9700
- let body = await saReplyCall(it, text, 'auto');
9808
+ let body = await saReplyCall(it, workerText, 'auto');
9701
9809
  if (body?.outcome === 'unsure') {
9702
9810
  it.inflight = false; it.rstatus = 'unsure'; it.rchoices = body.choices || REPLY_OUTCOMES_CLIENT;
9703
9811
  saPaintCard(goalId);
@@ -9709,7 +9817,7 @@ async function saSendReply(it, i, text){
9709
9817
  saPaintCard(goalId); return;
9710
9818
  }
9711
9819
  it.inflight = true; it.rstatus = 'thinking'; saPaintCard(goalId);
9712
- body = await saReplyCall(it, text, picked);
9820
+ body = await saReplyCall(it, workerText, picked);
9713
9821
  }
9714
9822
  it.inflight = false;
9715
9823
  if (body?.outcome === 'answer') { // a question: the AI answers, the goal never moves
@@ -9719,10 +9827,17 @@ async function saSendReply(it, i, text){
9719
9827
  it.rstatus = null; saPaintCard(goalId);
9720
9828
  return;
9721
9829
  }
9722
- // continue: the card is now "Working on it".
9723
- it.st = 3; it.rstatus = 'working'; SA.cur = goalId;
9830
+ // A change request has left Review. The response is the source of truth for whether it
9831
+ // is queued (To Do) or already starting (Doing); reveal that transition immediately.
9832
+ if (body?.goal && body.goal.status !== 'review') {
9833
+ saMoveFeedbackOutOfReview(it, body);
9834
+ return;
9835
+ }
9836
+ // split keeps the original goal in Review; only the newly-created ticket moves away.
9837
+ it.rstatus = null;
9724
9838
  saPaintCard(goalId);
9725
9839
  } catch {
9840
+ SA.attachments[goalId] = attached; saPaintAttachments(goalId);
9726
9841
  it.inflight = false;
9727
9842
  it.rthread = it.rthread.filter((m) => m.seq !== seq);
9728
9843
  if (!it.rthread.length) it.rstatus = null;
@@ -9752,6 +9867,27 @@ async function saAct(it, st, path, body){
9752
9867
  const SA_UNDO = { 1: 'revert-review', 2: 'reopen', 3: 'undismiss', 4: 'unarchive' }; // 07-20c: st 2 = Reject, undo = reopen
9753
9868
  $('seeall').addEventListener('mousedown', (e) => { if (e.target === $('seeall')) saClose(); }); // no-dim dialog: outside = close
9754
9869
  $('seeall').addEventListener('click', async (e) => {
9870
+ // Toggle only the diff in place. Avoiding a full render preserves the review scroll position.
9871
+ const diffMore = e.target.closest('[data-ghmore]');
9872
+ if (diffMore) {
9873
+ const goalId = Number(diffMore.dataset.ghmore);
9874
+ const open = !SA.diffOpen.has(goalId);
9875
+ if (open) SA.diffOpen.add(goalId); else SA.diffOpen.delete(goalId);
9876
+ diffMore.previousElementSibling?.classList.toggle('clip', !open);
9877
+ diffMore.classList.toggle('open', open);
9878
+ diffMore.setAttribute('aria-expanded', String(open));
9879
+ const label = diffMore.querySelector('.ghlabel');
9880
+ if (label) label.innerHTML = open ? 'Collapse' : `Show all <span class="n">${diffMore.dataset.total}</span> lines`;
9881
+ return;
9882
+ }
9883
+ const removeImage = e.target.closest('[data-saarm]');
9884
+ if (removeImage) {
9885
+ const [goalId, index] = removeImage.dataset.saarm.split(':').map(Number);
9886
+ SA.attachments[goalId]?.splice(index, 1);
9887
+ if (!SA.attachments[goalId]?.length) delete SA.attachments[goalId];
9888
+ saPaintAttachments(goalId);
9889
+ return;
9890
+ }
9755
9891
  // "+N more" in a conversation: show the whole thing, in place. Nothing was summarised
9756
9892
  // away, so this only has to stop hiding it (§B3).
9757
9893
  const more = e.target.closest('.convmore');
@@ -9824,7 +9960,7 @@ $('seeall').addEventListener('click', async (e) => {
9824
9960
  // same round-trip as Enter-in-.sa-chat below — one send-back code path either way.
9825
9961
  const inp = $('seeall').querySelector(`.sa-chat[data-i="${i}"]`);
9826
9962
  const text = inp?.value.trim();
9827
- if (!text) { inp?.focus(); return; }
9963
+ if (!text && !SA.attachments[it.goalId]?.length) { inp?.focus(); return; }
9828
9964
  return saSendReply(it, i, text);
9829
9965
  }
9830
9966
  if (k === 'undo') {
@@ -9840,11 +9976,20 @@ $('seeall').addEventListener('click', async (e) => {
9840
9976
  $('seeall').addEventListener('keydown', (e) => {
9841
9977
  if (!e.target.classList?.contains('sa-chat') || e.key !== 'Enter' || e.isComposing) return;
9842
9978
  const text = e.target.value.trim();
9843
- if (!text) return;
9844
- e.preventDefault();
9845
9979
  const it = SA.items[Number(e.target.dataset.i)];
9980
+ if (!text && !SA.attachments[it?.goalId]?.length) return;
9981
+ e.preventDefault();
9846
9982
  if (it) saSendReply(it, Number(e.target.dataset.i), text); // no extra confirm (PRD §5.1): Enter = sent
9847
9983
  });
9984
+ $('seeall').addEventListener('paste', (e) => {
9985
+ const input = e.target.closest('.sa-chat');
9986
+ if (!input) return;
9987
+ const images = [...(e.clipboardData?.items || [])].filter((item) => item.type.startsWith('image/'));
9988
+ if (!images.length) return;
9989
+ e.preventDefault();
9990
+ const goalId = Number(input.dataset.goal);
9991
+ for (const item of images) saAttachImage(goalId, item.getAsFile());
9992
+ });
9848
9993
  document.addEventListener('keydown', (e) => {
9849
9994
  if (!SA.open || ['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
9850
9995
  if (e.key === 'Escape') return saClose();
@@ -10069,6 +10214,7 @@ $('appmenu').addEventListener('click', (e) => {
10069
10214
  renderModelSelect();
10070
10215
  renderEffortSelect();
10071
10216
  renderMsgHint();
10217
+ renderAuthBanner(state.auth);
10072
10218
  $('appmenu').classList.remove('show');
10073
10219
  const projectId = state.active;
10074
10220
  if (projectId) fetch(withKey(`/api/projects/${encodeURIComponent(projectId)}/queue-agent`), {
package/engine/server.mjs CHANGED
@@ -26,7 +26,7 @@ import { createSerialQueue } from './lib.mjs';
26
26
  import { collectProjectRules } from './lib.mjs';
27
27
  import { FALLBACK_CODEX_MODELS, loadCodexModels } from './codex-models.mjs';
28
28
  import { CLAUDE_MODELS } from './claude-models.mjs';
29
- import { classifyWorkerPreflight, workerCanStart } from './worker-availability.mjs';
29
+ import { classifyWorkerPreflight, shouldProbeClaudeAuth, workerCanStart } from './worker-availability.mjs';
30
30
  import { openPR } from './pr.mjs';
31
31
  import { runVerification, exerciseUi } from './verify.mjs';
32
32
 
@@ -1183,6 +1183,7 @@ function runClaude({ prompt, cwd, tools, onEvent, onTodos, resume, model, effort
1183
1183
  }
1184
1184
  });
1185
1185
  child.stderr.on('data', (d) => { err += d; });
1186
+ child.on('error', (e) => { err += `${e.message}\n`; });
1186
1187
  child.on('close', (code) => {
1187
1188
  clearTimeout(timer);
1188
1189
  done({ code, timedOut, result: workerResultText({ result, err, code, timedOut, lastAct, timeoutMs: WORKER_TIMEOUT_MS }), sessionId, usage });
@@ -1229,6 +1230,7 @@ function runCodex({ prompt, cwd, onEvent, onTodos, resume, permissionMode = 'acc
1229
1230
  }
1230
1231
  });
1231
1232
  child.stderr.on('data', (d) => { err += d; });
1233
+ child.on('error', (e) => { err += `${e.message}\n`; });
1232
1234
  child.on('close', (code) => {
1233
1235
  clearTimeout(timer);
1234
1236
  done({ code, timedOut, result: workerResultText({ result, err, code, timedOut, lastAct, timeoutMs: WORKER_TIMEOUT_MS }), sessionId, usage });
@@ -1485,13 +1487,16 @@ function startNextStacked(projectId) {
1485
1487
  // in". We probe once at boot and hold the queue + raise a banner instead of
1486
1488
  // failing task after task. Claude waits for the short probe; Codex is gated
1487
1489
  // independently and may start immediately.
1488
- let workerAuth = { ok: null, reason: null, checkedAt: null };
1490
+ let workerAuth = shouldProbeClaudeAuth(AVAILABLE_AGENTS)
1491
+ ? { ok: null, reason: null, checkedAt: null }
1492
+ : { ok: true, reason: null, checkedAt: null };
1489
1493
  const publicAuth = () => ({ ok: workerAuth.ok, reason: workerAuth.reason });
1490
1494
 
1491
1495
  // Boot preflight: one cheap Read-only `claude` call; classify the result (pure,
1492
1496
  // in lib) and broadcast so the UI can show/clear the banner. `claude -p` exits 0
1493
1497
  // even when logged out, so classifyAuthProbe reads the text, not the code.
1494
1498
  async function probeWorkerAuth() {
1499
+ if (!shouldProbeClaudeAuth(AVAILABLE_AGENTS)) return workerAuth;
1495
1500
  try {
1496
1501
  const out = await runClaude({ prompt: 'Reply with the single word: ok', cwd: ROOT, tools: 'Read', model: 'haiku' });
1497
1502
  workerAuth = {
@@ -1509,6 +1514,20 @@ async function probeWorkerAuth() {
1509
1514
  return workerAuth;
1510
1515
  }
1511
1516
 
1517
+ function openClaudeLogin() {
1518
+ const command = 'claude auth login';
1519
+ if (process.platform === 'darwin') {
1520
+ execFile('osascript', ['-e', `tell application "Terminal" to do script "${command}"`, '-e', 'tell application "Terminal" to activate'], () => {});
1521
+ return true;
1522
+ }
1523
+ if (process.platform === 'win32') {
1524
+ const child = spawn('cmd.exe', ['/c', 'start', '', 'cmd.exe', '/k', command], { detached: true, stdio: 'ignore' });
1525
+ child.once('error', () => {}); child.unref(); return true;
1526
+ }
1527
+ const child = spawn('x-terminal-emulator', ['-e', command], { detached: true, stdio: 'ignore' });
1528
+ child.once('error', () => {}); child.unref(); return true;
1529
+ }
1530
+
1512
1531
  // Layer 2 of the intent router: a general planner clarify card has no builder to
1513
1532
  // re-render from, so when the user asks to switch language mid-question we ask
1514
1533
  // the model (cheap, Read-only, no repo edits) to RESTATE the pending question in
@@ -3588,6 +3607,33 @@ const server = createServer(async (req, res) => {
3588
3607
  return json(res, r.action === 'busy' ? 409 : 200, r);
3589
3608
  }
3590
3609
 
3610
+ // Human-sized recovery actions for the worker banner. Login opens Claude's
3611
+ // official interactive auth flow; rate-limit recovery moves only this
3612
+ // identity's queued Claude work and only after an explicit button press.
3613
+ if (url.pathname === '/api/worker-auth/connect' && req.method === 'POST') {
3614
+ return json(res, openClaudeLogin() ? 200 : 500, { opened: true });
3615
+ }
3616
+ if (url.pathname === '/api/worker-auth/retry' && req.method === 'POST') {
3617
+ const auth = await probeWorkerAuth();
3618
+ if (auth.ok) for (const project of projects) pump(project.id);
3619
+ return json(res, 200, { auth: publicAuth() });
3620
+ }
3621
+ if (url.pathname === '/api/worker-auth/run-with-codex' && req.method === 'POST') {
3622
+ if (workerAuth.reason !== 'rate-limited') return json(res, 409, { error: 'Claude work is not rate limited' });
3623
+ if (!AVAILABLE_AGENTS.includes('codex')) return json(res, 409, { error: 'Codex is not connected on this device' });
3624
+ const identity = identityFor(req);
3625
+ const visible = new Set(goals.filter((goal) => goalVisibleTo(goal, identity, MANAGER_OWNER_EMAIL)).map((goal) => goal.id));
3626
+ const queued = tasks.filter((task) => visible.has(task.goalId) && task.status === 'queued' && task.agent === 'claude-code');
3627
+ const goalIds = new Set(queued.map((task) => task.goalId));
3628
+ const model = CODEX_MODELS[0];
3629
+ for (const task of queued) { task.agent = 'codex'; task.model = model; task.sessionId = null; saveTask(task); }
3630
+ for (const goal of goals.filter((item) => goalIds.has(item.id))) {
3631
+ goal.agent = 'codex'; goal.model = model; goal.sessionId = null; goal.startedAt = null; saveGoal(goal);
3632
+ }
3633
+ for (const projectId of new Set(queued.map((task) => task.projectId))) pump(projectId);
3634
+ return json(res, 200, { changed: queued.length, goals: [...goalIds] });
3635
+ }
3636
+
3591
3637
  // The folders a person could actually point a project at: the git repos sitting
3592
3638
  // directly under HOME. A browser cannot open an OS folder dialog that yields a real
3593
3639
  // path (showDirectoryPicker hands back a handle, never a path), so a detected list
@@ -5016,9 +5062,9 @@ server.listen(PORT, '127.0.0.1', () => {
5016
5062
  spawn('open', ['-g', openUrl], { stdio: 'ignore' }).unref();
5017
5063
  }
5018
5064
  console.log(`[manager] projects: ${projects.map((p) => `${p.id}=${p.dir}`).join(' ')}`);
5019
- probeWorkerAuth().catch(() => {}); // hold Claude tasks + banner if Claude is unavailable
5065
+ if (shouldProbeClaudeAuth(AVAILABLE_AGENTS)) probeWorkerAuth().catch(() => {}); // hold Claude tasks + banner if Claude is unavailable
5020
5066
  setInterval(() => {
5021
- if (!workerAuth.ok) probeWorkerAuth().catch(() => {});
5067
+ if (shouldProbeClaudeAuth(AVAILABLE_AGENTS) && !workerAuth.ok) probeWorkerAuth().catch(() => {});
5022
5068
  }, 5 * 60_000).unref();
5023
5069
  syncAllExternal().catch(() => {});
5024
5070
  syncGoalMerges().catch(() => {});
@@ -6,3 +6,7 @@ export function classifyWorkerPreflight({ result = '', code = 0 } = {}, { classi
6
6
  export function workerCanStart({ agent, auth } = {}) {
7
7
  return auth?.ok === true || agent === 'codex';
8
8
  }
9
+
10
+ export function shouldProbeClaudeAuth(agents = []) {
11
+ return agents.includes('claude-code');
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.60",
3
+ "version": "0.10.65",
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": {