@galda/cli 0.10.117 → 0.10.119
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 +156 -31
- package/engine/lib.mjs +33 -6
- package/engine/server.mjs +94 -23
- package/package.json +1 -1
package/app/index.html
CHANGED
|
@@ -328,6 +328,11 @@
|
|
|
328
328
|
hairline-separated from the detected list, matching .appopt grammar. */
|
|
329
329
|
.ctxmenu .appopt.ctxopen{flex-direction:row;align-items:center;gap:7px;border-top:1px solid var(--hair);margin-top:4px;padding-top:8px}
|
|
330
330
|
.ctxmenu .appopt.ctxopen svg{width:14px;height:14px;flex:0 0 14px;stroke:currentColor;stroke-width:1.7;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
|
331
|
+
/* Working: the folder icon becomes a spinner in its own place, so the row
|
|
332
|
+
itself says a chooser is opening rather than only the line of text below it.
|
|
333
|
+
Reuses @keyframes spin — no new motion vocabulary (DESIGN-RULES §5.5). */
|
|
334
|
+
.ctxmenu .appopt.ctxopen .ctxspin{width:12px;height:12px;flex:0 0 14px;margin:1px;border-radius:50%;
|
|
335
|
+
border:1.5px solid var(--ink3);border-top-color:transparent;animation:spin 1s linear infinite}
|
|
331
336
|
.ctxmenu .appopt.ctxopen .nm{color:var(--ink);font-size:12.5px;line-height:1.3}
|
|
332
337
|
.appopt{display:flex;align-items:center;gap:7px;height:28px;padding:0 8px;border:0;border-radius:6px;background:transparent;
|
|
333
338
|
color:var(--ink2);font:inherit;font-size:12px;cursor:pointer;text-align:left;width:100%}
|
|
@@ -529,15 +534,18 @@
|
|
|
529
534
|
.badge.upnext{color:var(--invink);background:rgba(var(--tint),.75);border:0;font-weight:700}
|
|
530
535
|
.retrybtn{margin-left:6px;padding:1.5px 9px;border:1px solid var(--hair2);border-radius:6px;background:transparent;color:var(--ink);font-size:10.5px;cursor:pointer}
|
|
531
536
|
.retrybtn.skip{color:var(--ink3)} /* Dismiss reads as the secondary/clear action next to Retry */
|
|
532
|
-
/* Queue priority badge (To do rows only) — click opens a
|
|
533
|
-
No new hues:
|
|
534
|
-
on the neutral ink scale, so priority never reads as another rainbow.
|
|
537
|
+
/* Queue priority badge (To do rows only) — click opens a High/Medium/Low popup.
|
|
538
|
+
No new hues: high borrows the existing --amber attention accent, medium/low
|
|
539
|
+
stay on the neutral ink scale, so priority never reads as another rainbow.
|
|
540
|
+
One mono glyph in a 22px square, unchanged since it held 高: the square is
|
|
541
|
+
the shape, H/M/L is what fits in it, and the whole word is in the menu where
|
|
542
|
+
there is room for it. */
|
|
535
543
|
.priosel{position:relative;flex:0 0 auto;align-self:flex-start;margin-top:1px}
|
|
536
544
|
.priobtn{width:22px;height:20px;border:1px solid var(--hair2);border-radius:6px;background:transparent;
|
|
537
545
|
color:var(--ink3);font-family:var(--mono);font-size:10.5px;cursor:pointer;display:grid;place-items:center}
|
|
538
546
|
.priobtn:hover{color:var(--ink);border-color:rgba(var(--tint),.2)}
|
|
539
|
-
.priobtn.prio
|
|
540
|
-
.priobtn.prio
|
|
547
|
+
.priobtn.prio-high{color:var(--amber);border-color:rgba(var(--amber-rgb),.4)}
|
|
548
|
+
.priobtn.prio-medium{color:var(--ink2)}
|
|
541
549
|
.priomenu{position:absolute;top:calc(100% + 4px);right:0;min-width:64px;background:var(--surface);border:1px solid var(--hair2);
|
|
542
550
|
border-radius:9px;padding:4px;display:none;flex-direction:column;gap:1px;box-shadow:0 10px 28px rgba(0,0,0,.5);z-index:50}
|
|
543
551
|
.priomenu.show{display:flex}
|
|
@@ -1598,6 +1606,7 @@
|
|
|
1598
1606
|
#fsRoot .cc-ask-lb{font-family:var(--mono);font-size:8.5px;letter-spacing:.11em;text-transform:uppercase;
|
|
1599
1607
|
color:var(--blue);margin-bottom:6px}
|
|
1600
1608
|
#fsRoot .cc-ask-q{font-size:12.5px;font-weight:500;color:var(--ink);line-height:1.35;margin-bottom:10px}
|
|
1609
|
+
#fsRoot .cc-ask-note{font-size:12px;color:var(--amber,#e0a03a);line-height:1.35;margin-bottom:6px}
|
|
1601
1610
|
#fsRoot .cc-ask .qchips{margin:0}
|
|
1602
1611
|
/* Free-text answer, always shown alongside chips (Masa 2026-07-08「最初から出そう」):
|
|
1603
1612
|
input + the shared rainbow send ring. Restores 質問カードの自由記述回答欄. */
|
|
@@ -2922,13 +2931,31 @@
|
|
|
2922
2931
|
/* ---- iOS no-zoom: 16px inputs (extends the shared 16px rule to flagship-only fields) ---- */
|
|
2923
2932
|
body[data-layout="flagship"] .sa-chat,
|
|
2924
2933
|
body[data-layout="flagship"] #fsAskbar input{font-size:16px}
|
|
2925
|
-
/* ---- Ledger (#seeall) —
|
|
2934
|
+
/* ---- Ledger (#seeall) — Claude Code-style mobile reading sheet.
|
|
2935
|
+
A tapped Review should land at the top of the phone viewport, with the
|
|
2936
|
+
content column using the width instead of spending it on desktop gutters. ---- */
|
|
2937
|
+
body[data-layout="flagship"] #seeall.open{place-items:start center;z-index:120;pointer-events:auto}
|
|
2926
2938
|
body[data-layout="flagship"] #seeall .sa-panel{width:100%!important;max-width:100%!important;
|
|
2927
|
-
margin-top:
|
|
2928
|
-
|
|
2939
|
+
margin-top:calc(env(safe-area-inset-top) + 6px)!important;
|
|
2940
|
+
max-height:calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 12px)!important;
|
|
2941
|
+
border-radius:18px 18px 0 0!important}
|
|
2942
|
+
body[data-layout="flagship"] #seeall .sa-hd{padding:14px 18px 10px!important}
|
|
2929
2943
|
body[data-layout="flagship"] #seeall .sa-hd .keys{display:none} /* keyboard shortcuts — no keyboard on a phone */
|
|
2930
|
-
body[data-layout="flagship"] #seeall .sa-body{padding:
|
|
2944
|
+
body[data-layout="flagship"] #seeall .sa-body{padding:10px 8px 16px}
|
|
2931
2945
|
body[data-layout="flagship"] #seeall .sa-item{width:100%}
|
|
2946
|
+
body[data-layout="flagship"] #seeall .sa-item.redesign{padding:22px 18px 28px!important}
|
|
2947
|
+
body[data-layout="flagship"] #seeall .sa-body>.sa-item.redesign+.sa-item.redesign{padding-top:30px!important}
|
|
2948
|
+
body[data-layout="flagship"] #seeall .sa-panel .sa-hd:has(~ .sa-body .sa-item.redesign){padding:14px 18px 10px!important}
|
|
2949
|
+
body[data-layout="flagship"] #seeall .revrail.show{width:9px;margin-right:7px}
|
|
2950
|
+
body[data-layout="flagship"] #seeall .revrail button{font-size:0;padding:6px 0}
|
|
2951
|
+
body[data-layout="flagship"] #seeall .revrail button .bar{width:3px;height:18px}
|
|
2952
|
+
body[data-layout="flagship"] #seeall .revrail button.on .bar{height:28px}
|
|
2953
|
+
body[data-layout="flagship"] #seeall .redesign .rtitle{font-size:17px;line-height:1.32;letter-spacing:0}
|
|
2954
|
+
body[data-layout="flagship"] #seeall .redesign .rsec{margin-top:22px!important}
|
|
2955
|
+
body[data-layout="flagship"] #seeall .redesign .rsec-h{font-size:15px;letter-spacing:0}
|
|
2956
|
+
body[data-layout="flagship"] #seeall .redesign .rbody,
|
|
2957
|
+
body[data-layout="flagship"] #seeall .redesign .rreport{font-size:15px;line-height:1.72;letter-spacing:0;max-width:none}
|
|
2958
|
+
body[data-layout="flagship"] #seeall .redesign .rreport code{font-size:13px}
|
|
2932
2959
|
body[data-layout="flagship"] #seeall .sa-act{flex-wrap:wrap;row-gap:8px}
|
|
2933
2960
|
body[data-layout="flagship"] #seeall .sa-chatwrap{flex:1 1 100%;order:3;min-width:0}
|
|
2934
2961
|
body[data-layout="flagship"] #seeall .sa-app,
|
|
@@ -2962,8 +2989,15 @@
|
|
|
2962
2989
|
max-width:62%;justify-content:center;margin:0}
|
|
2963
2990
|
/* Review/To Do opener (right) = plain ☰ icon (Masa 2026-07-18: ボタン化はやめ、三本線だけ・
|
|
2964
2991
|
タップで To Do/Review ドロワー). The base .fshamb rule above already renders the ☰ svg. */
|
|
2965
|
-
body[data-layout="flagship"] #fsRoot .fshamb .fshamb-lb
|
|
2966
|
-
|
|
2992
|
+
body[data-layout="flagship"] #fsRoot .fshamb .fshamb-lb{display:none}
|
|
2993
|
+
/* Masa 2026-07-31: たまったレビューが右上から分からない → ☰ の右肩に赤ドット。
|
|
2994
|
+
件数はJS(renderFsReview)がfshamb-nに書き込み済みだったが display:none で常に非表示だった。
|
|
2995
|
+
件数の有無だけをJSからのtext contentで判定して出し分ける(空なら消える)。 */
|
|
2996
|
+
body[data-layout="flagship"] #fsRoot .fshamb{position:relative}
|
|
2997
|
+
body[data-layout="flagship"] #fsRoot .fshamb .fshamb-n{display:none;position:absolute;top:2px;right:2px;
|
|
2998
|
+
min-width:9px;height:9px;padding:0;border-radius:50%;background:var(--danger);
|
|
2999
|
+
box-shadow:0 0 0 2px var(--panel);font-size:0;line-height:0;color:transparent}
|
|
3000
|
+
body[data-layout="flagship"] #fsRoot .fshamb .fshamb-n:not(:empty){display:block}
|
|
2967
3001
|
/* Top bar = chromeless on mobile for ALL themes (Masa 2026-07-18: Midnight で上に変なフレーム
|
|
2968
3002
|
が出ていた → Gradient と同じく枠/面なしに). Gradient already did this; extend to every theme. */
|
|
2969
3003
|
body[data-layout="flagship"] #fsRoot .fsrail{background:transparent;box-shadow:none}
|
|
@@ -2983,9 +3017,10 @@
|
|
|
2983
3017
|
margin:0 0 4px;padding:0 2px 10px;gap:9px;border-bottom:1px solid var(--hair)}
|
|
2984
3018
|
/* H25③: See-all ledger → scrim + inset panel (a full-bleed sheet let translucent themes show through). */
|
|
2985
3019
|
body[data-layout="flagship"] #seeall{background:rgba(9,12,20,.55)}
|
|
3020
|
+
body[data-layout="flagship"] #seeall.open{z-index:120}
|
|
2986
3021
|
body[data-layout="flagship"] #seeall .sa-panel{width:96%!important;max-width:96%!important;
|
|
2987
3022
|
max-height:calc(100dvh - env(safe-area-inset-top) - 22px)!important;
|
|
2988
|
-
margin-top:calc(env(safe-area-inset-top) +
|
|
3023
|
+
margin-top:calc(env(safe-area-inset-top) + 6px)!important;border-radius:18px!important}
|
|
2989
3024
|
}
|
|
2990
3025
|
/* Billing UI (H16/H22) — self-serve plan. Tokens only; red is a line/label,
|
|
2991
3026
|
never a fill (DESIGN-RULES §5.6), and it is --danger (the app's real token —
|
|
@@ -3530,10 +3565,13 @@ function captureFocusedAnswerInput(container, attr){
|
|
|
3530
3565
|
if (!el || !container || !container.contains(el) || !el.hasAttribute(attr)) return null;
|
|
3531
3566
|
return { id: el.getAttribute(attr), value: el.value, start: el.selectionStart, end: el.selectionEnd };
|
|
3532
3567
|
}
|
|
3533
|
-
function restoreFocusedAnswerInput(container, attr, saved){
|
|
3568
|
+
function restoreFocusedAnswerInput(container, attr, saved, shouldRestoreValue){
|
|
3534
3569
|
if (!saved || !container) return;
|
|
3535
3570
|
const el = container.querySelector(`[${attr}="${saved.id}"]`);
|
|
3536
3571
|
if (!el) return;
|
|
3572
|
+
// Focus always comes back; the TEXT only when the caller still wants it there.
|
|
3573
|
+
const keep = typeof shouldRestoreValue === 'function' ? shouldRestoreValue(saved.id) : true;
|
|
3574
|
+
if (!keep) { el.value = ''; el.focus(); return; }
|
|
3537
3575
|
el.value = saved.value;
|
|
3538
3576
|
el.focus();
|
|
3539
3577
|
try { el.setSelectionRange(saved.start, saved.end); } catch { /* input type may not support it */ }
|
|
@@ -3706,18 +3744,34 @@ function pickColumnLabel(columns, bucket, fallback){
|
|
|
3706
3744
|
}
|
|
3707
3745
|
function columnsFor(projectId){ return state.workflowColumns[projectId] ?? DEFAULT_WORKFLOW_COLUMNS; }
|
|
3708
3746
|
|
|
3709
|
-
// Mirrors engine/lib.mjs TASK_PRIORITIES — kept in sync
|
|
3710
|
-
// comment on DEFAULT_WORKFLOW_COLUMNS above. Queue execution
|
|
3711
|
-
//
|
|
3712
|
-
// sortQueueByPriority) — this badge is only for To do (queued)
|
|
3713
|
-
// that ordering actually matters.
|
|
3714
|
-
|
|
3747
|
+
// Mirrors engine/lib.mjs TASK_PRIORITIES / normalizePriority — kept in sync
|
|
3748
|
+
// manually, see the comment on DEFAULT_WORKFLOW_COLUMNS above. Queue execution
|
|
3749
|
+
// order is high→medium→low, same-priority ties keep creation/drag order
|
|
3750
|
+
// (server.mjs's sortQueueByPriority) — this badge is only for To do (queued)
|
|
3751
|
+
// rows, where that ordering actually matters.
|
|
3752
|
+
//
|
|
3753
|
+
// The values were 高/中/低 until 2026-07-31. Nothing migrates what is already on
|
|
3754
|
+
// disk, so every task written before then still says 高 and must still render as
|
|
3755
|
+
// what it is: reading only the new list would have shown it as Medium — the board
|
|
3756
|
+
// lying about the order the queue really runs in.
|
|
3757
|
+
const TASK_PRIORITIES = ['high', 'medium', 'low'];
|
|
3758
|
+
const LEGACY_PRIORITIES = { 高: 'high', 中: 'medium', 低: 'low' };
|
|
3759
|
+
// One glyph for the 22px square, the whole word for the menu that has room.
|
|
3760
|
+
const PRIORITY_GLYPH = { high: 'H', medium: 'M', low: 'L' };
|
|
3761
|
+
const PRIORITY_NAME = { high: 'High', medium: 'Medium', low: 'Low' };
|
|
3762
|
+
function normalizePriority(value){
|
|
3763
|
+
const v = String(value ?? '').trim();
|
|
3764
|
+
if (TASK_PRIORITIES.includes(v)) return v;
|
|
3765
|
+
if (LEGACY_PRIORITIES[v]) return LEGACY_PRIORITIES[v];
|
|
3766
|
+
const lower = v.toLowerCase();
|
|
3767
|
+
return TASK_PRIORITIES.includes(lower) ? lower : 'medium';
|
|
3768
|
+
}
|
|
3715
3769
|
function prioBadge(t){
|
|
3716
|
-
const p =
|
|
3770
|
+
const p = normalizePriority(t.priority);
|
|
3717
3771
|
return `<div class="priosel" data-priosel="${t.id}">
|
|
3718
|
-
<button type="button" class="priobtn prio-${p}" data-priobtn="${t.id}" title="Change priority">${p}</button>
|
|
3772
|
+
<button type="button" class="priobtn prio-${p}" data-priobtn="${t.id}" title="Change priority — ${PRIORITY_NAME[p]}">${PRIORITY_GLYPH[p]}</button>
|
|
3719
3773
|
<div class="priomenu" data-priomenu="${t.id}">${TASK_PRIORITIES.map((x) =>
|
|
3720
|
-
`<button type="button" class="prioopt${x === p ? ' active' : ''}" data-priotask="${t.id}" data-val="${x}">${x}</button>`).join('')}</div>
|
|
3774
|
+
`<button type="button" class="prioopt${x === p ? ' active' : ''}" data-priotask="${t.id}" data-val="${x}">${PRIORITY_NAME[x]}</button>`).join('')}</div>
|
|
3721
3775
|
</div>`;
|
|
3722
3776
|
}
|
|
3723
3777
|
|
|
@@ -6699,8 +6753,19 @@ $('gdSend').onclick = () => {
|
|
|
6699
6753
|
const text = $('gdRevisionInput').value.trim();
|
|
6700
6754
|
if (goalId == null || !text) return;
|
|
6701
6755
|
const goal = state.goals.find((g) => g.id === goalId);
|
|
6702
|
-
if (goal?.status === 'review') sendDismiss(goalId, text);
|
|
6703
|
-
|
|
6756
|
+
if (goal?.status === 'review') { sendDismiss(goalId, text); return; }
|
|
6757
|
+
// A goal that is ASKING gets an ANSWER. This box says "Answer the question…" for
|
|
6758
|
+
// needsInput, and it used to post to /reply, where the server accepted it, returned 200
|
|
6759
|
+
// and changed nothing — the question stayed up and nobody was told (a first-time user
|
|
6760
|
+
// hit exactly this, 2026-07-31). /answer is the route that hands the words back to the
|
|
6761
|
+
// agent that asked.
|
|
6762
|
+
if (goal?.status === 'needsInput') {
|
|
6763
|
+
$('gdRevisionInput').value = '';
|
|
6764
|
+
closeGoalDetail();
|
|
6765
|
+
fsAnswerQuestion(goalId, text);
|
|
6766
|
+
return;
|
|
6767
|
+
}
|
|
6768
|
+
sendGoalInstruction(goalId, text);
|
|
6704
6769
|
};
|
|
6705
6770
|
// Feedback 8/9: Enter in the goal-detail revision box sends (like the button) —
|
|
6706
6771
|
// which is what moves the goal Review→To Do for rework. Was button-only before.
|
|
@@ -6985,9 +7050,15 @@ function renderFsFeed(){
|
|
|
6985
7050
|
// it deleted the GOAL, not the question, from an unlabelled card — and an
|
|
6986
7051
|
// irreversible action does not belong beside a text box in a feed you are
|
|
6987
7052
|
// skim-reading. Deleting still lives on the card and in the right lane.
|
|
7053
|
+
// The server marks an answer it could not use. Say it as its own line: it used to be
|
|
7054
|
+
// concatenated into the question text, so the card re-rendered looking identical and
|
|
7055
|
+
// read as "ignored" rather than "I could not use that" (2026-07-31).
|
|
7056
|
+
const notUsed = g.question.notUnderstood === true;
|
|
7057
|
+
const qText = notUsed ? String(g.question.text).replace(/^[^\n]*\n+/, '') : g.question.text;
|
|
6988
7058
|
askHtml += `<div class="cc-ask"><div class="cc-ask-lb">Question`
|
|
6989
7059
|
+ `<span class="cc-ask-of">#${esc(String(g.id))}${title ? ` ${esc(title)}` : ''}</span></div>`
|
|
6990
|
-
+ `<div class="cc-ask-
|
|
7060
|
+
+ (notUsed ? `<div class="cc-ask-note">I could not use that answer — give me a full path.</div>` : '')
|
|
7061
|
+
+ `<div class="cc-ask-q">${esc(qText)}</div>`
|
|
6991
7062
|
+ (chips ? `<div class="qchips">${chips}</div>` : '')
|
|
6992
7063
|
+ `<div class="answ-inline"><input type="text" data-fsansinput="${g.id}" placeholder="Type your answer…" aria-label="Type your answer">`
|
|
6993
7064
|
+ `<button type="button" class="sa-send" data-fsanssend="${g.id}" title="Send answer" aria-label="Send answer"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div></div>`;
|
|
@@ -7058,7 +7129,10 @@ function renderFsFeed(){
|
|
|
7058
7129
|
const savedFsAns = captureFocusedAnswerInput(feed, 'data-fsansinput');
|
|
7059
7130
|
feed.innerHTML = hasWork ? out : '';
|
|
7060
7131
|
if (nearBottom) sc.scrollTop = sc.scrollHeight;
|
|
7061
|
-
|
|
7132
|
+
// Not restored when the server just rejected it: putting the same words back is what
|
|
7133
|
+
// made a rejected answer look like nothing had happened at all.
|
|
7134
|
+
restoreFocusedAnswerInput(feed, 'data-fsansinput', savedFsAns, (id) =>
|
|
7135
|
+
!(state.goals.find((g) => String(g.id) === String(id))?.question?.notUnderstood === true));
|
|
7062
7136
|
window.__fsCineSync?.(); // Cinema dynamic rule: content growth may reach the band region
|
|
7063
7137
|
for (const b of feed.querySelectorAll('[data-fsretry]')) b.onclick = () => fsRetryTask(b.dataset.fsretry);
|
|
7064
7138
|
for (const b of feed.querySelectorAll('[data-fslog]')) b.onclick = () => fsToggleLog(Number(b.dataset.fslog));
|
|
@@ -8994,6 +9068,12 @@ function connectSSE(){
|
|
|
8994
9068
|
// fsActiveContext() uses to pick which running goal is "now" when several run
|
|
8995
9069
|
// in parallel (§1.5-1: never anchor on stale work when something is live).
|
|
8996
9070
|
else if (m.ev === 'act') { (state.act[m.taskId] ??= []).push(m.line); state.act[m.taskId] = state.act[m.taskId].slice(-1000); state.actAt[m.taskId] = Date.now(); scheduleActRender(); return; }
|
|
9071
|
+
// The agent talking, as opposed to the agent working. Everything above this
|
|
9072
|
+
// line is machinery (a status, a tool call, a to-do list); this is a sentence
|
|
9073
|
+
// somebody wrote to the person waiting, so it goes where the person is
|
|
9074
|
+
// looking — the same turn treatment a chat reply gets, because it is the same
|
|
9075
|
+
// thing. Masa's friend waited three minutes for exactly this and gave up.
|
|
9076
|
+
else if (m.ev === 'say') { sayFromAgent(m); return; }
|
|
8997
9077
|
else if (m.ev === 'todos') state.todos[m.taskId] = m.todos ?? [];
|
|
8998
9078
|
else if (m.ev === 'columns') state.workflowColumns[m.projectId] = m.columns;
|
|
8999
9079
|
else if (m.ev === 'review-definition') state.reviewDefinitions[m.projectId] = m.reviewDefinition;
|
|
@@ -9299,6 +9379,25 @@ function typeChatTurn(turn){
|
|
|
9299
9379
|
if (turn.revealed >= full.length) { turn.done = true; stopChatTyper(); renderFsFeed(); }
|
|
9300
9380
|
}, 16);
|
|
9301
9381
|
}
|
|
9382
|
+
// A worker's opening words (engine: `ev:'say'`). Rendered as a spoken turn, typed
|
|
9383
|
+
// out, exactly like the one-shot chat reply — Galda writes nothing of its own
|
|
9384
|
+
// here and picks nothing: the sentence is the agent's, and this only puts it
|
|
9385
|
+
// where it can be read. The board layout has no centre feed, so it stays in the
|
|
9386
|
+
// run log there rather than being invented somewhere new.
|
|
9387
|
+
function sayFromAgent({ goalId, text, agent }){
|
|
9388
|
+
const body = String(text ?? '').trim();
|
|
9389
|
+
if (!body || state.layout !== 'flagship') return;
|
|
9390
|
+
const goal = state.goals.find((g) => g.id === goalId);
|
|
9391
|
+
const projectId = goal?.projectId ?? state.active;
|
|
9392
|
+
const turns = (state.chatTurns[projectId] ||= []);
|
|
9393
|
+
// A reconnecting stream can hand us the same opening twice; a person reading it
|
|
9394
|
+
// twice would think the agent had started over.
|
|
9395
|
+
if (turns.some((t) => t.role === 'clawd' && t.text === body)) return;
|
|
9396
|
+
const turn = { role: 'clawd', text: body, revealed: 0, done: false, agent, ts: Date.now() };
|
|
9397
|
+
turns.push(turn);
|
|
9398
|
+
renderFsFeed();
|
|
9399
|
+
if (projectId === state.active) typeChatTurn(turn); else { turn.revealed = body.length; turn.done = true; }
|
|
9400
|
+
}
|
|
9302
9401
|
// A new send starts: finish any in-flight reply cleanly (complete the model turn +
|
|
9303
9402
|
// drop the caret, hide the board bar) so a half-typed reply never lingers.
|
|
9304
9403
|
function finalizeChatTyping(){
|
|
@@ -11486,7 +11585,7 @@ function renderCtxBar(){
|
|
|
11486
11585
|
.join('');
|
|
11487
11586
|
// Redrawing the menu must not hand back a second chooser: carry the disabled
|
|
11488
11587
|
// state that setFolderPickerBusy() put on the live button.
|
|
11489
|
-
const openRow = `<button type="button" class="appopt ctxopen${folderPickerBusy ? ' busy' : ''}" id="ctxopenfolder"${folderPickerBusy ? ' disabled' : ''} title="Open the OS folder chooser"
|
|
11588
|
+
const openRow = `<button type="button" class="appopt ctxopen${folderPickerBusy ? ' busy' : ''}" id="ctxopenfolder"${folderPickerBusy ? ' disabled' : ''} title="Open the OS folder chooser">${folderRowInner(folderPickerBusy)}</button>`;
|
|
11490
11589
|
menu.innerHTML = `${opts}${openRow}<div class="ctxmsg" id="ctxdirmsg"></div>`;
|
|
11491
11590
|
}
|
|
11492
11591
|
// Workspace mode is fixed to the isolated worktree (clean separate checkout). The
|
|
@@ -11538,17 +11637,37 @@ async function setProjectFolder(dir){
|
|
|
11538
11637
|
// request stays in flight for as long as the dialog is up, so "a request is out"
|
|
11539
11638
|
// is exactly "a dialog is open" — no timer to guess with.
|
|
11540
11639
|
let folderPickerBusy = false;
|
|
11640
|
+
const FOLDER_GLYPH = '<svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><path d="M12 11v6M9 14h6"/></svg>';
|
|
11641
|
+
// While a chooser is open the icon spins in its own place. Waiting with nothing
|
|
11642
|
+
// moving is what "it did nothing" looks like — and it is what made Masa's friend
|
|
11643
|
+
// tap ten times (2026-07-31). One source for the row's contents, so the live swap
|
|
11644
|
+
// and the next redraw cannot disagree.
|
|
11645
|
+
function folderRowInner(busy){
|
|
11646
|
+
return `${busy ? '<span class="ctxspin" aria-hidden="true"></span>' : FOLDER_GLYPH}<span class="nm">Open folder…</span>`;
|
|
11647
|
+
}
|
|
11541
11648
|
function setFolderPickerBusy(busy){
|
|
11542
11649
|
folderPickerBusy = busy;
|
|
11543
11650
|
const btn = $('ctxopenfolder');
|
|
11544
|
-
if (btn) { btn.disabled = busy; btn.classList.toggle('busy', busy); }
|
|
11545
|
-
}
|
|
11651
|
+
if (btn) { btn.disabled = busy; btn.classList.toggle('busy', busy); btn.innerHTML = folderRowInner(busy); }
|
|
11652
|
+
}
|
|
11653
|
+
// How long to wait before saying the dialog might not be where they are looking.
|
|
11654
|
+
// The engine opens it immediately; on a Mac where the engine was started by launchd
|
|
11655
|
+
// it can open behind everything, and there is nothing on screen to say so — which is
|
|
11656
|
+
// the other half of what happened to Masa's friend. Long enough not to nag someone
|
|
11657
|
+
// who is simply choosing a folder.
|
|
11658
|
+
const FOLDER_CHOOSER_SLOW_MS = 6000;
|
|
11546
11659
|
async function openFolderNative(){
|
|
11547
11660
|
if (folderPickerBusy) return;
|
|
11548
11661
|
const msg = $('ctxdirmsg');
|
|
11549
11662
|
const say = (t, bad) => { if (msg) { msg.textContent = t; msg.classList.toggle('bad', Boolean(bad)); } };
|
|
11550
11663
|
setFolderPickerBusy(true);
|
|
11551
11664
|
say('Opening the folder chooser…');
|
|
11665
|
+
// Still open after a while: say where it might be, and point at the way out that
|
|
11666
|
+
// does not need it. The row stays disabled — a second chooser is the bug this
|
|
11667
|
+
// guard exists to prevent, and offering one here would re-create it.
|
|
11668
|
+
const slow = setTimeout(() => {
|
|
11669
|
+
if (folderPickerBusy) say('Still waiting — the chooser may be behind your other windows. You can also pick a folder from the list above.');
|
|
11670
|
+
}, FOLDER_CHOOSER_SLOW_MS);
|
|
11552
11671
|
try {
|
|
11553
11672
|
const r = await fetch(withKey('/api/choose-folder'), { method: 'POST' });
|
|
11554
11673
|
const body = await r.json().catch(() => ({}));
|
|
@@ -11557,7 +11676,7 @@ async function openFolderNative(){
|
|
|
11557
11676
|
if (body.unsupported) { say('This engine can’t open a folder dialog on this system.', true); return; }
|
|
11558
11677
|
say(body.error || 'Could not open the folder chooser.', true);
|
|
11559
11678
|
} catch { say('Could not reach the engine.', true); }
|
|
11560
|
-
finally { setFolderPickerBusy(false); }
|
|
11679
|
+
finally { clearTimeout(slow); setFolderPickerBusy(false); }
|
|
11561
11680
|
}
|
|
11562
11681
|
$('ctxdirbtn').onclick = (e) => {
|
|
11563
11682
|
e.stopPropagation();
|
|
@@ -12493,7 +12612,13 @@ window.__tbink = (i) => { document.body.dataset.tbink = String(((i | 0) % 3 + 3)
|
|
|
12493
12612
|
raf = 0;
|
|
12494
12613
|
if (document.hidden) { clearFx(); return; }
|
|
12495
12614
|
const t = (performance.now() - t0) / 1000, d = Math.min(2, devicePixelRatio || 1);
|
|
12496
|
-
|
|
12615
|
+
// Clear in backing-store pixels *before* installing the CSS-pixel transform.
|
|
12616
|
+
// Mobile Safari can change its visual viewport as a question answer removes a
|
|
12617
|
+
// card (or as the browser chrome expands). Clearing in transformed viewport
|
|
12618
|
+
// coordinates then leaves pixels from the send ring's former location behind.
|
|
12619
|
+
// The canvas's own dimensions are the only stable full-clear bounds.
|
|
12620
|
+
clearFx();
|
|
12621
|
+
fc.setTransform(d, 0, 0, d, 0, 0);
|
|
12497
12622
|
if (MODE < 10) tick(t); // TB edge anim (optional, off by default = 10)
|
|
12498
12623
|
drawSendRing(t); // send ring (always on while btn mode = 0, independent of TB edge anim)
|
|
12499
12624
|
raf = requestAnimationFrame(loop);
|
package/engine/lib.mjs
CHANGED
|
@@ -1869,8 +1869,8 @@ export function reorderQueue(waiting, ids) {
|
|
|
1869
1869
|
return [...orderByIds(queued, ids), ...rest];
|
|
1870
1870
|
}
|
|
1871
1871
|
|
|
1872
|
-
// Priority queue ordering: '
|
|
1873
|
-
// priority (or an unrecognized value) is treated as '
|
|
1872
|
+
// Priority queue ordering: 'high' runs before 'medium' before 'low'. A task with
|
|
1873
|
+
// no priority (or an unrecognized value) is treated as 'medium', matching the
|
|
1874
1874
|
// default new tasks are created with. Array.prototype.sort is a stable sort
|
|
1875
1875
|
// (guaranteed by the spec), so this never disturbs the relative order tasks
|
|
1876
1876
|
// already have within the same priority — that incoming order already
|
|
@@ -1879,12 +1879,39 @@ export function reorderQueue(waiting, ids) {
|
|
|
1879
1879
|
// for everything it doesn't touch). Callers should feed this the array
|
|
1880
1880
|
// *after* applying a drag reorder, so a drag only ever re-ranks within a
|
|
1881
1881
|
// priority band rather than fighting it.
|
|
1882
|
-
|
|
1883
|
-
|
|
1882
|
+
//
|
|
1883
|
+
// The values were 高/中/低 until 2026-07-31 (Masa: the UI writes its own labels
|
|
1884
|
+
// in English — DESIGN-RULES §5.5). They are stored, sorted on, and sent over the
|
|
1885
|
+
// API, so the old ones cannot simply be swapped out: every task written before
|
|
1886
|
+
// today still says 高 on disk, and a browser tab left open on the old app will
|
|
1887
|
+
// keep PUTting 高 for as long as it stays open. Both are accepted forever and
|
|
1888
|
+
// normalised on the way in; nothing has to be migrated, and nothing that already
|
|
1889
|
+
// exists loses its place in the queue.
|
|
1890
|
+
export const TASK_PRIORITIES = ['high', 'medium', 'low'];
|
|
1891
|
+
const LEGACY_PRIORITIES = { 高: 'high', 中: 'medium', 低: 'low' };
|
|
1892
|
+
export const DEFAULT_PRIORITY = 'medium';
|
|
1893
|
+
|
|
1894
|
+
export function normalizePriority(value) {
|
|
1895
|
+
const v = String(value ?? '').trim();
|
|
1896
|
+
if (TASK_PRIORITIES.includes(v)) return v;
|
|
1897
|
+
if (LEGACY_PRIORITIES[v]) return LEGACY_PRIORITIES[v];
|
|
1898
|
+
const lower = v.toLowerCase();
|
|
1899
|
+
if (TASK_PRIORITIES.includes(lower)) return lower; // 'High' from a hand-written call
|
|
1900
|
+
return DEFAULT_PRIORITY;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
// Whether a value is one this product accepts at all — the API's gate. Deliberately
|
|
1904
|
+
// wider than TASK_PRIORITIES: an old client sending 高 is not making a mistake.
|
|
1905
|
+
export function isAcceptedPriority(value) {
|
|
1906
|
+
const v = String(value ?? '').trim();
|
|
1907
|
+
return TASK_PRIORITIES.includes(v) || Boolean(LEGACY_PRIORITIES[v]) || TASK_PRIORITIES.includes(v.toLowerCase());
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
const PRIORITY_WEIGHT = { high: 0, medium: 1, low: 2 };
|
|
1884
1911
|
export function sortQueueByPriority(tasks) {
|
|
1885
1912
|
return tasks
|
|
1886
1913
|
.map((t, i) => ({ t, i }))
|
|
1887
|
-
.sort((a, b) => (PRIORITY_WEIGHT[a.t.priority] ?? 1) - (PRIORITY_WEIGHT[b.t.priority] ?? 1) || a.i - b.i)
|
|
1914
|
+
.sort((a, b) => (PRIORITY_WEIGHT[normalizePriority(a.t.priority)] ?? 1) - (PRIORITY_WEIGHT[normalizePriority(b.t.priority)] ?? 1) || a.i - b.i)
|
|
1888
1915
|
.map(({ t }) => t);
|
|
1889
1916
|
}
|
|
1890
1917
|
|
|
@@ -3270,7 +3297,7 @@ export function buildGoalTask(goal, { id, num, passCondition = null, createdAt }
|
|
|
3270
3297
|
mode: goal.mode ?? 'auto', skill: goal.skill ?? null,
|
|
3271
3298
|
workspaceMode: resolveWorkspaceMode(goal.workspaceMode ?? goal.worktree),
|
|
3272
3299
|
worktree: goal.worktree === false ? false : undefined,
|
|
3273
|
-
status: 'queued', createdAt, priority:
|
|
3300
|
+
status: 'queued', createdAt, priority: DEFAULT_PRIORITY,
|
|
3274
3301
|
result: null, changedFiles: [], secs: null, activity: [], proof: null, usage: null,
|
|
3275
3302
|
};
|
|
3276
3303
|
}
|
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 { bootBanner, bootDestination, folderLabel, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse, isCommandOnPath, hostReadiness, parseRequestedWorkspaceDir } from './lib.mjs';
|
|
24
|
-
import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildReviewAttemptLedger, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, buildQueueWaits, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, isNothingVerifiableForPR, isSuspiciouslyIncompleteDone, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, REQUIREMENT_EVIDENCE_FILE, parseRequirementEvidenceDocument, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession, parseApprovalRequest } from './lib.mjs';
|
|
24
|
+
import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildReviewAttemptLedger, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, DEFAULT_PRIORITY, normalizePriority, isAcceptedPriority, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, buildQueueWaits, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, isNothingVerifiableForPR, isSuspiciouslyIncompleteDone, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, REQUIREMENT_EVIDENCE_FILE, parseRequirementEvidenceDocument, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession, parseApprovalRequest } from './lib.mjs';
|
|
25
25
|
import { migrateRequirementModel, validateRequirementModel } from './requirement-model.mjs';
|
|
26
26
|
import { buildManagerVerificationChecks, buildRequirementVerificationEvidence, mergeRequirementVerificationEvidence } from './requirement-verification.mjs';
|
|
27
27
|
import { readRelayPref, writeRelayPref, resolveRelayEnabled } from './relay-pref.mjs';
|
|
@@ -975,7 +975,15 @@ const workerOpenedWith = new Set();
|
|
|
975
975
|
function sayFromWorker(goal, task, text) {
|
|
976
976
|
if (!goal || !task || workerOpenedWith.has(task.id)) return;
|
|
977
977
|
workerOpenedWith.add(task.id);
|
|
978
|
-
addGoalMessage(goal, { from: 'ai', via: task.reply ? 'thread' : 'composer', text });
|
|
978
|
+
const msg = addGoalMessage(goal, { from: 'ai', via: task.reply ? 'thread' : 'composer', text });
|
|
979
|
+
if (!msg) return;
|
|
980
|
+
// Say it out loud, now. The conversation on a goal is read on demand (a board
|
|
981
|
+
// with 100 cards must not drag 100 threads along), so a message written to it
|
|
982
|
+
// is invisible until someone opens that card — and the whole point of this one
|
|
983
|
+
// is that the person is sitting in front of the composer waiting to hear
|
|
984
|
+
// something. The text rides on the event: no fetch, and nothing for the client
|
|
985
|
+
// to work out about which messages it has already shown.
|
|
986
|
+
sendGoalEvent({ ev: 'say', goalId: goal.id, taskId: task.id, agent: task.agent ?? goal.agent, text: msg.text, at: msg.at }, goal);
|
|
979
987
|
}
|
|
980
988
|
|
|
981
989
|
function readGoalMessages(goalId) {
|
|
@@ -2349,7 +2357,7 @@ function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {})
|
|
|
2349
2357
|
title: text.replace(/\s+/g, ' ').slice(0, 60), detail: promptText.slice(0, 4000), replyFrom: from,
|
|
2350
2358
|
workspaceMode: resolveWorkspaceMode(goal.workspaceMode ?? goal.worktree),
|
|
2351
2359
|
worktree: goal.worktree === false ? false : undefined,
|
|
2352
|
-
passCondition: null, status: 'queued', createdAt: new Date().toISOString(), priority:
|
|
2360
|
+
passCondition: null, status: 'queued', createdAt: new Date().toISOString(), priority: DEFAULT_PRIORITY,
|
|
2353
2361
|
result: null, changedFiles: [], secs: null, activity: [], proof: null, usage: null,
|
|
2354
2362
|
userRequirementHistory: tasks.filter((t) => t.goalId === goal.id && (!t.reply || t.replyFrom === 'you')).slice(-12).map((t) => ({ text: t.reply ? t.detail : t.title, status: t.status })),
|
|
2355
2363
|
// Transport the prior attempts as facts; the worker, not Manager, decides
|
|
@@ -4913,11 +4921,22 @@ const server = createServer(async (req, res) => {
|
|
|
4913
4921
|
// guarded here.
|
|
4914
4922
|
const runnable = !pending && !review && !approval;
|
|
4915
4923
|
const lang = /[-ヿ㐀-鿿]/.test(text) ? 'ja' : 'en';
|
|
4916
|
-
//
|
|
4917
|
-
// runnable goal
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4924
|
+
// Galda no longer asks "do you want PRs?" (Masa 2026-07-31). It used to ask once
|
|
4925
|
+
// per project, before the first runnable goal, WITHOUT looking at what was asked —
|
|
4926
|
+
// needsReviewPreference() was `return !askedBefore`. A first-time user typing
|
|
4927
|
+
// 「テスト」 got interrogated about pull requests, and because the folder question
|
|
4928
|
+
// had claimed the needsInput slot on message 1, it landed on message 2, out of
|
|
4929
|
+
// nowhere. (That folder question is gone too, as of #642 — this is the other half
|
|
4930
|
+
// of the same interrogation.)
|
|
4931
|
+
//
|
|
4932
|
+
// Nothing needs the answer: resolveWantsPR() already falls back to
|
|
4933
|
+
// `!preferenceKnown && isImplementationRequest(text)`, i.e. a PR for work that looks
|
|
4934
|
+
// like implementation and none for 「テスト」 — which is the behaviour the question
|
|
4935
|
+
// was there to obtain. Projects that already answered keep their setting, and an
|
|
4936
|
+
// agent that genuinely needs to know can ask for itself: the question layer belongs
|
|
4937
|
+
// to Claude Code / Codex, not to Galda.
|
|
4938
|
+
const needsReviewPref = false;
|
|
4939
|
+
const reviewPrefQuestion = undefined;
|
|
4921
4940
|
// Clamp to whatever agent CLIs are actually installed on this machine
|
|
4922
4941
|
// (connected-agents Phase A) — a requested/default agent that isn't
|
|
4923
4942
|
// present would otherwise spawn a worker doomed to fail at launch.
|
|
@@ -5309,7 +5328,7 @@ const server = createServer(async (req, res) => {
|
|
|
5309
5328
|
return;
|
|
5310
5329
|
}
|
|
5311
5330
|
|
|
5312
|
-
// change a queued task's priority (
|
|
5331
|
+
// change a queued task's priority (high/medium/low) — re-sorts its project's queue
|
|
5313
5332
|
// so a bumped-up task actually runs sooner, not just displays that way.
|
|
5314
5333
|
const priorityMatch = url.pathname.match(/^\/api\/tasks\/(\d+)\/priority$/);
|
|
5315
5334
|
if (priorityMatch && req.method === 'PUT') {
|
|
@@ -5321,8 +5340,11 @@ const server = createServer(async (req, res) => {
|
|
|
5321
5340
|
req.on('end', () => {
|
|
5322
5341
|
try {
|
|
5323
5342
|
const { priority } = JSON.parse(body);
|
|
5324
|
-
|
|
5325
|
-
|
|
5343
|
+
// A browser left open on yesterday's app still sends 高/中/低. Refusing it
|
|
5344
|
+
// would break a tab nobody reloaded, for a rename that is ours, so the old
|
|
5345
|
+
// words are accepted and stored as the new ones.
|
|
5346
|
+
if (!isAcceptedPriority(priority)) return json(res, 400, { error: `priority must be one of: ${TASK_PRIORITIES.join(', ')}` });
|
|
5347
|
+
task.priority = normalizePriority(priority);
|
|
5326
5348
|
saveTask(task);
|
|
5327
5349
|
const q = queues.get(task.projectId);
|
|
5328
5350
|
if (q) { q.waiting = sortQueueByPriority(q.waiting); for (const t of q.waiting) saveTask(t); }
|
|
@@ -5491,24 +5513,33 @@ const server = createServer(async (req, res) => {
|
|
|
5491
5513
|
// A worker-authored question is a transport pause, not a new planning
|
|
5492
5514
|
// cycle. Preserve the worker session and pass the user's answer straight
|
|
5493
5515
|
// back to it; Galda must not reinterpret or rewrite the answer.
|
|
5494
|
-
if (goal.question?.kind === 'worker')
|
|
5495
|
-
goal.question = null;
|
|
5496
|
-
goal.blocked = null;
|
|
5497
|
-
goal.status = 'running';
|
|
5498
|
-
goal.startedAt = null;
|
|
5499
|
-
saveGoal(goal);
|
|
5500
|
-
const replyTask = queueReplyTask(goal, answer, { from: 'you', via: 'question' });
|
|
5501
|
-
return json(res, 200, { ...goal, replyTaskId: replyTask.id });
|
|
5502
|
-
}
|
|
5516
|
+
if (goal.question?.kind === 'worker') return json(res, 200, deliverWorkerAnswer(goal, answer));
|
|
5503
5517
|
// Bind this goal to a project rooted at `dir` and start planning. Shared by
|
|
5504
5518
|
// the "picked a repo" and "git-init'd a new folder" paths below.
|
|
5505
5519
|
const bindAndPlan = (dir) => {
|
|
5506
5520
|
const existing = projects.find((p) => p.dir === dir);
|
|
5507
5521
|
let proj = existing;
|
|
5508
5522
|
if (!proj) {
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
|
|
5523
|
+
// The free tier allows one project, and POST /api/projects enforces that — but
|
|
5524
|
+
// this path never did, so every folder someone named here minted another one
|
|
5525
|
+
// (measured 2026-07-31: a first-time user on the free tier ended up with three).
|
|
5526
|
+
// At the cap, "pick a folder" means "this is the folder I work in", so point the
|
|
5527
|
+
// project someone already has at it instead of inventing a second.
|
|
5528
|
+
const atCap = checkFreeTierLimit({ ...computeFreeTierUsage(goal.owner ?? 'owner'), projectCount: projects.length + 1 }).reason === 'project-limit';
|
|
5529
|
+
// Only ever the goal's OWN project. A folder question only fires when that
|
|
5530
|
+
// project's dir is HOME or not a repo, so it is by definition the unusable one
|
|
5531
|
+
// and pointing it at the chosen folder is what the person just asked for. Never
|
|
5532
|
+
// repoint somebody else's project to make room.
|
|
5533
|
+
const reusable = atCap ? projects.find((p) => p.id === goal.projectId) : null;
|
|
5534
|
+
if (reusable) {
|
|
5535
|
+
reusable.dir = dir;
|
|
5536
|
+
saveProjects();
|
|
5537
|
+
proj = reusable;
|
|
5538
|
+
} else {
|
|
5539
|
+
proj = { id: uniqueProjectId(basename(dir)), name: basename(dir), dir };
|
|
5540
|
+
projects.push(proj); saveProjects();
|
|
5541
|
+
queues.set(proj.id, { running: new Set(), waiting: [] });
|
|
5542
|
+
}
|
|
5512
5543
|
}
|
|
5513
5544
|
goal.projectId = proj.id;
|
|
5514
5545
|
goal.question = null;
|
|
@@ -5559,6 +5590,11 @@ const server = createServer(async (req, res) => {
|
|
|
5559
5590
|
goal.question = buildFolderQuestion(detectRepos(HOME), { lang });
|
|
5560
5591
|
}
|
|
5561
5592
|
goal.question.text = notUnderstoodNote(lang) + goal.question.text;
|
|
5593
|
+
// Machine-readable, because the note above is concatenated into the question text
|
|
5594
|
+
// and the card re-renders looking identical — which reads as "ignored", not as
|
|
5595
|
+
// "I could not use that" (measured 2026-07-31). The app clears the box and shows
|
|
5596
|
+
// this as its own line instead of relying on someone re-reading the paragraph.
|
|
5597
|
+
goal.question.notUnderstood = true;
|
|
5562
5598
|
saveGoal(goal);
|
|
5563
5599
|
return json(res, 200, goal);
|
|
5564
5600
|
}
|
|
@@ -5654,12 +5690,47 @@ const server = createServer(async (req, res) => {
|
|
|
5654
5690
|
return;
|
|
5655
5691
|
}
|
|
5656
5692
|
|
|
5693
|
+
// Hand a person's answer back to the worker that asked the question, unchanged. The
|
|
5694
|
+
// worker's session is preserved, so it can answer, ask again, or carry on — which is the
|
|
5695
|
+
// whole point: the question layer is Claude Code's / Codex's, not Galda's (Masa
|
|
5696
|
+
// 2026-07-31). Shared by /answer and /reply so that WHICH box the person typed into
|
|
5697
|
+
// cannot change whether their words arrive.
|
|
5698
|
+
function deliverWorkerAnswer(goal, answer) {
|
|
5699
|
+
goal.question = null;
|
|
5700
|
+
goal.blocked = null;
|
|
5701
|
+
goal.status = 'running';
|
|
5702
|
+
goal.startedAt = null;
|
|
5703
|
+
saveGoal(goal);
|
|
5704
|
+
const replyTask = queueReplyTask(goal, answer, { from: 'you', via: 'question' });
|
|
5705
|
+
return { ...goal, replyTaskId: replyTask.id };
|
|
5706
|
+
}
|
|
5657
5707
|
const replyMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/reply$/);
|
|
5658
5708
|
if (replyMatch && req.method === 'POST') {
|
|
5659
5709
|
const goal = goals.find((g) => g.id === Number(replyMatch[1]));
|
|
5660
5710
|
if (!goal) return json(res, 404, { error: 'goal not found' });
|
|
5661
5711
|
if (!requireGoalOwnership(req, res, goal)) return;
|
|
5662
5712
|
if (goal.status === 'needsApproval') return json(res, 409, { error: 'goal is awaiting an approval decision — use /approval' });
|
|
5713
|
+
// A goal that is ASKING something used to accept a reply here, return 200, and change
|
|
5714
|
+
// nothing: the question stayed on screen, the goal stayed in needsInput, and the person
|
|
5715
|
+
// was told nothing (measured 2026-07-31 — a first-time user typed into a box labelled
|
|
5716
|
+
// "Answer the question…" and it silently went nowhere). Two honest outcomes instead:
|
|
5717
|
+
// • the worker asked → deliver it, exactly as /answer would. Which box someone typed
|
|
5718
|
+
// into must not decide whether their words reach the agent.
|
|
5719
|
+
// • Galda's own card → say so, so the UI can fail loudly instead of pretending.
|
|
5720
|
+
if (goal.status === 'needsInput') {
|
|
5721
|
+
if (goal.question?.kind === 'worker') {
|
|
5722
|
+
let body = '';
|
|
5723
|
+
req.on('data', (d) => { body += d; });
|
|
5724
|
+
req.on('end', () => {
|
|
5725
|
+
let text = '';
|
|
5726
|
+
try { text = String(JSON.parse(body || '{}').text ?? '').trim(); } catch {}
|
|
5727
|
+
if (!text) return json(res, 400, { error: 'text required' });
|
|
5728
|
+
return json(res, 200, deliverWorkerAnswer(goal, text));
|
|
5729
|
+
});
|
|
5730
|
+
return;
|
|
5731
|
+
}
|
|
5732
|
+
return json(res, 409, { error: 'goal is waiting on an answer — use /answer' });
|
|
5733
|
+
}
|
|
5663
5734
|
if (['stacked', 'planning'].includes(goal.status)) return json(res, 409, { error: 'goal not started yet — edit it instead' });
|
|
5664
5735
|
let body = '';
|
|
5665
5736
|
req.on('data', (d) => { body += d; });
|
package/package.json
CHANGED