@galda/cli 0.10.17 → 0.10.19

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
@@ -610,10 +610,14 @@
610
610
  .confetti-piece{position:fixed;pointer-events:none;z-index:9999;will-change:transform,opacity}
611
611
 
612
612
  /* connect modal */
613
- .overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);backdrop-filter:blur(6px);z-index:90;display:none;place-items:center}
614
- /* z-index:90 (was 60): the customize-panel discard confirm (#deleteConfirmOverlay, shares this
615
- class) can now fire while .gearpop (the customize panel, z-index:80) is open it was
616
- rendering BEHIND the panel it's confirming a discard for (Masa 2026-07-16, real screenshot). */
613
+ /* A confirm dialog (deleteConfirmOverlay etc.) must always sit above other
614
+ floating chrome it can be opened from was 60, then 90 (cleared .gearpop's
615
+ 80 for the customize-panel discard confirm), but that still sat under the
616
+ context menu (95) and the settings panel rendered in front of its own Log
617
+ out confirm (Masa 指示 2026-07-16). 100 clears every other floating layer
618
+ (gearpop 80, context menu 95) while staying under true full-screen gates
619
+ (lightbox 200, #connectGate 1000). */
620
+ .overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);backdrop-filter:blur(6px);z-index:100;display:none;place-items:center}
617
621
  .overlay.show{display:grid}
618
622
  .modal{width:640px;max-width:92vw;background:var(--surface);border:1px solid var(--hair2);border-radius:16px;padding:20px 22px;box-shadow:0 24px 70px rgba(0,0,0,.6)}
619
623
  .modal h3{margin:0 0 6px;font-size:15px;letter-spacing:-.01em}
@@ -634,6 +638,10 @@
634
638
  .mbtn.danger{background:color-mix(in srgb,var(--danger) 10%,var(--panel));color:color-mix(in srgb,var(--danger) 78%,var(--ink));
635
639
  box-shadow:inset 0 0 0 1px rgba(var(--danger-rgb),.35)}
636
640
  .mbtn.danger:hover{box-shadow:inset 0 0 0 1px var(--danger);background:color-mix(in srgb,var(--danger) 16%,var(--panel))}
641
+ /* Neutral confirm (not destructive/AI — e.g. Log out): same ink-solid tone as
642
+ the settings Save button (.cfoot .cbtn.save), not the iri default (AI-only, §5.5). */
643
+ .mbtn.solid{background:var(--ink);color:var(--invink)}
644
+ .mbtn.solid:hover{opacity:.9}
637
645
 
638
646
  /* Slack-style centered delete confirmation (replaces native confirm() — restores a
639
647
  decision lost in the flagship rewrite; see docs/confirmed-behaviors.md). Deliberately
@@ -980,7 +988,7 @@
980
988
  .gearbtn{display:grid;place-items:center;width:30px;height:28px;border:1px solid var(--hair2);border-radius:8px;background:transparent;color:var(--ink2);cursor:pointer}
981
989
  .gearbtn:hover{background:var(--surface2);color:var(--ink)}
982
990
  .gearbtn svg{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
983
- .gearpop{display:none;position:absolute;top:36px;right:0;z-index:80;background:var(--panel);border-radius:12px;box-shadow:0 10px 30px rgba(20,23,31,.18),0 0 0 1px var(--hair);padding:12px 13px;width:250px;
991
+ .gearpop{display:none;position:absolute;top:36px;right:0;z-index:80;background:var(--panel);border-radius:12px;box-shadow:0 10px 30px rgba(20,23,31,.18),0 0 0 1px var(--hair);padding:12px 13px;width:320px;
984
992
  max-height:78vh;overflow-y:auto;scrollbar-width:thin; /* TB edge + colour sections made it taller than the viewport */
985
993
  /* opaque chip fill (HANDOFF-v45 §9): a translucent --canvas would let the wallpaper
986
994
  bleed through and colour each row differently — this token mixes off the OPAQUE
@@ -1017,7 +1025,7 @@
1017
1025
  the rail (left is set on open from the measured rail edge), max-height = viewport −
1018
1026
  margins, internal scroll. White-translucent per §3: color-mix 74% + blur28 (theme-tinted).
1019
1027
  The node is adopted to <body> in flagship (positionLayout) — .top is display:none there. */
1020
- body[data-layout="flagship"] .gearpop{position:fixed;top:120px;right:auto;width:300px;max-height:calc(100vh - 160px);border-radius:14px;
1028
+ body[data-layout="flagship"] .gearpop{position:fixed;top:120px;right:auto;width:380px;max-height:calc(100vh - 160px);border-radius:14px;
1021
1029
  background:color-mix(in srgb,var(--panel) 74%,transparent);
1022
1030
  -webkit-backdrop-filter:blur(28px) saturate(1.35);backdrop-filter:blur(28px) saturate(1.35)}
1023
1031
  /* settings = EDIT SESSION (HANDOFF-v45 §3, flagship .cfoot/.mcpbox port): sticky footer,
@@ -1267,6 +1275,29 @@
1267
1275
  #fsRoot .fsstream::-webkit-scrollbar{display:none}
1268
1276
  #fsFeed{display:flex;flex-direction:column;gap:8px;padding-top:2px}
1269
1277
  #fsRoot .cc-say{font-size:13.5px;color:var(--ink)}
1278
+ /* Pattern A (H23 / AMENDMENT-center-feed-2026-07-16, CDO): the center feed is
1279
+ a conversation — the user's own turns live here too, not just Claude Code's.
1280
+ 器を増やさない(§1.5-1): no bubbles — a speaker label + a hairline turn border. */
1281
+ #fsRoot .cc-you{padding-top:11px;border-top:1px solid var(--hair)}
1282
+ #fsRoot .cc-you:first-child{padding-top:0;border-top:0}
1283
+ #fsRoot .cc-you .cy-n{display:block;font:600 8.5px/1 var(--mono);letter-spacing:.13em;text-transform:uppercase;color:var(--ink3);margin-bottom:5px}
1284
+ #fsRoot .cc-you .cy-t{font-size:13.5px;color:var(--ink);line-height:1.45}
1285
+ /* Typing cursor on the streaming reply frontier (rides the client-side reveal —
1286
+ the CLI returns the reply as one block, not token deltas). Solid block, hard
1287
+ terminal blink (steps(1)); --ink adapts across all themes. */
1288
+ #fsRoot .cc-caret{display:inline-block;width:6px;height:14px;margin-left:2px;transform:translateY(2px);
1289
+ background:var(--ink);animation:ccblink 1.05s steps(1) infinite}
1290
+ @keyframes ccblink{50%{opacity:0}}
1291
+ @media (prefers-reduced-motion:reduce){#fsRoot .cc-caret{animation:none}}
1292
+ /* "→ Added to To Do #NN" — shown quietly in the flow when a turn became a task.
1293
+ Undo appears on hover only (§1.5-10). No filled surface. */
1294
+ #fsRoot .cc-mark{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:11px;color:var(--ink3);padding:1px 0}
1295
+ #fsRoot .cc-mark .cm-ar{color:var(--ink2);font-size:12.5px;line-height:1}
1296
+ #fsRoot .cc-mark .cm-t{color:var(--ink2)}
1297
+ #fsRoot .cc-mark .cm-no{color:var(--ink3);font-variant-numeric:tabular-nums}
1298
+ #fsRoot .cc-mark .cm-undo{margin-left:2px;padding:0;border:0;background:none;font:inherit;color:var(--ink3);cursor:pointer;opacity:0;transition:opacity .15s,color .15s}
1299
+ #fsRoot .cc-mark:hover .cm-undo{opacity:1}
1300
+ #fsRoot .cc-mark .cm-undo:hover{color:var(--blue)}
1270
1301
  /* Claude Code CLI language (HANDOFF-v45 §12, flagship .cc-t port): gap:0 — the bullet's
1271
1302
  own margin-right does the spacing, not a flex gap (keeps ⏺ tight against the tool name
1272
1303
  the way the real CLI reads). */
@@ -1326,6 +1357,10 @@
1326
1357
  #fsRoot .fs-askbar{display:none;border:1px solid var(--hair2);border-bottom:0;border-left:2px solid var(--blue);border-radius:14px 14px 0 0;padding:9px 13px 11px} /* 枠1px (v3 §6.5) */
1327
1358
  #fsRoot .compwrap{max-width:var(--fscenterw,760px);width:100%;margin:10px auto 0;flex:0 0 auto;position:relative;z-index:1;
1328
1359
  transition:max-width .28s cubic-bezier(.4,0,.2,1)}
1360
+ /* #errbar is adopted into .compwrap by positionLayout() (flagship hides .main
1361
+ outright, its original home) — float it just above the input, matching the
1362
+ legacy layout's "just above the composer" placement (CDO判定 2026-07-16). */
1363
+ #fsRoot .compwrap>.errbar{position:absolute;left:50%;bottom:calc(100% + 10px);transform:translateX(-50%)}
1329
1364
  #fsRoot.lanefold{--fscenterw:1020px} /* folded space flows to the center (§1.5-7) — one var flip keeps feed+composer in lockstep */
1330
1365
  /* adopted composer (#composerWrap moves in — same node, same handlers, focus fix intact) */
1331
1366
  body[data-layout="flagship"] #composerWrap{padding:0;border-top:0;max-width:none;width:auto;margin:0}
@@ -2052,6 +2087,11 @@
2052
2087
  .bx-lb{font:600 9px/1 var(--mono);letter-spacing:.11em;text-transform:uppercase;color:var(--red)}
2053
2088
  .bx-q{font-size:13px;color:var(--ink);margin-top:4px}
2054
2089
  .bx-act{flex:none}
2090
+ /* neutral ask (not a limit/error — e.g. the sign-in nudge): same card, --blue
2091
+ accent instead of --red, matching the existing AI-confirmation blue
2092
+ (§7決定ログ「AI確認:色は青系」) rather than inventing a new color. */
2093
+ .bx-ask.info{border-left-color:var(--blue)}
2094
+ .bx-ask.info .bx-lb{color:var(--blue)}
2055
2095
  /* ② post-checkout return bar */
2056
2096
  .bx-cobar{box-shadow:inset 0 0 0 1px var(--hair2);border-radius:11px;padding:10px 13px;display:flex;align-items:center;gap:10px;font-size:12.5px;margin:0 auto 12px;width:100%;max-width:var(--fscenterw,760px)}
2057
2097
  .bx-cobar[hidden]{display:none}
@@ -2097,7 +2137,7 @@
2097
2137
  <button class="laybtn" data-theme="cinema">Cinema</button>
2098
2138
  </div>
2099
2139
  <div class="gearwrap">
2100
- <button class="gearbtn" id="gearBtn" title="カスタマイズ" aria-label="Customize"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
2140
+ <button class="gearbtn" id="gearBtn" title="Settings" aria-label="Settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
2101
2141
  <div class="gearpop" id="gearPop"></div>
2102
2142
  </div>
2103
2143
  <button class="pill-btn" id="connect">Connect MCP</button>
@@ -2210,9 +2250,8 @@
2210
2250
  <div class="lbrow"><span class="lb">Projects</span>
2211
2251
  <button class="ghostbtn morebtn" id="fsMoreBtn" title="Settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2"/><path d="M19.4 15a1.7 1.7 0 00.34 1.87l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.7 1.7 0 00-1.87-.34 1.7 1.7 0 00-1 1.56V21a2 2 0 11-4 0v-.09a1.7 1.7 0 00-1-1.56 1.7 1.7 0 00-1.87.34l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.7 1.7 0 00.34-1.87 1.7 1.7 0 00-1.56-1H3a2 2 0 110-4h.09a1.7 1.7 0 001.56-1 1.7 1.7 0 00-.34-1.87l-.06-.06a2 2 0 112.83-2.83l.06.06a1.7 1.7 0 001.87.34h.09a1.7 1.7 0 001-1.56V3a2 2 0 114 0v.09a1.7 1.7 0 001 1.56h.09a1.7 1.7 0 001.87-.34l.06-.06a2 2 0 112.83 2.83l-.06.06a1.7 1.7 0 00-.34 1.87v.09a1.7 1.7 0 001.56 1H21a2 2 0 110 4h-.09a1.7 1.7 0 00-1.56 1z"/></svg></button>
2212
2252
  <div class="moremenu" id="fsMoreMenu">
2213
- <button class="ghostbtn" id="fsCust">Customize…</button>
2253
+ <button class="ghostbtn" id="fsCust">Theme</button>
2214
2254
  <button class="ghostbtn" id="fsConn">Connect MCP</button>
2215
- <button class="ghostbtn" id="fsTodoBd">To Do board</button>
2216
2255
  <button class="ghostbtn" id="fsHist">History</button>
2217
2256
  <button class="ghostbtn" id="fsSet">Settings</button>
2218
2257
  </div>
@@ -2466,7 +2505,7 @@ const savedTheme = (() => {
2466
2505
  if (v !== s) localStorage.setItem('theme', v);
2467
2506
  return v;
2468
2507
  })();
2469
- const state = { projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, active: 'galda2', 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, summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null,
2508
+ const state = { projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: 'galda2', 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, summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null,
2470
2509
  // per-message send overrides set via slash-commands (reset after each send,
2471
2510
  // except model which is sticky in localStorage). palette = open command list.
2472
2511
  msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
@@ -2474,6 +2513,28 @@ const state = { projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {
2474
2513
  window.state = state; // top-level const isn't a window property; external checks probe window.state
2475
2514
  const $ = (id) => document.getElementById(id);
2476
2515
 
2516
+ // Question free-text answer boxes (qansinput/fsansinput) are rebuilt from
2517
+ // scratch (container.innerHTML = ...) on every SSE-driven render, which — same
2518
+ // class of bug as the composer's c32fda1 fix — detaches whatever the user is
2519
+ // mid-typing into and drops focus + cursor position + the in-progress text.
2520
+ // Since these nodes can't be kept alive like the composer (they're generated
2521
+ // HTML strings, not a persistent DOM subtree), save the focused box's value
2522
+ // and selection before the rebuild and restore them onto the freshly-rendered
2523
+ // node with the same data-attribute id afterward.
2524
+ function captureFocusedAnswerInput(container, attr){
2525
+ const el = document.activeElement;
2526
+ if (!el || !container || !container.contains(el) || !el.hasAttribute(attr)) return null;
2527
+ return { id: el.getAttribute(attr), value: el.value, start: el.selectionStart, end: el.selectionEnd };
2528
+ }
2529
+ function restoreFocusedAnswerInput(container, attr, saved){
2530
+ if (!saved || !container) return;
2531
+ const el = container.querySelector(`[${attr}="${saved.id}"]`);
2532
+ if (!el) return;
2533
+ el.value = saved.value;
2534
+ el.focus();
2535
+ try { el.setSelectionRange(saved.start, saved.end); } catch { /* input type may not support it */ }
2536
+ }
2537
+
2477
2538
  const STATUS_LABEL = {
2478
2539
  sending: 'Sending…', stacked: 'Queued', planning: 'Planning…', queued: 'To do', running: 'Working…', needsInput: 'Needs input',
2479
2540
  review: 'In review', done: 'Done', failed: 'Failed', partial: 'Partly failed', interrupted: 'Interrupted', skipped: 'Skipped',
@@ -2564,10 +2625,15 @@ function setAgentEffort(agent){
2564
2625
  localStorage.setItem('sel:effort', state.stickyEffort);
2565
2626
  }
2566
2627
  function renderAppSelect(){
2567
- if (!CONNECTED_APPS.some((a) => a.id === state.connectedApp)) state.connectedApp = CONNECTED_APPS[0].id;
2568
- const app = CONNECTED_APPS.find((a) => a.id === state.connectedApp) ?? CONNECTED_APPS[0];
2628
+ // Only offer agents whose CLI is actually installed (state.agents comes from the
2629
+ // server = AVAILABLE_AGENTS). A claude-only machine never shows Codex; a
2630
+ // Codex-only one never shows Claude Code. Never render an empty picker.
2631
+ const apps = CONNECTED_APPS.filter((a) => (state.agents || []).includes(a.id));
2632
+ const list = apps.length ? apps : CONNECTED_APPS.slice(0, 1);
2633
+ if (!list.some((a) => a.id === state.connectedApp)) state.connectedApp = list[0].id;
2634
+ const app = list.find((a) => a.id === state.connectedApp) ?? list[0];
2569
2635
  $('appbtn').innerHTML = `${app.icon}<span>${esc(app.label)}</span>${ICONS.caret}`;
2570
- $('appmenu').innerHTML = CONNECTED_APPS.map((a) =>
2636
+ $('appmenu').innerHTML = list.map((a) =>
2571
2637
  `<button type="button" class="appopt${a.id === state.connectedApp ? ' active' : ''}" data-val="${a.id}">${a.icon}<span>${esc(a.label)}</span></button>`
2572
2638
  ).join('');
2573
2639
  }
@@ -2779,6 +2845,14 @@ function buildReviewDigest({ goals, tasks } = {}){
2779
2845
  // Mirrors engine/lib.mjs buildReviewSummary()/requirementSummaryLine() (task 96)
2780
2846
  // — kept in sync manually since the browser script isn't a module. Used by both
2781
2847
  // the review checklist top summary and the chat-side review导线.
2848
+ // task 6 (2026-07-16): mirrors engine/lib.mjs goalTokenHistory() — one
2849
+ // {weightedTokens, requirementCount} sample per finished goal (excluding the
2850
+ // one being summarized), oldest-first, for the token-savings baseline below.
2851
+ function goalTokenHistoryLocal(goals, tasks, excludeGoalId){
2852
+ return (goals ?? [])
2853
+ .filter((g) => g && g.id !== excludeGoalId && GOAL_COMPLETE_STATUSES.includes(g.status) && g.runTokens != null)
2854
+ .map((g) => ({ weightedTokens: g.runTokens, requirementCount: (tasks ?? []).filter((t) => t.goalId === g.id && !t.reply).length }));
2855
+ }
2782
2856
  function buildReviewSummary({ goal, tasks, approvedN = 0 } = {}){
2783
2857
  const reqs = (tasks ?? []).filter((t) => !t.reply);
2784
2858
  const total = reqs.length;
@@ -2788,6 +2862,9 @@ function buildReviewSummary({ goal, tasks, approvedN = 0 } = {}){
2788
2862
  const source = plan.length ? plan : reqs.map((t) => t.title);
2789
2863
  const what = source.map((s) => String(s ?? '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 3).join(' / ');
2790
2864
  const fallback = String(goal?.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 80);
2865
+ // History is scoped to the same project as this goal — a goal in a small
2866
+ // toy project shouldn't be judged against a large project's average.
2867
+ const sameProjectGoals = (state.goals || []).filter((g) => g.projectId === goal?.projectId);
2791
2868
  return { approvedN, total, headline: `${approvedN} / ${total} approved`, what: what || fallback,
2792
2869
  detail: `${done}/${total} done${proofs ? ` · proof ×${proofs}` : ''}`,
2793
2870
  failureMemory: goal?.failureMemory?.length ? goal.failureMemory[goal.failureMemory.length - 1] : null,
@@ -2800,6 +2877,8 @@ function buildReviewSummary({ goal, tasks, approvedN = 0 } = {}){
2800
2877
  usedCompact: goal?.executionPlan?.sessionMode === 'compact',
2801
2878
  usedClear: Boolean(goal?.sessionClosedAt),
2802
2879
  agentModel: goal?.model,
2880
+ requirementCount: total,
2881
+ history: goalTokenHistoryLocal(sameProjectGoals, state.tasks || [], goal?.id),
2803
2882
  }) };
2804
2883
  }
2805
2884
  function requirementSummaryLine(task){
@@ -2867,28 +2946,65 @@ function usageBudgetTokensLocal(usage, cacheReadWeight = 0.1){
2867
2946
  const weight = Number.isFinite(Number(cacheReadWeight)) ? Math.max(0, Number(cacheReadWeight)) : 0.1;
2868
2947
  return Math.round((u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + ((u.cache_read_input_tokens ?? 0) * weight));
2869
2948
  }
2870
- function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, cacheReadWeight = 0.1, sessionMode = null, usedHandoff = false, usedCompact = false, usedClear = false, agentModel = null } = {}){
2949
+ // task 6 (2026-07-16): mirrors engine/lib.mjs tokenBaselinePerRequirement()
2950
+ // moving average of tokens-per-requirement over up to the last 20 finished
2951
+ // goals (needs at least 3 to be meaningful).
2952
+ const TOKEN_BASELINE_MIN_HISTORY = 3;
2953
+ const TOKEN_BASELINE_MAX_HISTORY = 20;
2954
+ function tokenBaselinePerRequirementLocal(history){
2955
+ const samples = (history ?? [])
2956
+ .map((h) => {
2957
+ if (typeof h === 'number') return h;
2958
+ const tokens = Number(h?.weightedTokens);
2959
+ const reqN = Math.max(1, Number(h?.requirementCount) || 1);
2960
+ return Number.isFinite(tokens) ? tokens / reqN : null;
2961
+ })
2962
+ .filter((n) => Number.isFinite(n) && n > 0)
2963
+ .slice(-TOKEN_BASELINE_MAX_HISTORY);
2964
+ if (samples.length < TOKEN_BASELINE_MIN_HISTORY) return null;
2965
+ return samples.reduce((a, b) => a + b, 0) / samples.length;
2966
+ }
2967
+ // Mirrors engine/lib.mjs estimateTokenOptimization() — see the comment there
2968
+ // for why "saved X%" now compares against a real per-project baseline
2969
+ // (basis: 'baseline') instead of always reusing this goal's own cache-hit
2970
+ // ratio as a stand-in for "cheaper than usual" (basis: 'cache-weight',
2971
+ // used only until there's enough finished-goal history).
2972
+ function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, cacheReadWeight = 0.1, sessionMode = null, usedHandoff = false, usedCompact = false, usedClear = false, agentModel = null, requirementCount = 1, history = [] } = {}){
2871
2973
  const u = usage ?? {};
2872
2974
  const raw = (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
2975
+ if (raw <= 0) return null;
2873
2976
  const weighted = weightedTokens != null && Number.isFinite(Number(weightedTokens)) ? Number(weightedTokens) : usageBudgetTokensLocal(u, cacheReadWeight);
2874
- const saved = Math.max(0, raw - weighted);
2875
- const percent = raw > 0 ? Math.round((saved / raw) * 100) : 0;
2977
+ const reqN = Math.max(1, Number(requirementCount) || 1);
2978
+ const perReq = weighted / reqN;
2979
+ const baseline = tokenBaselinePerRequirementLocal(history);
2980
+ let percent, basis, savedTokens;
2981
+ if (baseline != null) {
2982
+ percent = Math.max(-100, Math.min(100, Math.round(((baseline - perReq) / baseline) * 100)));
2983
+ basis = 'baseline';
2984
+ savedTokens = Math.max(0, Math.round((baseline - perReq) * reqN));
2985
+ } else {
2986
+ savedTokens = Math.max(0, raw - weighted);
2987
+ percent = Math.round((savedTokens / raw) * 100);
2988
+ basis = 'cache-weight';
2989
+ }
2876
2990
  const techniques = [];
2877
- if ((u.cache_read_input_tokens ?? 0) > 0) techniques.push('cache-read weighting');
2878
- if (['compact', 'cold-handoff'].includes(sessionMode)) techniques.push(sessionMode === 'compact' ? 'compact handoff' : 'fresh handoff');
2879
- if (usedHandoff) techniques.push('AI handoff');
2880
- if (usedCompact) techniques.push('/compact');
2881
- if (usedClear) techniques.push('/clear');
2882
- if (agentModel) techniques.push(`model ${agentModel}`);
2883
- if (!percent && !techniques.length) return null;
2884
- return { rawTokens: raw, weightedTokens: weighted, savedTokens: saved, percent, techniques: [...new Set(techniques)] };
2991
+ const cacheRatio = Math.round(((u.cache_read_input_tokens ?? 0) / raw) * 100);
2992
+ if (cacheRatio > 0) techniques.push(`${cacheRatio}% of tokens served from cache`);
2993
+ if (usedHandoff || sessionMode === 'cold-handoff') techniques.push('fresh handoff instead of a bloated session');
2994
+ if (usedCompact || sessionMode === 'compact') techniques.push('/compact before continuing');
2995
+ if (usedClear) techniques.push('/clear between unrelated goals');
2996
+ if (agentModel) techniques.push(`${agentModel} for this run`);
2997
+ if (basis === 'baseline' && percent > 0) techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
2998
+ if (!techniques.length) techniques.push(`${Math.round(raw / 1000)}k tokens this run (no cache reuse yet)`);
2999
+ return { rawTokens: raw, weightedTokens: weighted, savedTokens, percent, basis, techniques: [...new Set(techniques)] };
2885
3000
  }
2886
3001
  function tokenOptimizationHtml(opt){
2887
3002
  if (!opt) return '';
2888
- const techniques = opt.techniques?.length ? opt.techniques.slice(0, 4).join(' / ') : 'session planning';
3003
+ const techniques = opt.techniques?.length ? opt.techniques.slice(0, 4).join(' / ') : 'this run';
2889
3004
  const pct = Number(opt.percent);
3005
+ const cmp = opt.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
2890
3006
  const text = pct > 0
2891
- ? `Token optimized: saved about ${pct}% via ${techniques}`
3007
+ ? `Token optimized: saved about ${pct}% ${cmp} (${techniques})`
2892
3008
  : `Token optimized: ${techniques}`;
2893
3009
  return `<div class="rvtokenopt">${esc(text)}</div>`;
2894
3010
  }
@@ -3511,6 +3627,7 @@ function renderTasks(){
3511
3627
  <div class="cond">shelved — press Start to queue it</div></div>
3512
3628
  <button type="button" class="prmini pstart" data-pstart="${g.id}">Start</button>
3513
3629
  <button type="button" class="pdel" data-pdel="${g.id}" title="Delete">✕</button></div>`).join('');
3630
+ const savedQAns = captureFocusedAnswerInput($('tasklist'), 'data-qansinput');
3514
3631
  $('tasklist').innerHTML =
3515
3632
  sec('Attention', attention, 'attn')
3516
3633
  + sec(pickColumnLabel(cols, 'review', 'Review'), reviewDigest + review, 'reviewsec', reviewDefinitionFor(state.active).description, REVIEW_DEF_HINT)
@@ -3518,6 +3635,7 @@ function renderTasks(){
3518
3635
  + sec(pickColumnLabel(cols, 'todo', 'To do'), todo, 'todosec')
3519
3636
  + sec('Pending', pendingRows, 'dim')
3520
3637
  + sec(pickColumnLabel(cols, 'done', 'Done'), doneRows, 'dim');
3638
+ restoreFocusedAnswerInput($('tasklist'), 'data-qansinput', savedQAns);
3521
3639
  // faint pencil beside the Review subtitle → open the review-definition editor
3522
3640
  $('tasklist').querySelector('[data-editreviewdef]')?.addEventListener('click', (e) => { e.stopPropagation(); openWfEditor(); });
3523
3641
  for (const el of $('tasklist').querySelectorAll('.task[data-task]')) {
@@ -3674,7 +3792,7 @@ function renderTasks(){
3674
3792
  // event listeners, so all wiring above still works. Anchor comments mark each
3675
3793
  // node's home so other layouts restore it exactly.
3676
3794
  function ensureLayoutAnchors(){
3677
- for (const id of ['tasklist', 'composerWrap', 'actpanel']) {
3795
+ for (const id of ['tasklist', 'composerWrap', 'actpanel', 'errbar']) {
3678
3796
  const el = $(id);
3679
3797
  if (el && !el._anchor) { const a = document.createComment('home:' + id); el.parentNode.insertBefore(a, el); el._anchor = a; }
3680
3798
  }
@@ -3694,11 +3812,19 @@ function positionLayout(){
3694
3812
  // is never detached mid-typing on SSE-driven renders.
3695
3813
  const host = $('fsCompwrap'), cw = $('composerWrap');
3696
3814
  if (host && cw && cw.parentNode !== host) host.appendChild(cw);
3815
+ // #errbar (showErr/showBillingBlocked/showSignInNudge) lives inside .main,
3816
+ // which flagship hides outright (display:none) — every transient message
3817
+ // silently never rendered. Adopt it beside the composer, same technique as
3818
+ // #composerWrap above; a flagship CSS rule floats it just above the input
3819
+ // (CDO判定 2026-07-16 — same fix as #gearPop's body[data-layout] escape).
3820
+ const eb = $('errbar');
3821
+ if (host && eb && eb.parentNode !== host) host.appendChild(eb);
3697
3822
  // tasklist / actpanel go back home (they live hidden inside .main here)
3698
3823
  for (const id of ['tasklist', 'actpanel']) { const el = $(id); if (el && el._anchor && el.parentNode !== el._anchor.parentNode) el._anchor.parentNode.insertBefore(el, el._anchor); }
3699
3824
  return;
3700
3825
  }
3701
3826
  if (state.layout === 'bflow') {
3827
+ { const el = $('errbar'); if (el && el._anchor && el.parentNode !== el._anchor.parentNode) el._anchor.parentNode.insertBefore(el, el._anchor); }
3702
3828
  const bc = $('bcenter'), bt = $('brightTop'), bb = $('brightBottom');
3703
3829
  if (!bc) return;
3704
3830
  const tl = $('tasklist'), cw = $('composerWrap'), ap = $('actpanel');
@@ -3718,7 +3844,7 @@ function positionLayout(){
3718
3844
  if (!bb.querySelector('.brbot-head')) { const h = document.createElement('div'); h.className = 'brbot-head'; h.textContent = 'Implementing'; bb.appendChild(h); }
3719
3845
  if (ap.parentNode !== bb) bb.appendChild(ap); // right-bottom: Log → Implementing
3720
3846
  } else {
3721
- for (const id of ['tasklist', 'composerWrap', 'actpanel']) { const el = $(id); if (el && el._anchor && el.parentNode !== el._anchor.parentNode) el._anchor.parentNode.insertBefore(el, el._anchor); }
3847
+ for (const id of ['tasklist', 'composerWrap', 'actpanel', 'errbar']) { const el = $(id); if (el && el._anchor && el.parentNode !== el._anchor.parentNode) el._anchor.parentNode.insertBefore(el, el._anchor); }
3722
3848
  }
3723
3849
  }
3724
3850
 
@@ -4877,12 +5003,15 @@ function renderFsFeed(){
4877
5003
  // dogfood PRs (質問カードの自由記述回答欄 / 「Needs your answer」カードを削除できるように
4878
5004
  // — see docs/confirmed-behaviors.md): the chips-only card left no way to answer a question
4879
5005
  // with no matching preset option, or to discard a question you don't want to answer.
5006
+ // Questions render at the FEED BOTTOM (composer-adjacent) — built into askHtml
5007
+ // so they sit AFTER the conversation turns (H18/H19 / Pattern A: 質問は末尾固定).
5008
+ let askHtml = '';
4880
5009
  for (const g of state.goals.filter((g) => g.projectId === state.active && g.status === 'needsInput' && g.question)) {
4881
5010
  const chips = (g.question.options || []).map((o) => `<button class="achip" data-fsanswer="${g.id}" data-a="${esc(o)}">${esc(o)}</button>`).join('');
4882
5011
  // QUESTION card (H18): mono label + prompt + option chips + always-on free-text
4883
5012
  // answer + hover trash. Data hooks (data-fsanswer / -ansinput / -anssend /
4884
5013
  // -answerdel) are unchanged so the existing wiring below keeps working.
4885
- html += `<div class="cc-ask"><div class="cc-ask-lb">Question</div>`
5014
+ askHtml += `<div class="cc-ask"><div class="cc-ask-lb">Question</div>`
4886
5015
  + `<div class="cc-ask-q">${esc(g.question.text)}</div>`
4887
5016
  + (chips ? `<div class="qchips">${chips}</div>` : '')
4888
5017
  + `<div class="answ-inline"><input type="text" data-fsansinput="${g.id}" placeholder="Type your answer…" aria-label="Type your answer">`
@@ -4906,17 +5035,44 @@ function renderFsFeed(){
4906
5035
  // longer flash in and wipe what the user was looking at. While work exists but
4907
5036
  // this pass produced no lines, keep the last known feed (or a calm one-line
4908
5037
  // status) instead of blanking to the hero.
5038
+ // Pattern A (H23): the conversation turns — the user's own messages, Claude
5039
+ // Code's replies (typed out with a caret on the streaming frontier), and
5040
+ // "→ Added to To Do #NN" markers — rendered from state.chatTurns so the
5041
+ // whole-feed re-render (SSE act events) can't drop them. Order per turn:
5042
+ // You → (mark, if it became a task) and, for a reply, speaker line → reply.
5043
+ const turns = (state.chatTurns[state.active] || []).slice(-40);
5044
+ let chatHtml = '';
5045
+ for (const turn of turns) {
5046
+ if (turn.role === 'you') {
5047
+ chatHtml += `<div class="cc-you"><span class="cy-n">You</span><div class="cy-t">${esc(turn.text)}</div></div>`;
5048
+ if (turn.markGoalId != null && !turn.undone) {
5049
+ const mg = state.goals.find((x) => x.id === turn.markGoalId);
5050
+ const n = mg ? goalReviewNumber({ goal: mg, tasks: state.tasks }) : turn.markGoalId;
5051
+ chatHtml += `<div class="cc-mark"><span class="cm-ar">→</span><span class="cm-t">Added to To Do</span><span class="cm-no">#${n}</span><button class="cm-undo" type="button" data-ccundo="${turn.markGoalId}">Undo</button></div>`;
5052
+ }
5053
+ } else { // Claude Code reply — speaker line first (You → speaker → reply), then the text
5054
+ const app = connectedAppMeta(turn.agent);
5055
+ chatHtml += `<div class="cc-who">${app.icon}<span class="cs-n">${esc(app.label)}</span></div>`;
5056
+ const shown = esc((turn.text || '').slice(0, turn.revealed || 0));
5057
+ chatHtml += turn.done
5058
+ ? `<div class="cc-say">${shown}</div>`
5059
+ : `<div class="cc-say"><span id="ccTyping">${shown}</span><span class="cc-caret"></span></div>`;
5060
+ }
5061
+ }
4909
5062
  const pgoals = state.goals.filter((g) => g.projectId === state.active);
4910
5063
  const ptasks = state.tasks.filter((t) => t.projectId === state.active);
4911
- const hasWork = pgoals.length > 0 || ptasks.length > 0;
4912
- let out = html;
5064
+ const hasWork = pgoals.length > 0 || ptasks.length > 0 || turns.length > 0;
5065
+ // task feed (with its own speaker line) → conversation turns → question (bottom)
5066
+ let out = html + chatHtml + askHtml;
4913
5067
  if (out) _fsLastFeed[state.active] = out;
4914
5068
  else if (hasWork) out = _fsLastFeed[state.active] || `<div class="cc-c">⎿ ${esc(running.length ? 'working…' : 'no active task')}</div>`;
4915
5069
  $('fsRoot').classList.toggle('clawdhero', !hasWork);
4916
5070
  const sc = feed.parentNode;
4917
5071
  const nearBottom = sc.scrollHeight - sc.clientHeight - sc.scrollTop <= 80;
5072
+ const savedFsAns = captureFocusedAnswerInput(feed, 'data-fsansinput');
4918
5073
  feed.innerHTML = hasWork ? out : '';
4919
5074
  if (nearBottom) sc.scrollTop = sc.scrollHeight;
5075
+ restoreFocusedAnswerInput(feed, 'data-fsansinput', savedFsAns);
4920
5076
  window.__fsCineSync?.(); // Cinema dynamic rule: content growth may reach the band region
4921
5077
  for (const b of feed.querySelectorAll('[data-fsretry]')) b.onclick = () => fsRetryTask(b.dataset.fsretry);
4922
5078
  for (const b of feed.querySelectorAll('[data-fslog]')) b.onclick = () => fsToggleLog(Number(b.dataset.fslog));
@@ -4934,6 +5090,19 @@ function renderFsFeed(){
4934
5090
  // Delete a "needs your answer" card outright (restores 「Needs your answer」カードを削除
4935
5091
  // できるように — a question you don't want to answer had no discard path in flagship).
4936
5092
  for (const b of feed.querySelectorAll('[data-fsanswerdel]')) b.onclick = (e) => { e.stopPropagation(); fsTaskDelete(Number(b.dataset.fsanswerdel)); };
5093
+ // Pattern A (H23): Undo a "→ Added to To Do" marker — cancel the created goal
5094
+ // and drop the mark. A just-created (planning) goal is deletable; if it already
5095
+ // started the server returns non-OK (canDeleteGoal) and we say so quietly.
5096
+ for (const b of feed.querySelectorAll('[data-ccundo]')) b.onclick = async (e) => {
5097
+ e.stopPropagation();
5098
+ const gid = Number(b.dataset.ccundo);
5099
+ const r = await fetch(withKey(`/api/goals/${gid}`), { method: 'DELETE' });
5100
+ if (!r.ok) { showErr('Could not undo — the task may already be running'); return; }
5101
+ const turn = (state.chatTurns[state.active] || []).find((t) => t.markGoalId === gid);
5102
+ if (turn) turn.undone = true;
5103
+ state.goals = state.goals.filter((g) => g.id !== gid);
5104
+ render();
5105
+ };
4937
5106
  }
4938
5107
  async function fsAnswerQuestion(goalId, answer){
4939
5108
  if (!answer) return;
@@ -5456,11 +5625,10 @@ function renderClawdHero(){
5456
5625
  });
5457
5626
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMobilePanels(); });
5458
5627
  // v46 (HANDOFF-v45 §3): the menu entries deep-link into the 4-tab settings panel —
5459
- // Customize…→THEME / Connect MCP→MCP / History→HISTORY / Settings→SETTINGS.
5628
+ // Theme→THEME / Connect MCP→MCP / History→HISTORY / Settings→SETTINGS.
5460
5629
  // stopPropagation: the document click-away handler otherwise closes it in the same event.
5461
5630
  $('fsCust').onclick = (e) => { e.stopPropagation(); $('fsMoreMenu').classList.remove('open'); custOpen('theme'); };
5462
5631
  $('fsConn').onclick = (e) => { e.stopPropagation(); $('fsMoreMenu').classList.remove('open'); custOpen('mcp'); };
5463
- $('fsTodoBd').onclick = () => { $('fsMoreMenu').classList.remove('open'); $('toggle').click(); }; // To Do board stays reachable from the menu; ⌘K itself now opens the project switcher (HANDOFF-v45 §11)
5464
5632
  $('fsHist').onclick = (e) => { e.stopPropagation(); $('fsMoreMenu').classList.remove('open'); custOpen('history'); };
5465
5633
  $('fsSet').onclick = (e) => { e.stopPropagation(); $('fsMoreMenu').classList.remove('open'); custOpen('settings'); };
5466
5634
  // parallel-runs switcher (§1.5-1/-7): same toggle+click-away shape as fsMoreMenu above.
@@ -5567,12 +5735,13 @@ function fsGearAlignX(){
5567
5735
  function upsert(arr, item){ const i = arr.findIndex((x) => x.id === item.id); if (i >= 0) arr[i] = item; else arr.push(item); }
5568
5736
 
5569
5737
  let errTimer = null;
5738
+ let authDown = false; // true while the worker-auth banner is up → keep auto-hide timers from clearing it
5570
5739
  function showErr(msg){
5571
5740
  const bar = $('errbar');
5572
5741
  bar.classList.remove('billing'); // shed the paywall styling if it was last used for that
5573
5742
  bar.textContent = msg; bar.style.display = 'block';
5574
5743
  clearTimeout(errTimer);
5575
- errTimer = setTimeout(() => { bar.style.display = 'none'; }, 6000);
5744
+ errTimer = setTimeout(() => { if (authDown) return; bar.style.display = 'none'; }, 6000);
5576
5745
  }
5577
5746
 
5578
5747
  // Minimal placeholder for the free-tier paywall pending CDO design
@@ -5599,6 +5768,55 @@ function showBillingBlocked(blocked){
5599
5768
  const btn = bar.querySelector('#bxUpgrade');
5600
5769
  if (btn) btn.onclick = () => startCheckout();
5601
5770
  clearTimeout(errTimer);
5771
+ errTimer = setTimeout(() => { if (authDown) return; bar.style.display = 'none'; bar.classList.remove('billing'); }, 20000);
5772
+ }
5773
+
5774
+ // Worker sign-in banner. The server preflights `claude` at boot (/api/state.auth
5775
+ // + SSE ev:'auth'); if it isn't signed in, every worker would fail with "Not
5776
+ // logged in", so the queue is held server-side and this standing banner tells
5777
+ // the user how to fix it. Reuses #errbar + the .bx-ask card (bordered, red as a
5778
+ // left rule only — no new chrome), but WITHOUT an auto-hide timer since the
5779
+ // condition persists until the user relaunches signed-in. Chrome is English to
5780
+ // match the rest of the app.
5781
+ const AUTH_REASON_TEXT = {
5782
+ '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.',
5783
+ 'bad-credentials': 'Worker sign-in failed. Check ANTHROPIC_API_KEY, or run `claude` and /login, then relaunch the manager from that terminal.',
5784
+ 'probe-failed': 'Couldn’t verify claude sign-in. Ensure the `claude` CLI is installed and signed in, then relaunch the manager.',
5785
+ };
5786
+ function renderAuthBanner(auth){
5787
+ const bar = $('errbar');
5788
+ if (auth && auth.ok === false){
5789
+ authDown = true;
5790
+ const text = AUTH_REASON_TEXT[auth.reason] || AUTH_REASON_TEXT['logged-out'];
5791
+ bar.classList.add('billing'); // strips #errbar's error fill so the card stands alone
5792
+ bar.innerHTML = `<div class="bx-ask"><div class="bx-qt"><div class="bx-lb">Sign in</div>`
5793
+ + `<div class="bx-q">${esc(text)}</div></div></div>`;
5794
+ bar.style.display = 'block';
5795
+ clearTimeout(errTimer); // this banner persists — no auto-hide
5796
+ } else if (authDown) {
5797
+ authDown = false;
5798
+ bar.classList.remove('billing');
5799
+ bar.style.display = 'none';
5800
+ bar.innerHTML = '';
5801
+ }
5802
+ }
5803
+
5804
+ // Post-logout nudge (Masa指示 2026-07-16): shown once, the next time a signed-out
5805
+ // send() actually lands, so it reads as "you just logged out" rather than nagging
5806
+ // every anonymous user (the free tier needs no account — this never blocks send).
5807
+ // Same #errbar "ask card" look as showBillingBlocked, Sign in instead of Upgrade.
5808
+ // .bx-ask.info (blue, not red — CDO判定 2026-07-16): this isn't a limit/error,
5809
+ // red is reserved for those (§5.6).
5810
+ function showSignInNudge(){
5811
+ const bar = $('errbar');
5812
+ bar.classList.add('billing');
5813
+ bar.innerHTML = `<div class="bx-ask info"><div class="bx-qt"><div class="bx-lb">Signed out</div>`
5814
+ + `<div class="bx-q">Sign in with Google to keep your usage synced across devices.</div></div>`
5815
+ + `<div class="bx-act"><button class="bx-cta" id="bxSignin">Sign in</button></div></div>`;
5816
+ bar.style.display = 'block';
5817
+ const btn = bar.querySelector('#bxSignin');
5818
+ if (btn) btn.onclick = () => beginGoogleSignin((err) => showErr(err));
5819
+ clearTimeout(errTimer);
5602
5820
  errTimer = setTimeout(() => { bar.style.display = 'none'; bar.classList.remove('billing'); }, 20000);
5603
5821
  }
5604
5822
 
@@ -5670,6 +5888,21 @@ function startGoogleSignin(url){
5670
5888
  const w = window.open(url, 'galda-signin', 'width=520,height=680');
5671
5889
  if (!w) location.href = url; // popup blocked → same-tab fallback
5672
5890
  }
5891
+ // Shared by the Settings "Sign in with Google" button and the post-logout nudge
5892
+ // (showSignInNudge below) so both go through the same signin-url → popup path.
5893
+ async function beginGoogleSignin(onError){
5894
+ try {
5895
+ const r = await fetch(withKey('/api/signin-url'));
5896
+ const data = await r.json().catch(() => ({}));
5897
+ if (r.ok && data.url) { startGoogleSignin(data.url); return true; }
5898
+ onError?.(data.error || 'Sign-in is not available.');
5899
+ } catch { onError?.('Sign-in failed — check your connection.'); }
5900
+ return false;
5901
+ }
5902
+ // Set right after a confirmed Log out; consumed once by the next successful
5903
+ // send() while still signed out, to nudge the user back toward signing in
5904
+ // (Masa指示 2026-07-16) — never blocks the send itself (free tier needs no account).
5905
+ let recentlySignedOut = false;
5673
5906
  async function refreshAfterSignin(){
5674
5907
  await refresh();
5675
5908
  if (state.billing?.licensed) signinPending = false;
@@ -5713,6 +5946,8 @@ async function refresh(){
5713
5946
  for (const t of state.tasks) { if (t.todos?.length) state.todos[t.id] = t.todos; }
5714
5947
  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;
5715
5948
  state.billing = s.billing ?? { licensed: false, email: null };
5949
+ renderAppSelect(); // picker follows the server's installed-agent set; may correct a stale connectedApp
5950
+ renderAuthBanner(s.auth); // show/clear the worker sign-in banner from the boot preflight
5716
5951
  setAgentModel(state.connectedApp);
5717
5952
  setAgentEffort(state.connectedApp);
5718
5953
  renderModelSelect();
@@ -5769,6 +6004,7 @@ function connectSSE(){
5769
6004
  if (!state.projects.some((p) => p.id === state.active)) state.active = state.projects[0]?.id;
5770
6005
  }
5771
6006
  else if (m.ev === 'ping') { checkUiVersion(m.uiVersion); return; }
6007
+ else if (m.ev === 'auth') { renderAuthBanner(m.auth); return; } // worker sign-in preflight result
5772
6008
  scheduleRender();
5773
6009
  };
5774
6010
  es.onerror = () => {
@@ -5816,10 +6052,13 @@ function bootDisconnected(info){
5816
6052
  #connectGate .cg-cmd button{font:500 11px/1 var(--mono,monospace);color:#a3a097;background:transparent;
5817
6053
  border:1px solid rgba(255,255,255,.18);border-radius:7px;padding:6px 9px;cursor:pointer}
5818
6054
  #connectGate .cg-cmd button:hover{color:#fff}
6055
+ #connectGate .cg-hint{font-size:12px;line-height:1.6;color:var(--ink3,#6b6862);margin:0 0 16px}
6056
+ #connectGate .cg-hint a{color:var(--blue,#8fb0ff);text-decoration:none}
6057
+ #connectGate .cg-hint a:hover{text-decoration:underline}
5819
6058
  #connectGate .cg-who{font-size:12px;color:var(--ink3,#6b6862);margin:0 0 4px}
5820
6059
  #connectGate .cg-who b{color:var(--ink2,#a3a097)}
5821
6060
  #connectGate .cg-switch{font-size:12px;color:var(--ink3,#6b6862);margin:14px 0 0}
5822
- #connectGate .cg-switch a{color:#8fb0ff;text-decoration:none}
6061
+ #connectGate .cg-switch a{color:var(--blue,#8fb0ff);text-decoration:none}
5823
6062
  #connectGate .cg-x{position:absolute;top:14px;right:16px;width:28px;height:28px;border:0;background:transparent;color:var(--ink3,#6b6862);font-size:20px;line-height:1;cursor:pointer}
5824
6063
  #connectGate .cg-wait{display:inline-flex;align-items:center;gap:7px;font:500 10.5px/1 var(--mono,monospace);letter-spacing:.08em;color:var(--ink3,#6b6862);margin-top:16px}
5825
6064
  #connectGate .cg-wait i{width:6px;height:6px;border-radius:50%;background:var(--green,#3DDC97);animation:cgpulse 1.8s ease-in-out infinite}
@@ -5833,6 +6072,13 @@ function bootDisconnected(info){
5833
6072
  + '<h2>Connect this device to start</h2>'
5834
6073
  + '<p>Galda runs on your own machine. Run this where you use Claude Code or Codex — this board comes alive the moment it connects, no refresh needed:</p>'
5835
6074
  + '<div class="cg-cmd"><code>npx @galda/cli</code><button class="cg-copy">Copy</button></div>'
6075
+ // One command connects the machine, not an agent: the CLI probes PATH and hands
6076
+ // the whole detected set to the server. So we never split this per agent — we
6077
+ // only point people without either one at the official installers.
6078
+ + '<p class="cg-hint">Don\'t have either yet? Install '
6079
+ + '<a href="https://claude.com/claude-code" target="_blank" rel="noreferrer">Claude Code</a> or '
6080
+ + '<a href="https://openai.com/codex/" target="_blank" rel="noreferrer">Codex</a>, then run the same command '
6081
+ + '— Galda uses whichever agent you have.</p>'
5836
6082
  + '<p class="cg-who">Signed in as <b></b></p>'
5837
6083
  + '<p class="cg-switch">Already running Galda under a different account? Run <code>npx @galda/cli --signin</code> and pick this one. Or <a href="/logout">switch this browser →</a></p>'
5838
6084
  + '<div class="cg-wait"><i></i>WAITING FOR THIS DEVICE</div></div>';
@@ -5944,21 +6190,37 @@ async function postOutboxItem(item){
5944
6190
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
5945
6191
  const goal = await r.json();
5946
6192
  if (goal.kind === 'chat') {
5947
- // Conversation-first composer (SLICE 2): this was a chat/help/settings
5948
- // turn, not a To-Do. Retire the optimistic outbox item — a chat turn is
5949
- // ephemeral by design, so dropping it does NOT weaken the never-lose
5950
- // guarantee (which protects real tasks/goals). Show the reply quietly
5951
- // above the input. We must NOT reach land()/upsert(): a kind:'chat'
5952
- // response carries no goal id, so upserting it would wedge a ghost
5953
- // "Sending…" row in the queue forever and pollute state.goals.
6193
+ // Conversation-first (SLICE 2 + Pattern A H23): a chat/help/settings turn,
6194
+ // not a To-Do. Retire the optimistic outbox item — a chat turn is ephemeral
6195
+ // by design, so never-lose (which protects real tasks/goals) is untouched.
6196
+ // In flagship the reply is a turn in the center feed (typed out with a
6197
+ // caret); the board layout has no feed, so it falls back to the quiet
6198
+ // .peraanswer bar. Never land()/upsert(): a kind:'chat' response has no id.
5954
6199
  outbox.remove(item.oid);
5955
- showChatReply(goal.reply);
6200
+ if (state.layout === 'flagship') {
6201
+ const turn = { role: 'clawd', text: String(goal.reply || ''), revealed: 0, done: false, agent: item.agent };
6202
+ (state.chatTurns[item.projectId] ||= []).push(turn);
6203
+ renderFsFeed();
6204
+ typeChatTurn(turn);
6205
+ } else {
6206
+ showChatReply(goal.reply);
6207
+ }
5956
6208
  render();
5957
6209
  return; // finally still runs posting.delete(); skip land()/upsert()
5958
6210
  }
5959
6211
  goal.imageUrls = item.imageUrls;
5960
6212
  outbox.land(item.oid, goal.id);
5961
6213
  upsert(state.goals, goal);
6214
+ if (recentlySignedOut && !state.billing?.licensed) { recentlySignedOut = false; showSignInNudge(); }
6215
+ // Pattern A (H23): this send became a real To-Do — mark the most recent
6216
+ // unmarked user turn "→ Added to To Do #NN" in the feed. Skip a needsInput
6217
+ // goal (it's still asking; shown as the question card at the feed bottom).
6218
+ if (goal.status !== 'needsInput') {
6219
+ const turns = state.chatTurns[item.projectId] || [];
6220
+ for (let k = turns.length - 1; k >= 0; k--) {
6221
+ if (turns[k].role === 'you' && turns[k].markGoalId == null) { turns[k].markGoalId = goal.id; break; }
6222
+ }
6223
+ }
5962
6224
  } catch {
5963
6225
  outbox.mark(item.oid, 'unsent');
5964
6226
  } finally {
@@ -5967,16 +6229,57 @@ async function postOutboxItem(item){
5967
6229
  render();
5968
6230
  }
5969
6231
 
5970
- // SLICE 2: render / clear the conversation-first composer reply (reuses the
5971
- // .peraanswer surface). Quiet, transient cleared the moment the user sends
5972
- // the next message so a stale reply never lingers above a new turn.
6232
+ // SLICE 2 / Pattern A (H23): the conversational reply TYPES out — "read along as
6233
+ // it types" (ChatGPT / Claude Code). The claude CLI returns the reply as one
6234
+ // block, NOT token deltas (verified), so this is a client-side reveal of the full
6235
+ // text, not network streaming. Reveal progress lives on the turn MODEL
6236
+ // (turn.revealed), so the whole-feed re-render (SSE `act` events rebuild #fsFeed)
6237
+ // can't reset it; between re-renders we patch just the #ccTyping text node so we
6238
+ // don't rebuild the feed 60×/sec. Duration-normalized (~2s) so short/long both
6239
+ // feel right.
6240
+ let _chatTyper = null;
6241
+ function stopChatTyper(){ if (_chatTyper) { clearInterval(_chatTyper); _chatTyper = null; } }
6242
+ // Flagship: reveal a reply turn in the center feed. The caret sits next to
6243
+ // #ccTyping (a separate span) so patching the text node never wipes it.
6244
+ function typeChatTurn(turn){
6245
+ stopChatTyper();
6246
+ const full = turn.text || '';
6247
+ if (!full) { turn.done = true; return; }
6248
+ const step = Math.max(1, Math.round(full.length / 120)); // ~120 frames ≈ 2s
6249
+ _chatTyper = setInterval(() => {
6250
+ turn.revealed = Math.min(full.length, turn.revealed + step);
6251
+ const node = $('ccTyping');
6252
+ if (node) node.textContent = full.slice(0, turn.revealed);
6253
+ if (turn.revealed >= full.length) { turn.done = true; stopChatTyper(); renderFsFeed(); }
6254
+ }, 16);
6255
+ }
6256
+ // A new send starts: finish any in-flight reply cleanly (complete the model turn +
6257
+ // drop the caret, hide the board bar) so a half-typed reply never lingers.
6258
+ function finalizeChatTyping(){
6259
+ stopChatTyper();
6260
+ const turns = state.chatTurns[state.active] || [];
6261
+ for (let k = turns.length - 1; k >= 0; k--) {
6262
+ if (turns[k].role === 'clawd' && !turns[k].done) { turns[k].revealed = (turns[k].text || '').length; turns[k].done = true; break; }
6263
+ }
6264
+ const el = $('chatReply'); if (el) { el.hidden = true; el.textContent = ''; }
6265
+ }
6266
+ // Board layout (no center feed) keeps the quiet .peraanswer bar; same reveal.
5973
6267
  function showChatReply(text){
5974
6268
  const el = $('chatReply'); if (!el) return;
5975
- const t = (text || '').trim();
5976
- if (!t) { el.hidden = true; el.textContent = ''; return; }
5977
- el.className = 'peraanswer'; el.textContent = t; el.hidden = false;
5978
- }
5979
- function clearChatReply(){ const el = $('chatReply'); if (el) { el.hidden = true; el.textContent = ''; } }
6269
+ stopChatTyper();
6270
+ const full = (text || '').trim();
6271
+ if (!full) { el.hidden = true; el.textContent = ''; return; }
6272
+ el.className = 'peraanswer'; el.textContent = ''; el.hidden = false;
6273
+ let i = 0;
6274
+ const step = Math.max(1, Math.round(full.length / 120));
6275
+ _chatTyper = setInterval(() => {
6276
+ i = Math.min(full.length, i + step);
6277
+ el.textContent = full.slice(0, i);
6278
+ el.scrollTop = el.scrollHeight;
6279
+ if (i >= full.length) stopChatTyper();
6280
+ }, 16);
6281
+ }
6282
+ function clearChatReply(){ finalizeChatTyping(); }
5980
6283
 
5981
6284
  async function flushOutbox(){
5982
6285
  // Only (re)send items that still need it — never re-POST 'landed' memos that
@@ -6048,6 +6351,10 @@ async function send(){
6048
6351
  status: 'sending', ts: Date.now(),
6049
6352
  };
6050
6353
  outbox.add(item); // saved locally BEFORE any network call
6354
+ // Pattern A (H23): the user's own turn enters the center feed immediately, so
6355
+ // the composer reads like a conversation. If this send becomes a To-Do, a
6356
+ // .cc-mark is attached to this turn later; if it's chat, a reply turn follows.
6357
+ (state.chatTurns[state.active] ||= []).push({ role: 'you', text, ts: Date.now() });
6051
6358
  state.attach = [];
6052
6359
  // mode/skill/later/review are per-message → clear after send (model stays sticky).
6053
6360
  state.msg = { mode: null, model: null, effort: null, skill: null, later: false, review: false };
@@ -6244,6 +6551,8 @@ function saRows(){
6244
6551
  usedCompact: g?.executionPlan?.sessionMode === 'compact',
6245
6552
  usedClear: Boolean(g?.sessionClosedAt),
6246
6553
  agentModel: g?.model,
6554
+ requirementCount: ts.filter((t) => t.goalId === g?.id && !t.reply).length,
6555
+ history: goalTokenHistoryLocal(gs, ts, g?.id),
6247
6556
  }),
6248
6557
  };
6249
6558
  });
@@ -7555,11 +7864,11 @@ function gearSetHTML(){
7555
7864
  <button class="optchip" data-gsignin="1">Sign in with Google</button></div>
7556
7865
  <div class="srow dis" id="licKeyMsg" style="display:none"></div>`;
7557
7866
  } else if (bill.plan === 'paid') {
7558
- billingRow = `<div class="srow"><div class="sl">Account<span class="sd">Signed in with Google</span></div><span class="bx-sval">${esc(bill.email)}</span></div>
7867
+ billingRow = `<div class="srow"><div class="sl">Account<span class="sd">Signed in with Google · ${esc(bill.email)}</span></div><button class="optchip" data-gsignout="1">Log out</button></div>
7559
7868
  <div class="srow"><div class="sl">Plan</div><span class="bx-pdot"><i></i>Paid</span></div>
7560
7869
  <div class="srow"><div class="sl">Billing<span class="sd">manage or cancel in Stripe</span></div><button class="bx-cchip" data-gportal="1">Manage</button></div>`;
7561
7870
  } else {
7562
- billingRow = `<div class="srow"><div class="sl">Account<span class="sd">Signed in with Google</span></div><span class="bx-sval">${esc(bill.email)}</span></div>
7871
+ billingRow = `<div class="srow"><div class="sl">Account<span class="sd">Signed in with Google · ${esc(bill.email)}</span></div><button class="optchip" data-gsignout="1">Log out</button></div>
7563
7872
  <div class="srow"><div class="sl">Plan</div><span class="bx-sval">Free</span></div>
7564
7873
  <div class="srow"><div class="sl">To Dos<span class="sd">pending, this install</span></div><span class="bx-sval bx-num">${u.pending ?? 0} / ${lim.pending ?? 5}</span></div>
7565
7874
  <div class="srow"><div class="sl">Lifetime</div><span class="bx-sval bx-num">${u.cumulative ?? 0} / ${lim.cumulative ?? 19}</span></div>
@@ -7609,18 +7918,20 @@ function buildGearPop(){
7609
7918
  if (signBtn) signBtn.onclick = async () => {
7610
7919
  const msg = pop.querySelector('#licKeyMsg');
7611
7920
  signBtn.disabled = true;
7612
- try {
7613
- const r = await fetch(withKey('/api/signin-url'));
7614
- const data = await r.json().catch(() => ({}));
7615
- if (r.ok && data.url) { startGoogleSignin(data.url); }
7616
- else { msg.style.display = ''; msg.textContent = data.error || 'Sign-in is not available.'; }
7617
- } catch { msg.style.display = ''; msg.textContent = 'Sign-in failed — check your connection.'; }
7618
- finally { signBtn.disabled = false; }
7921
+ await beginGoogleSignin((err) => { msg.style.display = ''; msg.textContent = err; });
7922
+ signBtn.disabled = false;
7619
7923
  };
7620
7924
  const upBtn = pop.querySelector('[data-gupgrade]');
7621
7925
  if (upBtn) upBtn.onclick = () => startCheckout();
7622
7926
  const portalBtn = pop.querySelector('[data-gportal]');
7623
7927
  if (portalBtn) portalBtn.onclick = () => openBillingPortal();
7928
+ const signOutBtn = pop.querySelector('[data-gsignout]');
7929
+ if (signOutBtn) signOutBtn.onclick = async () => {
7930
+ if (!(await confirmDelete({ title: 'Log out of Google?', body: 'You can sign back in anytime.', confirmLabel: 'Log out', confirmDanger: false }))) return;
7931
+ signOutBtn.disabled = true;
7932
+ try { await fetch(withKey('/api/signout'), { method: 'POST' }); recentlySignedOut = true; }
7933
+ finally { await refresh(); if ($('gearPop')) buildGearPop(); }
7934
+ };
7624
7935
  return;
7625
7936
  }
7626
7937
  if (custTab === 'mcp') {
@@ -7804,11 +8115,14 @@ $('gearBtn').onclick = (e) => { e.stopPropagation(); const p = $('gearPop'); if
7804
8115
  // outside-tap = Cancel (v45 §3). e.composedPath() is frozen at dispatch, so a swatch that
7805
8116
  // buildGearPop() re-rendered (detached) mid-click still reads as INSIDE the pop —
7806
8117
  // selections never close the panel. Known trap (v45 §3): #fsMoreMenu clicks are excluded
7807
- // so a menu item is never swallowed as an outside tap.
8118
+ // so a menu item is never swallowed as an outside tap. #deleteConfirmOverlay is excluded
8119
+ // too (Masa指示 2026-07-16): it's a separate top-level overlay a settings row can open
8120
+ // (e.g. Log out) — without this, confirming/cancelling it read as an outside tap and
8121
+ // closed the settings panel underneath it as a surprise side effect.
7808
8122
  document.addEventListener('click', (e) => {
7809
8123
  const p = $('gearPop'); if (!p.classList.contains('open')) return;
7810
8124
  if (e.composedPath().includes(p)) return;
7811
- if (e.target.closest('.gearwrap') || e.target.closest('#fsCust') || e.target.closest('#fsMoreMenu') || e.target.closest('#fsMoreBtn')) return;
8125
+ if (e.target.closest('.gearwrap') || e.target.closest('#fsCust') || e.target.closest('#fsMoreMenu') || e.target.closest('#fsMoreBtn') || e.target.closest('#deleteConfirmOverlay')) return;
7812
8126
  custCancel();
7813
8127
  });
7814
8128
  // Banff BACKDROP free upload (v3 §6.5): file → dataURL → #themebg. Persists via