@galda/cli 0.10.39 → 0.10.40

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
@@ -2028,6 +2028,20 @@
2028
2028
  /* "Open it" is waiting on a real process to boot (up to ~15s), so the chip says so
2029
2029
  rather than looking dead. Dim + no pointer, no spinner — §5.5 薄く・主張しない. */
2030
2030
  .redesign .mx.mwait{opacity:.55;pointer-events:none}
2031
+ /* the AI's own screenshots: full width, own aspect, hairline edge (§5.5 縁は1px以下).
2032
+ No grid, no crop — one shot is often a comparison sheet and dies when shrunk. */
2033
+ .redesign .rshot{margin:0 0 16px}
2034
+ .redesign .rshot:last-child{margin-bottom:0}
2035
+ .redesign .rshot img{display:block;width:100%;height:auto;border-radius:10px;border:1px solid var(--hair)}
2036
+ .redesign .rshot figcaption{margin-top:6px;font-family:var(--mono);font-size:11px;color:var(--ink3)}
2037
+ /* before/after pair + the red box over what actually changed (pixel-diffed here from
2038
+ the worker's own two shots — no box appears unless the pixels really differ) */
2039
+ .redesign .beforeafter .proofimg{width:100%;height:auto;max-height:none;object-fit:initial;display:block;border-radius:8px;border:1px solid var(--hair)}
2040
+ .redesign .ba-imgwrap{position:relative}
2041
+ .redesign .ba-imgwrap .proofimg{margin-top:0}
2042
+ .redesign .ba-box{position:absolute;border:1.5px solid var(--danger);border-radius:2px;pointer-events:none}
2043
+ .redesign .ba-zoomhd{margin:14px 0 8px;font-family:var(--mono);font-size:11px;letter-spacing:.04em;text-transform:uppercase;color:var(--ink3)}
2044
+ .redesign .ba-zoomimg{display:block;width:100%;height:auto;border-radius:8px;border:1px solid var(--hair)}
2031
2045
  .redesign .rfiles{margin:12px 0 0;font-family:var(--mono);font-size:12px;color:var(--ink2);line-height:1.75}
2032
2046
  .redesign .rfiles .a{color:#3fd07a;font-style:normal}.redesign .rfiles .d{color:#ff7b72;font-style:normal}
2033
2047
  /* Request accordion (spec §3) — chevron › rotate 90, body indented, no left rule */
@@ -2100,22 +2114,25 @@
2100
2114
  in at the bottom of the opened body. */
2101
2115
  .redesign .ract{margin-top:24px !important}
2102
2116
  .redesign .ract>.rsec-h{margin-bottom:10px !important}
2103
- .redesign .lacc>summary{list-style:none;cursor:pointer;display:flex;gap:9px;align-items:baseline}
2117
+ .redesign .lacc>summary{list-style:none;cursor:pointer;display:flex;gap:14px;align-items:baseline}
2104
2118
  .redesign .lacc>summary::-webkit-details-marker{display:none}
2105
- .redesign .lacc .ltog{flex:0 0 auto;color:var(--ink3);font-style:normal;line-height:1;transition:transform .14s}
2119
+ .redesign .lacc .ltog{flex:0 0 auto;color:var(--ink2);font-style:normal;font-size:1.15em;line-height:1.25;transition:transform .14s}
2106
2120
  .redesign .lacc[open]>summary .ltog{transform:rotate(90deg)}
2107
2121
  .redesign .lacc .lfirst{flex:1;min-width:0}
2108
2122
  .redesign .lacc .lbody{margin-top:12px;padding-left:18px}
2109
2123
  /* transcript rows — Linear-clean, borderless (You / tool mono / res green / Claude) */
2110
2124
  .redesign .rtx{border:0;background:none}
2111
- .redesign .txr{display:flex;gap:12px;padding:5px 0;font-size:13px;line-height:1.55}
2112
- .redesign .txr .w{flex:0 0 46px;font-size:11px;color:var(--ink3);padding-top:1px}
2125
+ .redesign .txr{display:flex;gap:12px;padding:5px 0;font-size:13px;line-height:1.55;align-items:baseline}
2126
+ .redesign .txr .w{flex:0 0 46px;font-size:11px;color:var(--ink3)}
2113
2127
  .redesign .txr.you .w,.redesign .txr.cl .w{color:var(--ink);font-weight:600}
2114
2128
  .redesign .txr .m{flex:1;min-width:0;color:var(--ink2)}
2115
2129
  .redesign .txr.cl .m{color:var(--ink)}
2116
2130
  .redesign .txr.tool .m{color:var(--ink2);font-family:var(--mono);font-size:12px}
2117
2131
  .redesign .txr.tool .m b{color:var(--ink);font-weight:600}
2118
2132
  .redesign .txr.res .m{font-family:var(--mono);font-size:12px;color:var(--green)}
2133
+ /* Manager's own progress notes inside the implementation log: commentary, not results.
2134
+ Green would read as "passed" (§5.5 UIクロームは薄く・優しく・主張しない). */
2135
+ .redesign .txr.note .m{font-family:var(--mono);font-size:12px;color:var(--ink3)}
2119
2136
  /* Discussion (review back-and-forth) folded at the bottom of Activity's body */
2120
2137
  .redesign .lbody .rthread{display:flex;flex-direction:column;gap:16px;margin:0}
2121
2138
  .redesign .lbody .rmsg .who{font-size:12px;font-weight:600;color:var(--ink);margin-bottom:4px}
@@ -3758,11 +3775,105 @@ function proofMediaHtml(taskId, proof, altLabel){
3758
3775
  // us, not by the work: the "before" shot came from the same photo-staging pass that P1
3759
3776
  // removed on the engine side (#228), and in practice it never existed at all, so every
3760
3777
  // card fell through to the single shot anyway. Callers now ask for that shot directly.
3761
- // (P1, Masa 2026-07-21) The before/after diff-box machinery (computeDiffBox,
3762
- // baLoadImage, baZoomDataUrl, beforeAfterBoxedHtml) was deleted here. It pixel-diffed a
3763
- // "before" capture against an "after" one to draw a red box around what changed real
3764
- // craft, built on a pair that the engine no longer stages (#228) and that never existed
3765
- // outside this repo anyway. With no before shot there is nothing to compare.
3778
+ // Red box (restored 2026-07-22 on Masa's ask). This machinery was deleted in #230 with
3779
+ // the staged before/after pair, because the 'before' was a photo WE took of a screen
3780
+ // that had nothing to do with the change. It comes back on a different footing: the two
3781
+ // images are both the worker's own shots, taken by the agent that did the work, and the
3782
+ // box is computed here from those two PNGs by pixel comparison — nothing is invented, a
3783
+ // box only appears when the pixels really differ in one region.
3784
+ // minCoverage lowered 2026-07-22: the old 0.05% floor was tuned on a 640x200 proof pair.
3785
+ // A real worker shot is a whole 900-1440px page, where a genuine change (a dot going
3786
+ // green, a label swapping) is well under that floor and was being thrown away as noise.
3787
+ // 30 pixels differing by more than `threshold` is a change someone made on purpose;
3788
+ // compression noise does not clear a 24-point RGB delta.
3789
+ function computeDiffBox(beforeImg, afterImg, { threshold = 24, minPixels = 30, maxCoverage = 0.7, pad = 0.03 } = {}) {
3790
+ const w = beforeImg.naturalWidth, h = beforeImg.naturalHeight;
3791
+ if (w !== afterImg.naturalWidth || h !== afterImg.naturalHeight) return null; // different-shaped images: don't guess
3792
+ const c1 = document.createElement('canvas'); c1.width = w; c1.height = h;
3793
+ const c2 = document.createElement('canvas'); c2.width = w; c2.height = h;
3794
+ c1.getContext('2d').drawImage(beforeImg, 0, 0, w, h);
3795
+ c2.getContext('2d').drawImage(afterImg, 0, 0, w, h);
3796
+ const d1 = c1.getContext('2d').getImageData(0, 0, w, h).data;
3797
+ const d2 = c2.getContext('2d').getImageData(0, 0, w, h).data;
3798
+ let minX = w, minY = h, maxX = -1, maxY = -1, diffPx = 0;
3799
+ for (let y = 0; y < h; y++) {
3800
+ for (let x = 0; x < w; x++) {
3801
+ const i = (y * w + x) * 4;
3802
+ const d = Math.abs(d1[i] - d2[i]) + Math.abs(d1[i + 1] - d2[i + 1]) + Math.abs(d1[i + 2] - d2[i + 2]);
3803
+ if (d > threshold) {
3804
+ diffPx++;
3805
+ if (x < minX) minX = x; if (x > maxX) maxX = x;
3806
+ if (y < minY) minY = y; if (y > maxY) maxY = y;
3807
+ }
3808
+ }
3809
+ }
3810
+ const total = w * h;
3811
+ if (maxX < 0 || diffPx < minPixels) return null; // no real difference — don't box a no-op
3812
+ if (diffPx / total > maxCoverage) return null; // diff too diffuse to be "a spot" — a full redesign shouldn't get a red box either
3813
+ const padPx = Math.round(Math.max(w, h) * pad);
3814
+ minX = Math.max(0, minX - padPx); minY = Math.max(0, minY - padPx);
3815
+ maxX = Math.min(w, maxX + padPx); maxY = Math.min(h, maxY + padPx);
3816
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
3817
+ }
3818
+ function baLoadImage(src){
3819
+ return new Promise((resolve, reject) => {
3820
+ const im = new Image();
3821
+ im.onload = () => resolve(im);
3822
+ im.onerror = () => reject(new Error(`image failed to load: ${src}`));
3823
+ im.src = src;
3824
+ });
3825
+ }
3826
+ // Crop {x,y,w,h} out of a decoded image and scale it up into a small inset — same
3827
+ // aspect ratio as the box, never stretched. Magnification is capped at 6x so a
3828
+ // near-single-pixel box doesn't blow up into mush.
3829
+ function baZoomDataUrl(img, box, maxW = 420, maxH = 200){
3830
+ const scale = Math.min(maxW / box.w, maxH / box.h, 6);
3831
+ const outW = Math.max(1, Math.round(box.w * scale));
3832
+ const outH = Math.max(1, Math.round(box.h * scale));
3833
+ const c = document.createElement('canvas'); c.width = outW; c.height = outH;
3834
+ c.getContext('2d').drawImage(img, box.x, box.y, box.w, box.h, 0, 0, outW, outH);
3835
+ return c.toDataURL('image/png');
3836
+ }
3837
+ // A before/after pair exists only when the AI SAYS two of its shots are the same view
3838
+ // before and after (task.run.compare = [beforeName, afterName]). Masa 2026-07-22: an
3839
+ // earlier version of this guessed the pair from file names containing "before"/"after",
3840
+ // which is this layer inventing a relationship it cannot know — the same mistake as the
3841
+ // intake AI writing the pass condition. A request for three options has no "after"; only
3842
+ // the agent that did the work knows which images relate, so only it can say so.
3843
+ //
3844
+ // Nothing declares `compare` yet (the engine keeps cmd/url/note/check), so no card draws
3845
+ // a box today. The machinery below stays wired: the day the worker can declare a pair,
3846
+ // it lights up with no further app change.
3847
+ function saShotPair(shots, declared){
3848
+ const list = shots ?? [];
3849
+ const pair = Array.isArray(declared) ? declared : null;
3850
+ if (!pair || pair.length !== 2) return null;
3851
+ const find = (n) => list.find((s) => s.name === n || String(s.url || '').endsWith(String(n)));
3852
+ const before = find(pair[0]), after = find(pair[1]);
3853
+ return before && after && before !== after ? { before, after } : null;
3854
+ }
3855
+ function saPlainPairHtml(pair){
3856
+ const col = (sh, label) => `<div class="ba-col"><span class="balabel">${label}</span><img class="proofimg" src="${esc(sh.url)}" alt="${esc(label)}" loading="lazy"></div>`;
3857
+ return `<div class="beforeafter">${col(pair.before, 'Before')}${col(pair.after, 'After')}</div>`;
3858
+ }
3859
+ // Renders the pair with the changed region boxed on both, plus a cropped enlargement.
3860
+ // Returns null on every fallback (not two images, load failure, no clean box, different
3861
+ // sizes) so the caller keeps showing the plain shots — never a guessed box.
3862
+ async function shotsBoxedHtml(before, after){
3863
+ if (!before?.url || !after?.url) return null;
3864
+ let bImg, aImg;
3865
+ try { [bImg, aImg] = await Promise.all([baLoadImage(before.url), baLoadImage(after.url)]); }
3866
+ catch { return null; }
3867
+ const box = computeDiffBox(bImg, aImg);
3868
+ if (!box) return null;
3869
+ const w = bImg.naturalWidth, h = bImg.naturalHeight;
3870
+ const st = `left:${(box.x / w * 100).toFixed(3)}%;top:${(box.y / h * 100).toFixed(3)}%;width:${(box.w / w * 100).toFixed(3)}%;height:${(box.h / h * 100).toFixed(3)}%`;
3871
+ const frame = (img, label) => `<div class="ba-col"><span class="balabel">${label}</span><div class="ba-imgwrap"><img class="proofimg" src="${img.src}" alt="${label.toLowerCase()}" loading="lazy"><div class="ba-box" style="${st}"></div></div></div>`;
3872
+ const zoom = (dataUrl, label) => `<div class="ba-col"><img class="ba-zoomimg" src="${dataUrl}" alt="zoomed ${label}"></div>`;
3873
+ return `<div class="beforeafter">${frame(bImg, 'Before')}${frame(aImg, 'After')}</div>`
3874
+ + `<div class="ba-zoomhd">Zoomed on the boxed area</div>`
3875
+ + `<div class="beforeafter ba-zoomrow">${zoom(baZoomDataUrl(bImg, box), 'before')}${zoom(baZoomDataUrl(aImg, box), 'after')}</div>`;
3876
+ }
3766
3877
  // swap the still for the real video only on demand, one at a time
3767
3878
  document.addEventListener('click', (e) => {
3768
3879
  const w = e.target.closest('.proofwrap');
@@ -7992,7 +8103,7 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
7992
8103
  // 4 archived / 5 reverted — session-local; the server round-trip is what
7993
8104
  // settles it (see saSync's "returned to review" reset).
7994
8105
  // ============================================================================
7995
- const SA = { open: false, items: [], diffs: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
8106
+ const SA = { open: false, items: [], diffs: {}, actLog: {}, shotBox: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
7996
8107
  window.SA = SA; // top-level const isn't a window property; external checks probe window.SA (same convention as window.state above)
7997
8108
  const SA_SVG = {
7998
8109
  ok: '<svg viewBox="0 0 24 24"><path d="M4 12l5 5L20 6"/></svg>',
@@ -8046,6 +8157,25 @@ function saRows(){
8046
8157
  const t = ts.find((x) => x.goalId === g?.id && !x.reply);
8047
8158
  return Array.isArray(t?.todosFinal) ? t.todosFinal : [];
8048
8159
  })(),
8160
+ // Screenshots the worker took of what it built (#229). Measured 2026-07-22 on real
8161
+ // runs: it takes 1-3 depending on the ask — three variants as three shots, or four
8162
+ // variants as ONE comparison sheet. A single shot is not a lazy shot, so none of
8163
+ // these get thumbnailed down.
8164
+ //
8165
+ // Folded goals count: a goal folded into this one keeps its own tasks, and those
8166
+ // tasks carry shots. Reading only the primary goal's tasks makes real deliverables
8167
+ // vanish from the screen (measured: #4 folded into #3, its images disappeared).
8168
+ shots: (() => {
8169
+ const ids = [...r.goalIds, ...folded.map((f) => f.id)];
8170
+ return ts.filter((t) => ids.includes(t.goalId)).flatMap((t) => t.shots ?? []);
8171
+ })(),
8172
+ // The task whose implementation log the engine froze at completion (P1.5). The
8173
+ // lines are NOT in state — 1000 of them per task would bloat every page load — so
8174
+ // this is just the id to ask for when the reviewer opens Activity.
8175
+ logTaskId: (() => {
8176
+ const t = ts.find((x) => x.goalId === g?.id && !x.reply && x.activityLog);
8177
+ return t?.id ?? null;
8178
+ })(),
8049
8179
  // P1 (Masa 2026-07-21): the AI's own final report, verbatim and untrimmed — the
8050
8180
  // card renders it as Markdown and interprets nothing. This is the deliverable for
8051
8181
  // every ask that has no running thing to open ("research this", "explain that"),
@@ -8068,9 +8198,7 @@ function saRows(){
8068
8198
  testResult: (typeof (g?.reviewSummary?.tests) === 'string')
8069
8199
  ? { ran: true, ok: true, passed: parseInt(g.reviewSummary.tests, 10) || 0, failed: 0, label: g.reviewSummary.tests }
8070
8200
  : r.testResult,
8071
- answer: g?.reviewSummary?.answer ?? null,
8072
8201
  activity: Array.isArray(g?.reviewSummary?.activity) ? g.reviewSummary.activity : null,
8073
- discussion: Array.isArray(g?.reviewSummary?.discussion) ? g.reviewSummary.discussion : null,
8074
8202
  changedDesc: g?.reviewSummary?.changed ?? null,
8075
8203
  metaFiles: Array.isArray(g?.reviewSummary?.files) ? g.reviewSummary.files : null,
8076
8204
  metaAdd: g?.reviewSummary?.a ?? null, metaDel: g?.reviewSummary?.d ?? null,
@@ -8085,7 +8213,6 @@ function saRows(){
8085
8213
  recordingUrl: pt?.proof?.mp4 ? withKey(`/proof/${pt.id}/${pt.proof.mp4}`) : (typeof g?.reviewSummary?.recording === 'string' ? g.reviewSummary.recording : null),
8086
8214
  // Local URL (spec §5): a running localhost the reviewer can open ("Local localhost:4809 ↗").
8087
8215
  // Falls back to the existing "Local run ↗" (runlocal) button when absent.
8088
- localUrl: g?.reviewSummary?.localUrl ?? null,
8089
8216
  // Client-side diff fallback: the git-backed /diff endpoint is null for seeded/
8090
8217
  // offline goals, so "What changed" never renders. A unified-diff string stored on
8091
8218
  // the goal (or any of its tasks) lets saGhDiffHtml render locally with no server.
@@ -8111,35 +8238,30 @@ function saRows(){
8111
8238
  // (P1, Masa 2026-07-21) saMetaRowHtml() was deleted here — the pre-redesign meta row,
8112
8239
  // uncalled since the review-card redesign. Its "Run locally" chip only ever opened a
8113
8240
  // note saying no run command was wired; the card's Open it chip now starts the real one.
8114
- // Context: closed by default, one-line verbatim-quote summary, chevron large and at
8115
- // the far right (H23 §2). Quotes goal.text the user's own words never
8116
- // task.detail/title (AI-authored; an earlier round of this design mistakenly quoted
8117
- // the AI's own read on the request as "your ask" and it wasn't recognizable).
8118
- function saContextHtml(it, gk){
8119
- const goalTxt = it.goalText;
8120
- const summary = goalTxt
8121
- ? `"${esc(goalTxt.length > 60 ? `${goalTxt.slice(0, 60)}…` : goalTxt)}"`
8122
- : 'No verbatim ask on record for this goal.';
8123
- const split = it.siblings
8124
- ? `<div class="lg-splitnote"><div class="hd">Split into ${it.siblings.length} goals — nothing else was dropped</div><ul>${it.siblings.map((s) => `<li class="${s.cur ? 'cur' : ''}">${s.no != null ? `#${esc(s.no)} ` : ''}${esc(s.title)}${s.cur ? ' <b>(this one)</b>' : ''}</li>`).join('')}</ul></div>`
8125
- : '';
8126
- const did = it.did?.length ? `<div class="lg-bullethd">What we did</div><ul class="lg-bullets">${it.did.map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : '';
8127
- const look = it.look?.length ? `<div class="lg-bullethd">What to look at</div><ul class="lg-bullets">${it.look.map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : '';
8128
- const inner = (split || did || look) ? `${split}${did}${look}` : '<div class="lg-notfound">Nothing else on record for this goal.</div>';
8129
- const open = SA.detOpen.has(`${gk}:ctx`) ? ' open' : '';
8130
- return `<details class="lg-goal" data-detkey="${gk}:ctx"${open}><summary><span class="k">Context</span><span class="v">${summary}</span><i class="gtoggle">›</i></summary><div class="lg-context">${inner}</div></details>`;
8131
- }
8132
- // (P1, Masa 2026-07-21) saEvidenceHtml() was deleted here. It rendered the four
8133
- // "evidence hero" shapes for the pre-redesign card — including the 'missing' one that
8134
- // told the reviewer "No capture — this touched the UI and should have one". Nothing has
8135
- // called it since the review-card redesign, and its premise (we owe you a screenshot)
8136
- // is exactly what P1 removes. The report the AI wrote, plus Open it, is the evidence.
8241
+ // (saContextHtml() was deleted here Masa 2026-07-22. The pre-redesign Context fold:
8242
+ // verbatim quote, 'What we did' / 'What to look at' bullets, sibling split note. Nothing
8243
+ // has called it since the review-card redesign; Request and Result carry that ground now.)
8137
8244
  // Unified-diff → GitHub two-gutter model (REVIEW-CARD-SPEC §7 "GitHub diff = Bright"):
8138
8245
  // walk the @@ hunk headers to recover the old/new line numbers, emit one row per line
8139
8246
  // {o:oldNo|null, n:newNo|null, t:'add'|'del'|'ctx', c:text}. Word-level highlight is
8140
8247
  // NOT fabricated from a raw diff (the product never invents evidence) — the Bright
8141
8248
  // line+gutter tint carries the change and .gh-hi simply stays absent. Pure, so it is
8142
8249
  // extracted and unit-tested in engine/test/review-card-diff.test.mjs.
8250
+ // How many body rows the diff really has, ignoring the display cap. A big change used
8251
+ // to be cut at 80 rows with nothing said (SPEC §7 asks for "…+N 行 · GitHub で開く ↗"
8252
+ // precisely so a reviewer is never shown a slice that looks like the whole thing).
8253
+ // Same skip rules as saUnifiedToGh below — headers and @@ markers are not rows.
8254
+ function saDiffRowCount(diff){
8255
+ let n = 0;
8256
+ for (const raw of String(diff || '').split('\n')) {
8257
+ if (raw === '') continue;
8258
+ if (/^(diff --git|index |--- |\+\+\+ |new file|deleted file|old mode|new mode|similarity|rename |Binary )/.test(raw)) continue;
8259
+ if (/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/.test(raw)) continue;
8260
+ if (raw.startsWith('\\')) continue;
8261
+ n++;
8262
+ }
8263
+ return n;
8264
+ }
8143
8265
  function saUnifiedToGh(diff, max){
8144
8266
  const cap = max || 80; const out = []; let o = 0, n = 0;
8145
8267
  for (const raw of String(diff || '').split('\n')) {
@@ -8155,10 +8277,13 @@ function saUnifiedToGh(diff, max){
8155
8277
  }
8156
8278
  return out;
8157
8279
  }
8158
- function saGhDiffHtml(diff){
8280
+ function saGhDiffHtml(diff, pr){
8159
8281
  const rows = saUnifiedToGh(diff);
8160
8282
  if (!rows.length) return '';
8161
- return saGhDiffRowsHtml(rows);
8283
+ // Say what was left out. Before this, a 5000-line change rendered its first 80 rows
8284
+ // and stopped — a quiet lie, and the one thing SPEC §7 names as forbidden.
8285
+ const more = Math.max(0, saDiffRowCount(diff) - rows.length);
8286
+ return saGhDiffRowsHtml(rows, more, pr || '');
8162
8287
  }
8163
8288
  // One Activity transcript row (spec §3): You / tool (mono, bold name) / res (green) /
8164
8289
  // Claude. Mirrors the flagship artifact's tx mapping so the pulldown pixel-matches.
@@ -8168,6 +8293,18 @@ function saTxRowHtml(e){
8168
8293
  if (e[0] === 'user') return `<div class="txr you"><span class="w">You</span><span class="m">${esc(e[1])}</span></div>`;
8169
8294
  return `<div class="txr cl"><span class="w">Claude</span><span class="m">${esc(e[1])}</span></div>`;
8170
8295
  }
8296
+ // One line of the frozen implementation log. The worker's own tool calls come through
8297
+ // as "Read app/index.html" / "Bash npm test"; the Manager's own progress notes are
8298
+ // prefixed with ✎ by sendProcessAct() and read as commentary, so they take the quieter
8299
+ // row. Nothing is parsed beyond that split — the line is shown as it was written.
8300
+ function saLogRowHtml(line){
8301
+ const s = String(line ?? '');
8302
+ if (s.startsWith('✎')) return `<div class="txr note"><span class="w"></span><span class="m">${esc(s.replace(/^✎\s*/, ''))}</span></div>`;
8303
+ const m = s.match(/^([A-Z][A-Za-z]*)\s+([\s\S]*)$/);
8304
+ return m
8305
+ ? `<div class="txr tool"><span class="w">tool</span><span class="m"><b>${esc(m[1])}</b> ${esc(m[2])}</span></div>`
8306
+ : `<div class="txr cl"><span class="w"></span><span class="m">${esc(s)}</span></div>`;
8307
+ }
8171
8308
  // Structured GitHub diff (REVIEW-CARD-SPEC §7): renders {o,n,t,c,h} rows with a
8172
8309
  // word-level highlight (h) and, for a huge change, a "…+N 行 · GitHub で開く ↗"
8173
8310
  // footer (only linking to GitHub when a PR exists). Mirrors the flagship artifact's
@@ -8207,7 +8344,15 @@ function saWorkflowHtml(it){
8207
8344
  // P1 (Masa 2026-07-21): "Proof captured □" is gone. We no longer go take photos of
8208
8345
  // the work, so an empty box there was scoring the goal against a step that isn't
8209
8346
  // part of the process any more — a permanent unchecked box nobody could ever check.
8210
- const steps = [['Implemented', 'ok'], ['Tested', ran ? 'ok' : 'todo'], ['In review', 'now'], ['Merge', 'todo'], ['Deploy', 'todo']];
8347
+ // "Deploy" is gone too (Masa 2026-07-22): nothing in the product observes a deploy,
8348
+ // so that box could never be ticked — the same permanent-□ problem as Proof captured.
8349
+ // Merge stays because it IS observed: the PR poller flips the goal to done on merge.
8350
+ //
8351
+ // (PRD §5.5-A/15 asks for the USER'S OWN pipeline — PRD→design→implement→check→PR→
8352
+ // merge→deploy, varying per person. There is no place to record such a pipeline yet,
8353
+ // so inventing one here would be this layer guessing someone's process. Left as a
8354
+ // decision, not faked.)
8355
+ const steps = [['Implemented', 'ok'], ['Tested', ran ? 'ok' : 'todo'], ['In review', 'now'], ['Merge', 'todo']];
8211
8356
  return '<div class="rwf">' + steps.map(([s, st]) => `<div class="wf-s ${st}"><span class="mk">${st === 'ok' ? '✓' : st === 'now' ? '→' : '□'}</span>${s}</div>`).join('') + '</div>';
8212
8357
  }
8213
8358
  function saItemHtml(it, i){
@@ -8250,9 +8395,9 @@ function saItemHtml(it, i){
8250
8395
  const kind = saEvidenceKind(filePaths, hasProofImg, gtr);
8251
8396
  const gk = it.goalId;
8252
8397
  const rs = it.goal?.reviewSummary || {};
8253
- // Conversational task (no code change): drops Proof + What changed, shows an Answer
8254
- // instead (spec §11); its Activity IS the conversation.
8255
- const isConv = Boolean(it.answer);
8398
+ // A conversational task (no code change) has no Proof / What changed to show. Its
8399
+ // Result IS the answer (P1) the old separate Answer section is gone.
8400
+ const isConv = !it.report && !filePaths.length;
8256
8401
  // ── meta (spec §5) — fully data-driven / hide-empty: each chip renders only when its
8257
8402
  // data exists (no PR → no PR chip · no tests → no Tests · no file totals → no Files ·
8258
8403
  // no recording → no Verify). PR = bold green, no underline (a real <a href>). ──
@@ -8269,10 +8414,9 @@ function saItemHtml(it, i){
8269
8414
  if (testsLabel) chips.push(`<span class="mx"><span class="mk">Tests</span> <b class="ok">${esc(testsLabel)}</b></span>`);
8270
8415
  // Files: hide-empty — only with changed files AND totals (a bare-count chip reads odd).
8271
8416
  if (nf && fAdd) chips.push(`<span class="mx"><span class="mk">Files</span> <b>${nf}</b> <span class="mmut">· <i class="a">${esc(fAdd)}</i> <i class="d">${esc(fDel)}</i></span></span>`);
8272
- // Local: an open localhost URL (neutral, not green). A bare host:port isn't navigable in
8273
- // the demo, so it renders as a non-navigating chip; a real URL opens in a new tab.
8274
- if (it.localUrl) { const lu = String(it.localUrl); const isUrl = /^https?:/.test(lu);
8275
- chips.push(`<a class="mx mlink"${isUrl ? ` href="${esc(lu)}" target="_blank" rel="noopener"` : ' style="cursor:pointer"'} title="Open the running app"><span class="mk">Local</span> <b>${esc(lu.replace(/^https?:\/\//, ''))}</b>${arw}</a>`); }
8417
+ // (The Local chip was deleted here Masa 2026-07-22. It read reviewSummary.localUrl,
8418
+ // a key parseReviewSummary never emits, so it only ever appeared in demo data; and the
8419
+ // real thing it promised is the Open it section.)
8276
8420
  // (Open it is NOT a meta chip — Masa 2026-07-22. It was saying the same thing as the
8277
8421
  // Open it section below, in the PR chip's vocabulary, so the card spoke two languages
8278
8422
  // about one thing. It lives in the body now, in the same typography as every other
@@ -8333,6 +8477,21 @@ function saItemHtml(it, i){
8333
8477
  // change explains itself here, and an image the AI pasted shows up here as an image.
8334
8478
  // We interpret nothing; mdLite only turns Markdown into the shapes it already means. ──
8335
8479
  const reportBody = it.report ? `<div class="rreport">${mdLite(it.report, { images: false })}</div>` : '';
8480
+ // ── Screenshots (P2, Masa 2026-07-22): the pictures the AI took of its own result.
8481
+ // Full width, at their own aspect — a four-variant comparison sheet is unreadable
8482
+ // shrunk into a thumbnail, and that is the shape the AI actually produces. The file
8483
+ // name it chose is the caption (it names them variant-1-linear.png / compare.png), so
8484
+ // it labels its own work; we add nothing. ──
8485
+ // A before/after pair is two shots the worker named as such — it decides whether the
8486
+ // change had a "before" worth shooting, we only recognise the naming. Anything else is
8487
+ // shown as the plain list.
8488
+ const shotPair = saShotPair(it.shots, it.run?.compare);
8489
+ const boxed = shotPair ? SA.shotBox[it.goalId] : null;
8490
+ const shotsBody = shotPair
8491
+ ? `<div class="rshotpair" data-shotpair="${it.goalId}">${typeof boxed === 'string' ? boxed : saPlainPairHtml(shotPair)}</div>`
8492
+ : it.shots?.length
8493
+ ? it.shots.map((sh) => `<figure class="rshot"><img src="${esc(sh.url)}" alt="${esc(sh.name || 'screenshot')}" loading="lazy"><figcaption>${esc(String(sh.name || '').replace(/\.[a-z]+$/i, ''))}</figcaption></figure>`).join('')
8494
+ : '';
8336
8495
  // ── Proof (spec §6/§11): before/after (identical size) when a real image exists; a
8337
8496
  // run-log for logic-only tests; dropped entirely for a conversational task. ──
8338
8497
  let proofInner = '';
@@ -8355,7 +8514,7 @@ function saItemHtml(it, i){
8355
8514
  : (clientDiff && filePaths.length) ? `<div class="rfiles">${filePaths.map((p) => esc(p)).join('<br>')}</div>` : '';
8356
8515
  const diffHtml = it.ghRows
8357
8516
  ? saGhDiffRowsHtml(it.ghRows, it.diffTotal || 0, it.pr || '')
8358
- : hasGitDiff ? saGhDiffHtml(d.diff) : clientDiff ? saGhDiffHtml(clientDiff) : '';
8517
+ : hasGitDiff ? saGhDiffHtml(d.diff, it.pr) : clientDiff ? saGhDiffHtml(clientDiff, it.pr) : '';
8359
8518
  const changedBody = isConv ? '' : (changedDescHtml + filesListHtml + diffHtml);
8360
8519
  // ── sensitive-area note (real: classifyChangeRisk forced the pause) ──
8361
8520
  const risk = it.risk;
@@ -8373,17 +8532,30 @@ function saItemHtml(it, i){
8373
8532
  const txArr = (it.activity && it.activity.length)
8374
8533
  ? it.activity.map((e) => saTxRowHtml(e))
8375
8534
  : ((!reportBody && it.did?.length) ? it.did.map((s) => saTxRowHtml(['claude', s])) : []);
8376
- const discArr = it.discussion || it.thread || null;
8377
- const threadHtml = (discArr && discArr.length)
8378
- ? discArr.map((m) => `<div class="rmsg${m[0] === 'you' ? ' you' : ''}"><div class="who">${m[0] === 'you' ? 'You' : 'Manager'}</div><div class="msg">${esc(m[1])}</div></div>`).join('')
8379
- : '';
8535
+ // (Discussion was deleted here — Masa 2026-07-22. It read reviewSummary.discussion,
8536
+ // which parseReviewSummary never emits, so it never rendered on a real goal; replies
8537
+ // already live in the card's own reply box.)
8538
+ const threadHtml = '';
8539
+ // ── Activity = the implementation log (P1.5, Masa 2026-07-22): what the AI actually
8540
+ // did, read AFTER it finished. Fetched only when the section is opened — a task's log
8541
+ // runs to ~500KB and most cards are never expanded — so the closed state is one line
8542
+ // and the lines arrive on the first open (same "only when asked" shape as Open it).
8543
+ // Once fetched they live in SA.actLog so an SSE rebuild doesn't wipe them.
8544
+ const logLines = it.logTaskId != null ? SA.actLog[it.logTaskId] : null;
8545
+ const logHtml = Array.isArray(logLines)
8546
+ ? (logLines.length
8547
+ ? logLines.map((l) => saLogRowHtml(l)).join('')
8548
+ : '<div class="txr res"><span class="w"></span><span class="m">Nothing was logged for this one.</span></div>')
8549
+ : '<div class="txr res"><span class="w"></span><span class="m">Loading…</span></div>';
8380
8550
  const actOpen = SA.detOpen.has(`${gk}:act`) ? ' open' : '';
8381
8551
  const txFirst = txArr[0] || '<div class="txr"><span class="w"></span><span class="m">Activity</span></div>';
8382
8552
  const txRest = txArr.slice(1).join('');
8383
8553
  const discInner = threadHtml ? `<div class="rsub">Discussion</div><div class="rthread">${threadHtml}</div>` : '';
8384
8554
  const activitySec = (txArr.length || threadHtml)
8385
8555
  ? `<section class="rsec ract"><div class="rsec-h">Activity</div><details class="lacc" data-detkey="${gk}:act"${actOpen}><summary><i class="ltog">›</i><div class="rtx lfirst">${txFirst}</div></summary><div class="lbody">${txRest ? `<div class="rtx">${txRest}</div>` : ''}${discInner}</div></details></section>`
8386
- : '';
8556
+ : it.logTaskId != null
8557
+ ? `<section class="rsec ract"><div class="rsec-h">Activity</div><details class="lacc" data-detkey="${gk}:act" data-actlog="${it.logTaskId}"${actOpen}><summary><i class="ltog">›</i><div class="rtx lfirst">${saTxRowHtml(['user', it.goalText || it.title])}</div></summary><div class="lbody"><div class="rtx" data-actbody="${it.logTaskId}">${logHtml}</div>${discInner}</div></details></section>`
8558
+ : '';
8387
8559
  const S = (label, body) => body ? `<section class="rsec"><div class="rsec-h">${label}</div>${body}</section>` : '';
8388
8560
  return `<div class="sa-item lg redesign${it.goalId === SA.cur ? ' cur' : ''}" data-gid="${it.goalId}">
8389
8561
  <div class="rhead"><span class="rtag">${it.no != null ? `#${it.no}` : ''}</span><span class="rst"><span class="rdot"></span>In review</span></div>
@@ -8394,11 +8566,10 @@ function saItemHtml(it, i){
8394
8566
  ${S('Also carries', carriesBody)}
8395
8567
  ${S('What to check', checkBody)}
8396
8568
  ${S('Result', reportBody)}
8569
+ ${S('Screenshots', shotsBody)}
8397
8570
  ${S('Steps', stepsBody)}
8398
- ${S('Answer', it.answer ? `<p class="rbody">${esc(it.answer)}</p>` : '')}
8399
8571
  ${S('Proof', proofInner)}
8400
8572
  ${S('What changed', changedBody)}
8401
- ${S('Not done', it.risk?.summary ? `<p class="rbody rrisk">${esc(it.risk.summary)}</p>` : '')}
8402
8573
  ${riskHtml}
8403
8574
  ${activitySec}
8404
8575
  ${S('Workflow', saWorkflowHtml(it))}
@@ -8413,6 +8584,40 @@ function saScrollCardToTop(el){
8413
8584
  if (!body || !el) return;
8414
8585
  body.scrollTop += el.getBoundingClientRect().top - body.getBoundingClientRect().top;
8415
8586
  }
8587
+ // Fetch one task's frozen implementation log and paint it into the open card. Cached in
8588
+ // SA.actLog (including an empty result) so a card can be opened and closed without
8589
+ // asking twice. A failure writes an honest line rather than an empty box — the log
8590
+ // either exists or it doesn't, and we say which.
8591
+ async function saLoadActivityLog(taskId){
8592
+ if (!Number.isFinite(taskId) || SA.actLog[taskId] !== undefined) return;
8593
+ SA.actLog[taskId] = null; // in flight — keeps a second toggle from re-fetching
8594
+ let lines = [];
8595
+ try {
8596
+ const r = await fetch(withKey(`/api/tasks/${taskId}/activity`));
8597
+ lines = r.ok ? ((await r.json()).lines ?? []) : [];
8598
+ } catch { lines = []; }
8599
+ SA.actLog[taskId] = lines;
8600
+ const body = document.querySelector(`#seeall [data-actbody="${taskId}"]`);
8601
+ if (!body) return;
8602
+ body.innerHTML = lines.length
8603
+ ? lines.map((l) => saLogRowHtml(l)).join('')
8604
+ : '<div class="txr res"><span class="w"></span><span class="m">Nothing was logged for this one.</span></div>';
8605
+ }
8606
+ // After first paint, compute the red box for any card showing a before/after pair and
8607
+ // patch that card's node directly — a full re-render here would close every open
8608
+ // pulldown just to draw a box. Cached per goal (null included) so it runs once.
8609
+ async function saEnhanceShotBoxes(){
8610
+ const targets = SA.items.filter((it) => saShotPair(it.shots, it.run?.compare) && SA.shotBox[it.goalId] === undefined);
8611
+ await Promise.all(targets.map(async (it) => {
8612
+ const pair = saShotPair(it.shots, it.run?.compare);
8613
+ let html = null;
8614
+ try { html = await shotsBoxedHtml(pair.before, pair.after); } catch { html = null; }
8615
+ SA.shotBox[it.goalId] = html;
8616
+ if (!html || !SA.open) return;
8617
+ const host = document.querySelector(`#seeall [data-shotpair="${it.goalId}"]`);
8618
+ if (host) host.innerHTML = html;
8619
+ }));
8620
+ }
8416
8621
  function saRender(){
8417
8622
  const S = $('seeall'); if (!S) return;
8418
8623
  const pending = SA.items.filter((x) => x.st === 0);
@@ -8429,7 +8634,14 @@ function saRender(){
8429
8634
  det.addEventListener('toggle', () => {
8430
8635
  const k = det.dataset.detkey;
8431
8636
  if (det.open) SA.detOpen.add(k); else SA.detOpen.delete(k);
8637
+ // Opening Activity is what fetches the implementation log (Masa 2026-07-22:
8638
+ // "開いた時だけ取りに行く"). Closed cards cost nothing; a reopened one is served
8639
+ // from SA.actLog.
8640
+ if (det.open && det.dataset.actlog) saLoadActivityLog(Number(det.dataset.actlog));
8432
8641
  });
8642
+ // A card re-rendered while its Activity was already open (SSE tick) keeps the log
8643
+ // it had — the fetch below no-ops once SA.actLog holds the answer.
8644
+ if (det.open && det.dataset.actlog) saLoadActivityLog(Number(det.dataset.actlog));
8433
8645
  }
8434
8646
  // "ないものは載せない" (Masa): a Proof <img> whose file is missing/broken must never
8435
8647
  // show a broken-image placeholder. proofImg is only a stored filename, so a file that
@@ -8511,6 +8723,7 @@ function saOpen(focusGoalId){
8511
8723
  if (el) saScrollCardToTop(el);
8512
8724
  });
8513
8725
  saFetchDiffs();
8726
+ saEnhanceShotBoxes();
8514
8727
  }
8515
8728
  function saClose(){ SA.open = false; SA.focusGid = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); }
8516
8729
  // Continuous review (Masa 2026-07-08): move a persistent "current" card with
package/engine/lib.mjs CHANGED
@@ -534,6 +534,96 @@ export function parseStreamEvents(line) {
534
534
  return out;
535
535
  }
536
536
 
537
+ // ---- CODEX BRIDGE ----------------------------------------------------------
538
+ // The same three wires the product is built out of — the board (the agent's own
539
+ // to-dos), the implementation log (what it actually did), and the thread it can
540
+ // be talked to again in — read from `codex exec --json` instead of `claude -p
541
+ // --output-format stream-json`. Everything above this line stays agent-agnostic:
542
+ // the bridge's whole job is to speak Codex's event vocabulary into the one this
543
+ // engine already has, so nothing downstream branches on which agent ran.
544
+ //
545
+ // The shapes below are transcribed from a real `codex exec --json` run
546
+ // (codex-cli 0.142.4) — see engine/test/codex-bridge.test.mjs, whose fixtures
547
+ // are that run's stdout verbatim rather than a schema we imagined.
548
+ export function parseCodexTodos(items) {
549
+ return (Array.isArray(items) ? items : [])
550
+ .map((i) => ({ content: String(i?.text ?? '').trim(), status: i?.completed ? 'completed' : 'pending' }))
551
+ .filter((t) => t.content);
552
+ }
553
+
554
+ // Codex reports usage with its own key names AND its own arithmetic: its
555
+ // `input_tokens` INCLUDES the cached ones, where claude reports cache reads as a
556
+ // separate bucket that input_tokens excludes. Adding both as-is counted the
557
+ // cached tokens twice, so the ledger showed a Codex run as far more expensive
558
+ // than it was (measured: ~92k of a ~108k turn was cache). Subtract, so a number
559
+ // from either agent means the same thing.
560
+ export function parseCodexUsage(usage) {
561
+ if (!usage) return null;
562
+ const total = usage.input_tokens ?? usage.inputTokens ?? 0;
563
+ const cached = usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? 0;
564
+ return {
565
+ input_tokens: Math.max(0, total - cached),
566
+ output_tokens: usage.output_tokens ?? usage.outputTokens ?? 0,
567
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
568
+ cache_read_input_tokens: cached,
569
+ };
570
+ }
571
+
572
+ // Parse one line of `codex exec --json` output into the same event vocabulary
573
+ // parseStreamEvents returns ('session' | 'todos' | 'activity' | 'message' |
574
+ // 'usage'). 'message' rather than 'result' because Codex emits an agent_message
575
+ // per narration step, not once at the end — the caller keeps the last one.
576
+ export function parseCodexEvents(line) {
577
+ let ev;
578
+ try { ev = JSON.parse(line); } catch { return []; }
579
+ if (!ev || typeof ev !== 'object') return [];
580
+ if (ev.type === 'thread.started' && ev.thread_id) return [{ kind: 'session', id: String(ev.thread_id) }];
581
+ if (ev.type === 'turn.completed') return [{ kind: 'usage', usage: parseCodexUsage(ev.usage) }];
582
+ if (ev.type === 'error' && ev.message) return [{ kind: 'error', text: String(ev.message) }];
583
+ if (!['item.started', 'item.updated', 'item.completed'].includes(ev.type)) return [];
584
+ const item = ev.item ?? {};
585
+ if (item.type === 'todo_list') {
586
+ const todos = parseCodexTodos(item.items);
587
+ return todos.length ? [{ kind: 'todos', todos }] : [];
588
+ }
589
+ if (item.type === 'agent_message' && ev.type === 'item.completed' && item.text) {
590
+ return [{ kind: 'message', text: String(item.text) }];
591
+ }
592
+ // Commands are announced when they start (the log should move while the
593
+ // worker works, not after) and only mentioned again when they fail.
594
+ if (item.type === 'command_execution' && item.command) {
595
+ if (ev.type === 'item.started') return [{ kind: 'activity', text: `Run ${String(item.command)}`.slice(0, 140) }];
596
+ if (ev.type === 'item.completed' && item.exit_code) {
597
+ return [{ kind: 'activity', text: `Run ${String(item.command)} (exit ${item.exit_code})`.slice(0, 140) }];
598
+ }
599
+ return [];
600
+ }
601
+ // File edits are the bulk of what a worker does and were entirely missing from
602
+ // the log on Codex — the run read as "a few commands and then, somehow, a PR".
603
+ if (item.type === 'file_change' && ev.type === 'item.completed') {
604
+ const verb = { add: 'Write', delete: 'Delete' };
605
+ return (Array.isArray(item.changes) ? item.changes : [])
606
+ .filter((c) => c?.path)
607
+ .slice(0, 8)
608
+ .map((c) => ({ kind: 'activity', text: `${verb[c.kind] ?? 'Edit'} ${String(c.path)}`.slice(0, 140) }));
609
+ }
610
+ if (item.type === 'error' && item.message && ev.type === 'item.completed') {
611
+ return [{ kind: 'error', text: String(item.message) }];
612
+ }
613
+ return [];
614
+ }
615
+
616
+ // Argument list for one `codex exec` run. `resume` continues an existing thread
617
+ // (the id from a previous run's thread.started) — that subcommand takes neither
618
+ // --cd nor --sandbox, so the working directory comes from the spawn's cwd and
619
+ // the sandbox from -c sandbox_mode. Verified against codex-cli 0.142.4.
620
+ export function buildCodexArgs({ model, effort, sandbox, cwd, resume } = {}) {
621
+ const common = ['--json', '-m', model, '-c', `model_reasoning_effort="${effort}"`, '--skip-git-repo-check'];
622
+ return resume
623
+ ? ['exec', 'resume', String(resume), ...common, '-c', `sandbox_mode="${sandbox}"`, '-']
624
+ : ['exec', ...common, '--sandbox', sandbox, '--cd', cwd, '-'];
625
+ }
626
+
537
627
  // Parse the planner's reply into { entry, tasks }. Accepts either a JSON
538
628
  // object {entry, tasks:[...]} or a bare JSON array anywhere in the text;
539
629
  // falls back to a single task made from the raw request. Each task may carry
@@ -2219,6 +2309,15 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code')
2219
2309
  lastFailure ? `\nPREVIOUS ATTEMPT FAILED EXTERNAL VERIFICATION: ${lastFailure}\nYour previous changes are on disk. Diagnose and fix.` : '',
2220
2310
  '',
2221
2311
  'RULES:',
2312
+ // The board is a window onto the agent's own plan — it holds nothing this
2313
+ // layer invented. That only works if the agent keeps a plan where we can see
2314
+ // it. Claude Code reaches for TodoWrite on its own; Codex has the same list
2315
+ // but skips it on anything small (measured 2026-07-22: gpt-5.4-mini emitted
2316
+ // no todo_list even when the REQUEST said "in three steps" — it does emit one
2317
+ // when told to track the work in its to-do list). So ask, once, for every
2318
+ // agent — the instruction is about how to work, never about what the request
2319
+ // is, so no request-type branching enters here.
2320
+ '- 作業の手順は最初に自分の Todo リスト(Claude Code なら TodoWrite、Codex なら todo list)に書き出し、進むたびに更新すること。人はこの Todo を見て進み具合を知る。',
2222
2321
  '- Edit only files inside this directory and this project. Stay on this request; do not wander into unrelated work.',
2223
2322
  // Deliverable fidelity (replaces the old "make the smallest safe change
2224
2323
  // instead of expanding scope"): the request, not our fear of scope, sets
@@ -2251,7 +2350,14 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code')
2251
2350
  // to use it — that is the worker's call, like the run declaration above.
2252
2351
  // Telling it "screenshot UI changes" would be this layer deciding what kind
2253
2352
  // of request it is, which is the behaviour that produced off-target proof.
2254
- `- 画面・見た目のあるものを作ったなら、自分でスクリーンショットを撮って見せてよい: \`node ${SHOT_CLI} <URL または HTMLファイル> ${SHOT_DIR}/<名前>.png [--full] [--wait-for <CSSセレクタ>]\`(headless Chrome・前面に出ない)。${SHOT_DIR}/ に置いた画像は Manager が回収して人に見せる(diff には入らない)。撮ったものは Read で自分でも見返せる。`,
2353
+ // ...but only to an agent that can actually take one. Codex runs its shell
2354
+ // inside a sandbox that Chrome cannot start under (measured 2026-07-22 on
2355
+ // codex-cli 0.142.4: the camera fails under `workspace-write` and works only
2356
+ // with the sandbox off — opening network access does not help). Offering it
2357
+ // anyway is not a harmless extra line: the Codex worker tried four times,
2358
+ // then reported the failure instead of the work. Do not ask an agent for
2359
+ // something this machine has proven it cannot do (Masa 2026-07-22).
2360
+ agentName === 'codex' ? '' : `- 画面・見た目のあるものを作ったなら、自分でスクリーンショットを撮って見せてよい: \`node ${SHOT_CLI} <URL または HTMLファイル> ${SHOT_DIR}/<名前>.png [--full] [--wait-for <CSSセレクタ>]\`(headless Chrome・前面に出ない)。${SHOT_DIR}/ に置いた画像は Manager が回収して人に見せる(diff には入らない)。撮ったものは Read で自分でも見返せる。`,
2255
2361
  `- 最後に${replyLang}で2〜4行:何を変えたか・どのファイルか・テスト結果・人間が確認すべき点。`,
2256
2362
  ].filter(Boolean).join('\n');
2257
2363
  }
package/engine/server.mjs CHANGED
@@ -21,7 +21,7 @@ import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
23
23
  import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse } from './lib.mjs';
24
- import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyVerifyGate, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames } from './lib.mjs';
24
+ import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyVerifyGate, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames } from './lib.mjs';
25
25
  import { createSerialQueue } from './lib.mjs';
26
26
  import { openPR } from './pr.mjs';
27
27
  import { runVerification, exerciseUi } from './verify.mjs';
@@ -1080,54 +1080,49 @@ function runClaude({ prompt, cwd, tools, onEvent, onTodos, resume, model, effort
1080
1080
  });
1081
1081
  });
1082
1082
  }
1083
- function parseCodexUsage(usage) {
1084
- if (!usage) return null;
1085
- return {
1086
- input_tokens: usage.input_tokens ?? usage.inputTokens ?? 0,
1087
- output_tokens: usage.output_tokens ?? usage.outputTokens ?? 0,
1088
- cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
1089
- cache_read_input_tokens: usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? 0,
1090
- };
1091
- }
1092
- function runCodex({ prompt, cwd, onEvent, permissionMode = 'acceptEdits', model, effort, onChild }) {
1083
+ // The Codex half of the worker bridge. Same contract as runClaude, because
1084
+ // nothing downstream may care which agent ran: live activity, the agent's own
1085
+ // to-dos (the board), the thread id to talk to it again, usage, final message.
1086
+ function runCodex({ prompt, cwd, onEvent, onTodos, resume, permissionMode = 'acceptEdits', model, effort, onChild }) {
1093
1087
  return new Promise((done) => {
1094
1088
  const sandbox = permissionMode === 'plan' ? 'read-only' : 'workspace-write';
1095
- const args = ['exec', '--json', '-m', workerModel('codex', model), '-c', `model_reasoning_effort="${workerEffort('codex', effort)}"`, '--sandbox', sandbox, '--skip-git-repo-check', '--cd', cwd, '-'];
1089
+ const args = buildCodexArgs({ model: workerModel('codex', model), effort: workerEffort('codex', effort), sandbox, cwd, resume });
1096
1090
  const child = spawn('codex', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
1097
1091
  onChild?.(child);
1098
1092
  child.stdin.end(prompt);
1099
- let result = '', err = '', buf = '', usage = null, timedOut = false, lastAct = '';
1093
+ let result = '', err = '', buf = '', sessionId = null, usage = null, timedOut = false, lastAct = '';
1100
1094
  const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); }, WORKER_TIMEOUT_MS);
1101
1095
  child.stdout.on('data', (d) => {
1102
1096
  buf += d;
1103
1097
  const lines = buf.split('\n'); buf = lines.pop();
1104
1098
  for (const line of lines) {
1105
1099
  if (!line.trim()) continue;
1106
- try {
1107
- const ev = JSON.parse(line);
1108
- if (ev.type === 'item.completed') {
1109
- const item = ev.item ?? {};
1110
- if (item.type === 'agent_message' && item.text) {
1111
- result = item.text;
1112
- onEvent?.(`✎ ${item.text.trim().replace(/\s+/g, ' ').slice(0, 500)}`);
1113
- } else if (item.type === 'error' && item.message) onEvent?.(`Error ${item.message}`);
1114
- else if (item.type === 'command_execution' && item.command) { lastAct = `Run ${item.command}`; onEvent?.(lastAct); }
1115
- } else if (ev.type === 'turn.completed') {
1116
- usage = parseCodexUsage(ev.usage);
1117
- } else if (ev.type === 'error' && ev.message) {
1118
- err += `${ev.message}\n`;
1119
- onEvent?.(`Error ${ev.message}`);
1120
- }
1121
- } catch {
1122
- if (line.trim()) lastAct = line.trim().slice(0, 240);
1123
- onEvent?.(line.trim().slice(0, 240));
1100
+ const evs = parseCodexEvents(line);
1101
+ // A line we cannot parse is still the worker talking — show it rather
1102
+ // than swallow it (how this ran before the bridge existed).
1103
+ if (!evs.length && !line.trim().startsWith('{')) {
1104
+ lastAct = line.trim().slice(0, 240);
1105
+ onEvent?.(lastAct);
1106
+ continue;
1107
+ }
1108
+ for (const ev of evs) {
1109
+ if (ev.kind === 'session') sessionId = ev.id;
1110
+ else if (ev.kind === 'usage') usage = ev.usage;
1111
+ else if (ev.kind === 'todos') onTodos?.(ev.todos);
1112
+ else if (ev.kind === 'message') {
1113
+ result = ev.text;
1114
+ onEvent?.(`✎ ${ev.text.trim().replace(/\s+/g, ' ').slice(0, 500)}`);
1115
+ } else if (ev.kind === 'error') {
1116
+ err += `${ev.text}\n`;
1117
+ onEvent?.(`Error ${ev.text}`);
1118
+ } else { lastAct = ev.text; onEvent?.(ev.text); }
1124
1119
  }
1125
1120
  }
1126
1121
  });
1127
1122
  child.stderr.on('data', (d) => { err += d; });
1128
1123
  child.on('close', (code) => {
1129
1124
  clearTimeout(timer);
1130
- done({ code, timedOut, result: workerResultText({ result, err, code, timedOut, lastAct, timeoutMs: WORKER_TIMEOUT_MS }), sessionId: null, usage });
1125
+ done({ code, timedOut, result: workerResultText({ result, err, code, timedOut, lastAct, timeoutMs: WORKER_TIMEOUT_MS }), sessionId, usage });
1131
1126
  });
1132
1127
  });
1133
1128
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.39",
3
+ "version": "0.10.40",
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": {