@galda/cli 0.10.46 → 0.10.48
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 +125 -19
- package/app/theme.css +5 -5
- package/engine/lib.mjs +33 -1
- package/engine/server.mjs +41 -1
- package/package.json +1 -1
package/app/index.html
CHANGED
|
@@ -2052,6 +2052,12 @@
|
|
|
2052
2052
|
— no re-layout, no flash. */
|
|
2053
2053
|
.redesign .rst.working{color:var(--green)}
|
|
2054
2054
|
.redesign .rst.working .rdot{background:var(--green)}
|
|
2055
|
+
/* A "Needs you" goal opened as this card reads its state, grounded in the flagship list's
|
|
2056
|
+
state colours: a stopped/failed error is red (--danger); interrupted (resumable) is amber. */
|
|
2057
|
+
.redesign .rst.err{color:var(--danger)}
|
|
2058
|
+
.redesign .rst.err .rdot{background:var(--danger)}
|
|
2059
|
+
.redesign .rst.int{color:var(--amber)}
|
|
2060
|
+
.redesign .rst.int .rdot{background:var(--amber)}
|
|
2055
2061
|
.sa-item.working{box-shadow:0 1px 2px rgba(20,23,31,.05),0 0 0 1px color-mix(in oklab,var(--green) 22%,var(--hair))}
|
|
2056
2062
|
.replynote .wkline .wkdot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 0 3px color-mix(in oklab,var(--green) 22%,transparent);margin-right:9px;vertical-align:middle;position:relative;top:-1px}
|
|
2057
2063
|
.redesign .rtitle{font-family:var(--ui);font-size:18px;font-weight:680;letter-spacing:-.018em;line-height:1.3;margin:8px 0 0 !important;color:var(--ink)}
|
|
@@ -4664,7 +4670,7 @@ function renderTasks(){
|
|
|
4664
4670
|
// inline expand.
|
|
4665
4671
|
if (el.dataset.attn) {
|
|
4666
4672
|
const g = goalOf(state.tasks.find((t) => t.id === id));
|
|
4667
|
-
if (g) { goToGoalInChat(g.id);
|
|
4673
|
+
if (g) { goToGoalInChat(g.id); openGoalOrCard(g.id); }
|
|
4668
4674
|
return;
|
|
4669
4675
|
}
|
|
4670
4676
|
state.expanded.has(id) ? state.expanded.delete(id) : state.expanded.add(id);
|
|
@@ -4686,7 +4692,7 @@ function renderTasks(){
|
|
|
4686
4692
|
: goal;
|
|
4687
4693
|
goToGoalInChat(opened.id);
|
|
4688
4694
|
if (opened.status === 'review') openReviewChecklist(opened.id);
|
|
4689
|
-
else
|
|
4695
|
+
else openGoalOrCard(opened.id);
|
|
4690
4696
|
});
|
|
4691
4697
|
}
|
|
4692
4698
|
for (const b of $('tasklist').querySelectorAll('.reqopen')) {
|
|
@@ -5878,6 +5884,13 @@ function openGoalDetail(goalId){
|
|
|
5878
5884
|
renderGoalDetail();
|
|
5879
5885
|
$('goalDetailOverlay').classList.add('show');
|
|
5880
5886
|
}
|
|
5887
|
+
// "Needs you" (attention) goals open as the SAME review card (Masa 2026-07-24, Option A);
|
|
5888
|
+
// any other non-review goal keeps the goal-detail overlay.
|
|
5889
|
+
function openGoalOrCard(goalId){
|
|
5890
|
+
const g = state.goals.find((x) => x.id === goalId);
|
|
5891
|
+
if (g && ['blocked', 'partial', 'failed', 'interrupted'].includes(g.status) && typeof saOpen === 'function') { saOpen(goalId, true); return; }
|
|
5892
|
+
openGoalDetail(goalId);
|
|
5893
|
+
}
|
|
5881
5894
|
function closeGoalDetail(){
|
|
5882
5895
|
$('goalDetailOverlay').classList.remove('show');
|
|
5883
5896
|
const goalId = state.goalDetailOpen;
|
|
@@ -6068,6 +6081,9 @@ async function sendDismiss(goalId, text){
|
|
|
6068
6081
|
} catch { showErr('Dismiss failed.'); }
|
|
6069
6082
|
$('gdApprove').disabled = false; $('gdDismiss').disabled = false; $('gdSend').disabled = false;
|
|
6070
6083
|
}
|
|
6084
|
+
// Residual goal-detail overlay path (non-review, non-attention goals — a done/running goal
|
|
6085
|
+
// opened for a note). "Needs you" goals no longer come here: they open the review card
|
|
6086
|
+
// (openGoalOrCard → saOpen solo), where the reply is the in-place "Working on it" thread.
|
|
6071
6087
|
async function sendGoalInstruction(goalId, text){
|
|
6072
6088
|
$('gdSend').disabled = true; $('gdDiscard').disabled = true;
|
|
6073
6089
|
try {
|
|
@@ -6424,6 +6440,10 @@ function fsToggleLog(taskId){
|
|
|
6424
6440
|
toggleGoalDetail(task.goalId);
|
|
6425
6441
|
}
|
|
6426
6442
|
function toggleGoalDetail(goalId){
|
|
6443
|
+
// "Needs you" (attention) goals open in the SAME review card, not the bespoke overlay
|
|
6444
|
+
// (Masa 2026-07-24, Option A). Everything else keeps the goal-detail overlay.
|
|
6445
|
+
const g = state.goals.find((x) => x.id === goalId);
|
|
6446
|
+
if (g && ['blocked', 'partial', 'failed', 'interrupted'].includes(g.status)) { saOpen(goalId, true); return; }
|
|
6427
6447
|
const overlay = $('goalDetailOverlay');
|
|
6428
6448
|
if (state.goalDetailOpen === goalId && overlay.classList.contains('show')) closeGoalDetail();
|
|
6429
6449
|
else openGoalDetail(goalId);
|
|
@@ -6794,6 +6814,13 @@ function renderFsTodo(){
|
|
|
6794
6814
|
// 一時停止したゴール(blocked kind:'paused')は Doing に「Paused」1行で留める(Masa 2026-07-19)。
|
|
6795
6815
|
// Needs you 側(failed/blocked)へは出さず、その走行/中断タスク行も抑止して二重行を作らない。
|
|
6796
6816
|
const pausedGoalIds = new Set(pgoals.filter((g) => g.status === 'blocked' && g.blocked?.kind === 'paused').map((g) => g.id));
|
|
6817
|
+
// A goal picked back up by a "Needs you" reply is `running` but may have no running WORK task
|
|
6818
|
+
// (only a reply task, which Doing excludes) — it used to vanish from the lane (Masa 2026-07-24).
|
|
6819
|
+
// Show it in Doing as a goal row, and stop its old stopped task from lingering under "Needs you".
|
|
6820
|
+
const runningTaskGoalIds = new Set(running.filter((t) => !t.reply).map((t) => t.goalId));
|
|
6821
|
+
const resumedGoalIds = new Set(pgoals.filter((g) => g.status === 'running' && g.startedAt).map((g) => g.id));
|
|
6822
|
+
const doingGoals = pgoals.filter((g) => g.status === 'running' && g.startedAt
|
|
6823
|
+
&& !runningTaskGoalIds.has(g.id) && !pausedGoalIds.has(g.id));
|
|
6797
6824
|
const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 70));
|
|
6798
6825
|
// LATER = deliberately shelved goals (composer "Later" send → server status 'pending';
|
|
6799
6826
|
// PRD §8.2 top gap — they were invisible in flagship). Staged disclosure: the cap
|
|
@@ -6815,7 +6842,10 @@ function renderFsTodo(){
|
|
|
6815
6842
|
running.filter((t) => !pausedGoalIds.has(t.goalId)).map((t) => `<div class="drow"><div class="trow now"><span class="st run"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span><span class="pct num">${progressFor(t)}%</span>${fsActs(t.goalId)}</div>
|
|
6816
6843
|
<div class="tbar"><i style="width:${progressFor(t)}%"></i></div></div>`).join('')
|
|
6817
6844
|
// 一時停止したゴールは Doing に「Paused」1行(アンバー)で留める+Resume。走行/中断行は上と needsRows で抑止済み。
|
|
6818
|
-
+ pgoals.filter((g) => pausedGoalIds.has(g.id)).map((g) => `<div class="drow"><div class="trow now"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword int">Paused</span><button class="ab txt" data-fspause="1" title="Resume — 再開">Resume</button></div></div>`).join('')
|
|
6845
|
+
+ pgoals.filter((g) => pausedGoalIds.has(g.id)).map((g) => `<div class="drow"><div class="trow now"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword int">Paused</span><button class="ab txt" data-fspause="1" title="Resume — 再開">Resume</button></div></div>`).join('')
|
|
6846
|
+
// A goal picked back up by a reply (running, no running work task yet): show it here so it
|
|
6847
|
+
// lands in Doing instead of vanishing. No % bar — nothing measurable is running yet.
|
|
6848
|
+
+ doingGoals.map((g) => `<div class="drow"><div class="trow now"><span class="st run"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword" style="color:var(--green)">Working on it</span>${fsActs(g.id)}</div></div>`).join('');
|
|
6819
6849
|
// NEEDS YOU: stopped/errored work that used to fold into Doing, now under its own caption so
|
|
6820
6850
|
// it no longer reads as "still in flight" (Masa 2026-07-19). Three kinds share it, each
|
|
6821
6851
|
// keeping the affordances it already had — nothing about the failed-task and blocked-goal
|
|
@@ -6832,7 +6862,7 @@ function renderFsTodo(){
|
|
|
6832
6862
|
// both still call the same /retry endpoint, which already resumes the session when one
|
|
6833
6863
|
// exists (server-side retry = "resume if possible, else re-run"; there is no separate
|
|
6834
6864
|
// resume endpoint to call).
|
|
6835
|
-
failed.filter((t) => !pausedGoalIds.has(t.goalId)).map((t) => { const isInt = t.status === 'interrupted';
|
|
6865
|
+
failed.filter((t) => !pausedGoalIds.has(t.goalId) && !resumedGoalIds.has(t.goalId)).map((t) => { const isInt = t.status === 'interrupted';
|
|
6836
6866
|
const word = isInt ? 'Interrupted' : 'Failed', icon = isInt ? '⏸' : '✗', label = isInt ? 'Resume' : 'Retry';
|
|
6837
6867
|
// don't restate the state word as the "reason" too (e.g. "Interrupted / Interrupted")
|
|
6838
6868
|
// when the task has no distinct result text — the coloured word already said it, so
|
|
@@ -6873,8 +6903,8 @@ function renderFsTodo(){
|
|
|
6873
6903
|
// goals) + Next up (queued) + Later (pending goals). "· next-up order" stays keyed off
|
|
6874
6904
|
// queued.length specifically, since that suffix describes the Next-up sublist's own sort,
|
|
6875
6905
|
// not the lane total.
|
|
6876
|
-
const needsCount = failed.length + blocked.length + needsInput.length;
|
|
6877
|
-
const todoTotal = running.length + needsCount + queued.length + later.length;
|
|
6906
|
+
const needsCount = failed.filter((t) => !resumedGoalIds.has(t.goalId)).length + blocked.length + needsInput.length;
|
|
6907
|
+
const todoTotal = running.length + doingGoals.length + needsCount + queued.length + later.length;
|
|
6878
6908
|
// Free-tier pending counter (H22): on the free plan the header shows the wall
|
|
6879
6909
|
// — pending goals out of the 5-slot cap — reddening at the cap and gone once
|
|
6880
6910
|
// paid. Reads the SAME numbers the gate enforces (state.billing, never a local
|
|
@@ -8481,7 +8511,7 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
|
|
|
8481
8511
|
// 4 archived / 5 reverted — session-local; the server round-trip is what
|
|
8482
8512
|
// settles it (see saSync's "returned to review" reset).
|
|
8483
8513
|
// ============================================================================
|
|
8484
|
-
const SA = { open: false, items: [], diffs: {}, actLog: {}, msgs: {}, convOpen: new Set(), shotBox: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
|
|
8514
|
+
const SA = { open: false, items: [], diffs: {}, actLog: {}, msgs: {}, convOpen: new Set(), shotBox: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null, solo: null };
|
|
8485
8515
|
window.SA = SA; // top-level const isn't a window property; external checks probe window.SA (same convention as window.state above)
|
|
8486
8516
|
const SA_SVG = {
|
|
8487
8517
|
ok: '<svg viewBox="0 0 24 24"><path d="M4 12l5 5L20 6"/></svg>',
|
|
@@ -8491,7 +8521,12 @@ const SA_SVG = {
|
|
|
8491
8521
|
function saRows(){
|
|
8492
8522
|
const gs = (state.goals || []).filter((g) => g.projectId === state.active);
|
|
8493
8523
|
const ts = (state.tasks || []).filter((t) => t.projectId === state.active);
|
|
8494
|
-
return buildReviewDigest({ goals: gs, tasks: ts }).map((r) =>
|
|
8524
|
+
return buildReviewDigest({ goals: gs, tasks: ts }).map((r) => saRowFromDigest(r, gs, ts));
|
|
8525
|
+
}
|
|
8526
|
+
// One digest-row → one See-all card item. Extracted from saRows (2026-07-24) so a single
|
|
8527
|
+
// "Needs you" goal can be opened as the SAME review card (saLedgerRows solo mode) — the card's
|
|
8528
|
+
// whole shape (Request/Result/Proof/Activity, empty sections auto-hidden) is reused verbatim.
|
|
8529
|
+
function saRowFromDigest(r, gs, ts){
|
|
8495
8530
|
const g = gs.find((x) => x.id === r.goalIds[0]);
|
|
8496
8531
|
const pt = ts.find((t) => r.goalIds.includes(t.goalId) && t.proof && (t.proof.png || t.proof.gif));
|
|
8497
8532
|
// "What we did" (H23 §2, replaces the old Plan/Review-thread folds): one bullet
|
|
@@ -8616,7 +8651,44 @@ function saRows(){
|
|
|
8616
8651
|
history: goalTokenHistoryLocal(gs, ts, g?.id),
|
|
8617
8652
|
}),
|
|
8618
8653
|
};
|
|
8619
|
-
|
|
8654
|
+
}
|
|
8655
|
+
// A "Needs you" (attention) goal, shaped as a digest row so saRowFromDigest builds it the same
|
|
8656
|
+
// way as a review card. No PR-merge/plan (those are review-only); everything else falls out of
|
|
8657
|
+
// the goal + its tasks exactly as buildReviewDigest does for a review goal.
|
|
8658
|
+
function saSoloDigestRow(g, ts){
|
|
8659
|
+
const goalTasks = (ts ?? []).filter((t) => t.goalId === g.id);
|
|
8660
|
+
const proofTask = goalTasks.find((t) => t.proof);
|
|
8661
|
+
const checkLine = (g.reviewSummary?.check || g.plan?.[0] || g.text || '').replace(/\s+/g, ' ').trim().slice(0, 66);
|
|
8662
|
+
return {
|
|
8663
|
+
goalIds: [g.id], reviewNumber: goalReviewNumber({ goal: g, tasks: ts }), checkLine,
|
|
8664
|
+
pr: g.pr ?? null, testResult: g.testResult ?? null,
|
|
8665
|
+
proof: proofTask?.proof ?? null, proofTaskId: proofTask?.id ?? null, plan: false, planText: null,
|
|
8666
|
+
};
|
|
8667
|
+
}
|
|
8668
|
+
// The See-all Ledger's cards. Normally the review digest (the "See all" batch stays review-only);
|
|
8669
|
+
// in solo mode (a single "Needs you" goal tapped) it is just that one goal's card, reusing the
|
|
8670
|
+
// exact review-card render (Masa 2026-07-24, Option A).
|
|
8671
|
+
function saLedgerRows(){
|
|
8672
|
+
if (SA.solo != null) {
|
|
8673
|
+
const gs = (state.goals || []).filter((g) => g.projectId === state.active);
|
|
8674
|
+
const ts = (state.tasks || []).filter((t) => t.projectId === state.active);
|
|
8675
|
+
const g = gs.find((x) => x.id === SA.solo);
|
|
8676
|
+
if (!g) return [];
|
|
8677
|
+
const row = saRowFromDigest(saSoloDigestRow(g, ts), gs, ts);
|
|
8678
|
+
// A stuck goal: surface WHY it stopped and what to do in the card's Result — the same
|
|
8679
|
+
// plain-language, language-following summary the old Attention panel showed
|
|
8680
|
+
// (summarizeAttention), mapped into the review card instead of a bespoke "What happened /
|
|
8681
|
+
// Your call" layout (Masa 2026-07-24). The Request section already shows the ask, so drop
|
|
8682
|
+
// the summary's "必要だったこと:/Needed:" lead to avoid repeating it.
|
|
8683
|
+
if (['blocked', 'partial', 'failed', 'interrupted'].includes(g.status)) {
|
|
8684
|
+
const s = summarizeAttention(g, ts.filter((t) => t.goalId === g.id));
|
|
8685
|
+
const why = String(s.happened || '').replace(/^\s*(必要だったこと:|Needed:)[^。.]*[。.]\s*/, '').trim();
|
|
8686
|
+
const merged = [why, s.decision].filter(Boolean).join('\n\n');
|
|
8687
|
+
if (merged) row.report = merged;
|
|
8688
|
+
}
|
|
8689
|
+
return [row];
|
|
8690
|
+
}
|
|
8691
|
+
return saRows();
|
|
8620
8692
|
}
|
|
8621
8693
|
// (P1, Masa 2026-07-21) saMetaRowHtml() was deleted here — the pre-redesign meta row,
|
|
8622
8694
|
// uncalled since the review-card redesign. Its "Run locally" chip only ever opened a
|
|
@@ -8854,7 +8926,11 @@ function saWorkflowHtml(it){
|
|
|
8854
8926
|
// merge→deploy, varying per person. There is no place to record such a pipeline yet,
|
|
8855
8927
|
// so inventing one here would be this layer guessing someone's process. Left as a
|
|
8856
8928
|
// decision, not faked.)
|
|
8857
|
-
|
|
8929
|
+
// A stopped / being-picked-back-up goal sits at Implemented — it has NOT reached review, so
|
|
8930
|
+
// "In review" must not read as the current step (Masa 2026-07-24). Only a real review goal
|
|
8931
|
+
// marks Implemented done (✓) and In review as now (→).
|
|
8932
|
+
const atImplemented = it.st === 3 || ['blocked', 'partial', 'failed', 'interrupted', 'running'].includes(it.goal?.status);
|
|
8933
|
+
const steps = [['Implemented', atImplemented ? 'now' : 'ok'], ['Tested', ran ? 'ok' : 'todo'], ['In review', atImplemented ? 'todo' : 'now'], ['Merge', 'todo']];
|
|
8858
8934
|
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>';
|
|
8859
8935
|
}
|
|
8860
8936
|
function saItemHtml(it, i){
|
|
@@ -9092,8 +9168,18 @@ function saItemHtml(it, i){
|
|
|
9092
9168
|
// BELOW the box (the same .replynote surface the answer flow uses) — so sending only APPENDS
|
|
9093
9169
|
// a line and never re-lays-out the card. It leaves Review only when the card is closed.
|
|
9094
9170
|
const working = it.st === 3;
|
|
9171
|
+
// A "Needs you" (attention) goal opened as this card reads its real state, not "In review"
|
|
9172
|
+
// (Masa 2026-07-24). After a reply it becomes "Working on it" like any other card.
|
|
9173
|
+
const gstatus = it.goal?.status;
|
|
9174
|
+
const attn = !working && ['blocked', 'partial', 'failed', 'interrupted'].includes(gstatus);
|
|
9175
|
+
// Match the flagship list's state colours (Masa's Interrupt=amber / Failed=red rule): an
|
|
9176
|
+
// interrupted goal is amber+resumable, everything else stopped is a red error.
|
|
9177
|
+
const attnInt = gstatus === 'interrupted';
|
|
9178
|
+
const attnLabel = gstatus === 'interrupted' ? 'Interrupted' : gstatus === 'blocked' ? 'Blocked' : 'Failed';
|
|
9095
9179
|
const rst = working
|
|
9096
9180
|
? '<span class="rst working"><span class="rdot"></span>Working on it</span>'
|
|
9181
|
+
: attn
|
|
9182
|
+
? `<span class="rst ${attnInt ? 'int' : 'err'}"><span class="rdot"></span>${attnLabel}</span>`
|
|
9097
9183
|
: '<span class="rst"><span class="rdot"></span>In review</span>';
|
|
9098
9184
|
return `<div class="sa-item lg redesign${it.goalId === SA.cur ? ' cur' : ''}${working ? ' working' : ''}" data-gid="${it.goalId}">
|
|
9099
9185
|
<div class="rhead"><span class="rtag">${it.no != null ? `#${it.no}` : ''}</span>${rst}</div>
|
|
@@ -9112,7 +9198,7 @@ function saItemHtml(it, i){
|
|
|
9112
9198
|
${conversationSec}
|
|
9113
9199
|
${activitySec}
|
|
9114
9200
|
${S('Workflow', saWorkflowHtml(it))}
|
|
9115
|
-
<div class="sa-act"
|
|
9201
|
+
<div class="sa-act">${attn ? '' : `<button class="sa-app" data-saact="app:${i}">${SA_SVG.ok}Approve</button><button class="sa-dis" data-saact="dis:${i}">Dismiss</button>`}<div class="sa-chatwrap"><input class="sa-chat" data-i="${i}" data-goal="${it.goalId}" placeholder="Reply, ask, or send back to the AI…"><button class="sa-send" data-saact="send:${i}" title="Send"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div><button class="sa-ic" title="Archive" data-saact="arch:${i}">${SA_SVG.arch}</button><button class="sa-ic" title="Revert" data-saact="rev:${i}">${SA_SVG.trash}</button></div>
|
|
9116
9202
|
</div>`;
|
|
9117
9203
|
}
|
|
9118
9204
|
// Scroll the See-all body so a card's top edge (.rhead) sits at the top of the visible
|
|
@@ -9180,10 +9266,15 @@ function saRender(){
|
|
|
9180
9266
|
const S = $('seeall'); if (!S) return;
|
|
9181
9267
|
const pending = SA.items.filter((x) => x.st === 0);
|
|
9182
9268
|
const green = pending.filter((x) => !x.testResult || x.testResult.ok !== false).length;
|
|
9183
|
-
|
|
9269
|
+
// Solo mode = a single "Needs you" goal opened as this card. It is not a review batch, so the
|
|
9270
|
+
// header drops "Review / N ready / all green" and the Approve/Dismiss hints (Masa 2026-07-24).
|
|
9271
|
+
const solo = SA.solo != null;
|
|
9272
|
+
const hdTitle = solo ? 'Needs you' : 'Review';
|
|
9273
|
+
const c = solo ? '' : `${pending.length} ready · ${green === pending.length ? 'all green' : `${pending.length - green} failing`}`;
|
|
9184
9274
|
const curIdx = pending.findIndex((x) => x.goalId === SA.cur);
|
|
9185
|
-
const posLabel = pending.length && curIdx >= 0 ? `${curIdx + 1} / ${pending.length}` : '';
|
|
9186
|
-
|
|
9275
|
+
const posLabel = solo ? '' : (pending.length && curIdx >= 0 ? `${curIdx + 1} / ${pending.length}` : '');
|
|
9276
|
+
const hdKeys = solo ? '↑↓ MOVE · ESC CLOSE' : '↑↓ MOVE · A APPROVE · D DISMISS · ESC CLOSE';
|
|
9277
|
+
S.innerHTML = `<div class="sa-panel"><div class="sa-hd"><span class="t">${hdTitle}</span><span class="c">${c}</span><span class="sa-count">${posLabel}</span><span class="keys">${hdKeys}</span><button class="sa-x" data-saact="close:0">✕</button></div>
|
|
9187
9278
|
<div class="sa-body">${SA.items.length ? SA.items.map((it, i) => saItemHtml(it, i)).join('') : '<div class="sa-empty">Nothing waiting for review.</div>'}</div></div>`;
|
|
9188
9279
|
// Native <details> would collapse on every rebuild (SSE tick, approve, etc.) —
|
|
9189
9280
|
// track what's open per goal+section in SA.detOpen (persists across re-renders)
|
|
@@ -9237,7 +9328,7 @@ function saSync(){
|
|
|
9237
9328
|
if (!SA.open) return;
|
|
9238
9329
|
const ae = document.activeElement;
|
|
9239
9330
|
if (ae && $('seeall').contains(ae) && ae.tagName === 'INPUT') return;
|
|
9240
|
-
const rows =
|
|
9331
|
+
const rows = saLedgerRows();
|
|
9241
9332
|
for (const row of rows) {
|
|
9242
9333
|
const ex = SA.items.find((x) => x.goalId === row.goalId);
|
|
9243
9334
|
// Preserve st===3 ("Working on it", 決定1-4): a reply held the card open in place; if a
|
|
@@ -9258,9 +9349,10 @@ async function saFetchDiffs(){
|
|
|
9258
9349
|
}));
|
|
9259
9350
|
if (SA.open) saSync();
|
|
9260
9351
|
}
|
|
9261
|
-
function saOpen(focusGoalId){
|
|
9352
|
+
function saOpen(focusGoalId, solo = false){
|
|
9262
9353
|
SA.open = true;
|
|
9263
|
-
SA.
|
|
9354
|
+
SA.solo = solo ? focusGoalId : null; // solo = a single "Needs you" goal opened as one card
|
|
9355
|
+
SA.items = saLedgerRows().map((r) => ({ ...r, st: 0 }));
|
|
9264
9356
|
SA.detOpen = new Set();
|
|
9265
9357
|
// H14: focus a specific goal's card (single-review tap). Map the tapped goal
|
|
9266
9358
|
// to the card that CONTAINS it (goals can group in the digest), then let
|
|
@@ -9289,7 +9381,7 @@ function saOpen(focusGoalId){
|
|
|
9289
9381
|
saFetchDiffs();
|
|
9290
9382
|
saEnhanceShotBoxes();
|
|
9291
9383
|
}
|
|
9292
|
-
function saClose(){ SA.open = false; SA.focusGid = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); }
|
|
9384
|
+
function saClose(){ SA.open = false; SA.solo = null; SA.focusGid = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); }
|
|
9293
9385
|
// Continuous review (Masa 2026-07-08): move a persistent "current" card with
|
|
9294
9386
|
// ↑↓ and auto-advance to the next pending after each verdict — same screen, same
|
|
9295
9387
|
// Ledger, so approving flows without hunting for the next one.
|
|
@@ -9369,7 +9461,10 @@ async function saSendReply(it, i, text){
|
|
|
9369
9461
|
// Append the line to the card's own thread immediately — this is what the person sees, and
|
|
9370
9462
|
// its `seq` fixes its order regardless of when any round trip returns.
|
|
9371
9463
|
it.rthread = [...(it.rthread || []), { who: 'you', text, seq }];
|
|
9372
|
-
|
|
9464
|
+
// A "Needs you" (attention) reply is a fix instruction, not a review verdict: it always goes
|
|
9465
|
+
// straight to /reply (no answer/unsure routing) and picks the goal back up (Masa 2026-07-24).
|
|
9466
|
+
const isAttn = ['blocked', 'partial', 'failed', 'interrupted'].includes(it.goal?.status);
|
|
9467
|
+
const followUp = it.st === 3 || it.inflight || isAttn; // card in conversation / attention → another instruction
|
|
9373
9468
|
|
|
9374
9469
|
if (followUp) {
|
|
9375
9470
|
it.st = 3; it.rstatus = 'working'; SA.cur = goalId;
|
|
@@ -9377,6 +9472,14 @@ async function saSendReply(it, i, text){
|
|
|
9377
9472
|
try {
|
|
9378
9473
|
const r = await fetch(withKey(`/api/goals/${goalId}/reply`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) });
|
|
9379
9474
|
if (!r.ok) throw new Error('reply failed');
|
|
9475
|
+
if (isAttn) {
|
|
9476
|
+
// A Needs-you reply picks the goal back up → it belongs in Doing, not lingering in
|
|
9477
|
+
// "Needs you" or vanishing (Masa 2026-07-24). Flip it optimistically so the lane behind
|
|
9478
|
+
// the card updates on close; the SSE 'goal' event reconciles the same value.
|
|
9479
|
+
const g = state.goals.find((x) => x.id === goalId);
|
|
9480
|
+
if (g) { g.status = 'running'; g.blocked = null; if (!g.startedAt) g.startedAt = new Date().toISOString(); }
|
|
9481
|
+
render(); // saSync skips the focused card, so this only refreshes the lane behind it
|
|
9482
|
+
}
|
|
9380
9483
|
} catch {
|
|
9381
9484
|
it.rthread = it.rthread.filter((m) => m.seq !== seq); // roll back just this line
|
|
9382
9485
|
if (!it.rthread.some((m) => m.who === 'you')) { it.st = 0; it.rstatus = null; }
|
|
@@ -9547,6 +9650,9 @@ document.addEventListener('keydown', (e) => {
|
|
|
9547
9650
|
return saMove(e.key === 'ArrowDown' || e.key === 'j' ? 1 : -1);
|
|
9548
9651
|
}
|
|
9549
9652
|
if (!['a', 'A', 'd', 'D'].includes(e.key)) return;
|
|
9653
|
+
// A "Needs you" card (solo) has no Approve/Dismiss — the buttons are hidden, so the
|
|
9654
|
+
// keyboard shortcut must not fire an approve/reject on a stuck goal either (Masa 2026-07-24).
|
|
9655
|
+
if (SA.solo != null) return;
|
|
9550
9656
|
// Act on the CURRENT card (fall back to the top pending), then auto-advance
|
|
9551
9657
|
// focus to the next pending — so A A A blitzes down the queue with tempo.
|
|
9552
9658
|
const it = SA.items.find((x) => x.goalId === SA.cur && x.st === 0) || SA.items.find((x) => x.st === 0);
|
package/app/theme.css
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
--tint:20,23,31;
|
|
22
22
|
--hair:rgba(var(--tint),.10); --hair2:rgba(var(--tint),.16);
|
|
23
23
|
--glass:rgb(255 255 255 / .72);
|
|
24
|
-
--green-rgb:10,154,106; --blue-rgb:56,116,214; --amber-rgb:
|
|
24
|
+
--green-rgb:10,154,106; --blue-rgb:56,116,214; --amber-rgb:170,128,40; --danger-rgb:200,44,44;
|
|
25
25
|
--purple-rgb:150,90,220; --glow-rgb:120,150,230;
|
|
26
26
|
--green:rgb(var(--green-rgb)); --amber:rgb(var(--amber-rgb)); --blue:rgb(var(--blue-rgb)); --danger:rgb(var(--danger-rgb));
|
|
27
27
|
--iri:linear-gradient(112deg,#b794f6 0%,#7cc4ff 38%,#5ce0c0 72%,#ffd27a 100%);
|
|
@@ -44,7 +44,7 @@ body[data-theme="banff"]{
|
|
|
44
44
|
--tint:255,255,255;
|
|
45
45
|
--hair:rgba(var(--tint),.09); --hair2:rgba(var(--tint),.16);
|
|
46
46
|
--glass:rgba(12,13,17,.6);
|
|
47
|
-
--green-rgb:79,215,156; --blue-rgb:124,196,255; --amber-rgb:
|
|
47
|
+
--green-rgb:79,215,156; --blue-rgb:124,196,255; --amber-rgb:228,198,120; --danger-rgb:240,115,111;
|
|
48
48
|
--purple-rgb:183,148,246; --glow-rgb:146,180,255;
|
|
49
49
|
--green:rgb(var(--green-rgb)); --amber:rgb(var(--amber-rgb)); --blue:rgb(var(--blue-rgb)); --danger:rgb(var(--danger-rgb));
|
|
50
50
|
--on-accent:#0a0a0b; --invink:#0a0a0b;
|
|
@@ -62,7 +62,7 @@ body[data-theme="dark"],body[data-theme="midnight"],body[data-theme="cinema"]{
|
|
|
62
62
|
--tint:255,255,255;
|
|
63
63
|
--hair:rgba(var(--tint),.07); --hair2:rgba(var(--tint),.12);
|
|
64
64
|
--glass:rgba(22,22,27,.55);
|
|
65
|
-
--green-rgb:52,211,153; --blue-rgb:124,196,255; --amber-rgb:
|
|
65
|
+
--green-rgb:52,211,153; --blue-rgb:124,196,255; --amber-rgb:228,198,120; --danger-rgb:255,120,120;
|
|
66
66
|
--purple-rgb:183,148,246; --glow-rgb:146,180,255;
|
|
67
67
|
--on-accent:#0a0a0b; --invink:#0a0a0b;
|
|
68
68
|
--send-bg:var(--iri); --send-ink:#0a0a0b;
|
|
@@ -92,7 +92,7 @@ body[data-theme="glass"]{
|
|
|
92
92
|
--tint:255,255,255;
|
|
93
93
|
--hair:rgba(255,255,255,.16); --hair2:rgba(255,255,255,.24);
|
|
94
94
|
--glass:rgba(12,13,17,.6);
|
|
95
|
-
--green-rgb:102,230,198; --blue-rgb:143,208,255; --amber-rgb:
|
|
95
|
+
--green-rgb:102,230,198; --blue-rgb:143,208,255; --amber-rgb:232,206,134; --danger-rgb:255,138,134;
|
|
96
96
|
--purple-rgb:183,148,246; --glow-rgb:146,180,255;
|
|
97
97
|
--green:rgb(var(--green-rgb)); --amber:rgb(var(--amber-rgb)); --blue:rgb(var(--blue-rgb)); --danger:rgb(var(--danger-rgb));
|
|
98
98
|
--on-accent:#0a0a0b; --invink:#0a0a0b;
|
|
@@ -117,7 +117,7 @@ body[data-theme="gradient"]{
|
|
|
117
117
|
--tint:20,23,31;
|
|
118
118
|
--hair:rgba(var(--tint),.10); --hair2:rgba(var(--tint),.16);
|
|
119
119
|
--glass:rgb(255 255 255 / .72);
|
|
120
|
-
--green-rgb:79,215,156; --blue-rgb:37,99,235; --amber-rgb:
|
|
120
|
+
--green-rgb:79,215,156; --blue-rgb:37,99,235; --amber-rgb:178,140,40; --danger-rgb:240,115,111;
|
|
121
121
|
--purple-rgb:150,90,220; --glow-rgb:120,150,230;
|
|
122
122
|
--green:rgb(var(--green-rgb)); --amber:rgb(var(--amber-rgb)); --blue:rgb(var(--blue-rgb)); --danger:rgb(var(--danger-rgb));
|
|
123
123
|
--on-accent:#0a0a0b; --invink:#ffffff;
|
package/engine/lib.mjs
CHANGED
|
@@ -1086,7 +1086,15 @@ export function parseReviewSummary(raw, fallback = {}) {
|
|
|
1086
1086
|
};
|
|
1087
1087
|
}
|
|
1088
1088
|
|
|
1089
|
-
|
|
1089
|
+
// A change is "doc-only" (skip the project test suite) when every file is
|
|
1090
|
+
// documentation. EVERYTHING under docs/ counts — not just .md: docs/ holds design
|
|
1091
|
+
// mockups (docs/design/**/*.html), handoffs, and images that the engine's tests
|
|
1092
|
+
// never import, so running the 58-file suite for them is pure waste. Before this,
|
|
1093
|
+
// only docs/**.md|mdx|txt was excused, so a CDO design-lab goal editing a single
|
|
1094
|
+
// docs/design/*.html re-ran the whole engine suite in its worktree and sat in
|
|
1095
|
+
// `running` ("Working on it") for 20+ minutes on a busy machine (dogfood 2026-07-24,
|
|
1096
|
+
// goal-632). Plus top-level README/CHANGELOG/LICENSE and any *.md/*.txt anywhere.
|
|
1097
|
+
const DOC_ONLY_RE = /(^|\/)(README|START-HERE|CHANGELOG|LICENSE)(\.[\w.-]+)?$|(^|\/)docs\/|\.mdx?$|\.txt$/i;
|
|
1090
1098
|
export function hasTestRelevantChanges(changedFiles) {
|
|
1091
1099
|
const files = (changedFiles ?? []).map((f) => String(f ?? '').trim()).filter(Boolean);
|
|
1092
1100
|
if (!files.length) return true;
|
|
@@ -4126,6 +4134,30 @@ export function licenseTokenIdentity(token) {
|
|
|
4126
4134
|
} catch { return ''; }
|
|
4127
4135
|
}
|
|
4128
4136
|
|
|
4137
|
+
// Decode a license token's payload (unverified — signature is checked elsewhere,
|
|
4138
|
+
// offline via verifyLicenseToken with the baked-in public key). Returns null when
|
|
4139
|
+
// it can't be decoded. Used to read exp/iat/isPaying for the refresh scheduler.
|
|
4140
|
+
export function licenseTokenPayload(token) {
|
|
4141
|
+
if (!token || typeof token !== 'string') return null;
|
|
4142
|
+
const parts = token.split('.');
|
|
4143
|
+
if (parts.length !== 3) return null;
|
|
4144
|
+
try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); }
|
|
4145
|
+
catch { return null; }
|
|
4146
|
+
}
|
|
4147
|
+
|
|
4148
|
+
// License tokens are short-lived (7 days) BY DESIGN — the engine is meant to fetch
|
|
4149
|
+
// a fresh one while online. Before this scheduler that fetch never happened, so a
|
|
4150
|
+
// paying user was silently logged out every 7 days (relay rejects the expired
|
|
4151
|
+
// token → the app shows "run npx @galda/cli" forever; dogfood 2026-07-24). Refresh
|
|
4152
|
+
// once the token is within `refreshWithinMs` of expiry (default 2 days) — INCLUDING
|
|
4153
|
+
// already-expired, so an agent that was offline past expiry still recovers on its
|
|
4154
|
+
// own without a manual sign-in (the Worker bounds how stale it may be). A token far
|
|
4155
|
+
// from expiry, or one that can't be decoded (not ours / malformed), is left alone.
|
|
4156
|
+
export function shouldRefreshLicense({ exp, now = Date.now(), refreshWithinMs = 2 * 24 * 3600 * 1000 } = {}) {
|
|
4157
|
+
if (typeof exp !== 'number' || !Number.isFinite(exp)) return false;
|
|
4158
|
+
return (exp * 1000 - now) < refreshWithinMs;
|
|
4159
|
+
}
|
|
4160
|
+
|
|
4129
4161
|
// Pure reducer for the relay-client's reconnect decision. Returns the next state
|
|
4130
4162
|
// and whether to dial the relay now. State: { stoodDown, standingIdentity }.
|
|
4131
4163
|
// Events:
|
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, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse } from './lib.mjs';
|
|
24
|
-
import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, taskStatusAfterVerify, 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, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, 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, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages } from './lib.mjs';
|
|
24
|
+
import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, taskStatusAfterVerify, 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, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, 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, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages } from './lib.mjs';
|
|
25
25
|
import { createSerialQueue } from './lib.mjs';
|
|
26
26
|
import { collectProjectRules } from './lib.mjs';
|
|
27
27
|
import { openPR } from './pr.mjs';
|
|
@@ -293,6 +293,46 @@ async function activateLicense(token) {
|
|
|
293
293
|
return { ok: true, email: result.payload.email };
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
// Keep the short-lived (7-day) license token fresh WHILE ONLINE so a paying user
|
|
297
|
+
// is never silently logged out when it expires. The token can only otherwise be
|
|
298
|
+
// re-minted by a full Google sign-in, so before this a $12/mo customer was
|
|
299
|
+
// dropped to "run npx @galda/cli" every 7 days (relay rejects the expired token;
|
|
300
|
+
// dogfood 2026-07-24). When the on-disk token is within ~2 days of expiry (or
|
|
301
|
+
// already expired but still refreshable), ask billing-api for a fresh one and
|
|
302
|
+
// persist it through activateLicense — which verifies the new token OFFLINE with
|
|
303
|
+
// the baked key before writing, so a bad/forged response can't wedge us. The
|
|
304
|
+
// relay-client watches license.token and reconnects the instant it changes, so
|
|
305
|
+
// the refresh re-authenticates the relay with NO user action. Best-effort: any
|
|
306
|
+
// failure (offline / 401 too-stale → needs sign-in / misconfig) leaves the
|
|
307
|
+
// current token untouched and retries next tick.
|
|
308
|
+
async function refreshLicenseIfNeeded() {
|
|
309
|
+
try {
|
|
310
|
+
if (!BILLING_API_URL || !existsSync(licenseFile)) return;
|
|
311
|
+
const token = readFileSync(licenseFile, 'utf8').trim();
|
|
312
|
+
const payload = licenseTokenPayload(token);
|
|
313
|
+
if (!payload || !shouldRefreshLicense({ exp: payload.exp })) return;
|
|
314
|
+
const controller = new AbortController();
|
|
315
|
+
const timer = setTimeout(() => controller.abort(), 10000);
|
|
316
|
+
let res;
|
|
317
|
+
try {
|
|
318
|
+
res = await fetch(`${BILLING_API_URL}/license/refresh`, {
|
|
319
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
320
|
+
body: JSON.stringify({ token }), signal: controller.signal,
|
|
321
|
+
});
|
|
322
|
+
} finally { clearTimeout(timer); }
|
|
323
|
+
if (!res.ok) return; // 401 (too stale → a real sign-in is needed), 501 misconfig, 5xx — retry later
|
|
324
|
+
const body = await res.json().catch(() => null);
|
|
325
|
+
if (!body?.token || body.token === token) return; // nothing new
|
|
326
|
+
const r = await activateLicense(body.token);
|
|
327
|
+
if (r.ok) console.log(`[manager] license renewed for ${r.email} before expiry (no sign-in needed)`);
|
|
328
|
+
} catch { /* best-effort — keep the current token, retry next tick */ }
|
|
329
|
+
}
|
|
330
|
+
// Check on boot and twice a day. A 7-day token with a 2-day refresh window is
|
|
331
|
+
// renewed ~5 days in, long before the relay would reject it, as long as the
|
|
332
|
+
// engine is running at any point in that window.
|
|
333
|
+
refreshLicenseIfNeeded();
|
|
334
|
+
setInterval(refreshLicenseIfNeeded, 12 * 60 * 60 * 1000).unref?.();
|
|
335
|
+
|
|
296
336
|
// The HTML shown in the popup after Google sign-in. It notifies the app window
|
|
297
337
|
// (postMessage; the app validates origin+shape and refreshes) and closes
|
|
298
338
|
// itself. Everything is inline + self-contained — this page can be opened
|
package/package.json
CHANGED