local-kanban 2.0.0-beta.4 → 2.0.0-beta.6
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/CHANGELOG.md +22 -0
- package/bin/kb +4 -4
- package/bin/local-kanban +2 -1
- package/package.json +1 -1
- package/public/js/board.js +6 -11
- package/public/js/chat.js +2 -1
- package/public/js/core.js +5 -0
- package/public/js/drawer.js +9 -5
- package/public/js/health-rules.js +2 -0
- package/public/js/health.js +2 -2
- package/public/js/review-rules.js +1 -0
- package/public/js/sse.js +1 -0
- package/public/js/unsaved.js +30 -0
- package/src/auto-health.js +19 -5
- package/src/auto-resume.js +5 -4
- package/src/chat-feed.js +13 -6
- package/src/launch.js +4 -4
- package/src/phantom.js +23 -6
- package/src/routes/chat.js +2 -2
- package/src/routes/projects.js +1 -1
- package/src/routes/system.js +7 -6
- package/src/routes/tasks.js +23 -9
- package/src/routes/terminal.js +4 -4
- package/src/share.js +1 -1
- package/src/shipped.js +10 -3
- package/src/sync/github.js +3 -2
- package/src/tree-print.js +12 -2
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,28 @@ version it goes out as and opens an empty one again. Released sections are histo
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [2.0.0-beta.6]
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Words typed to the terminal of a task in Review that only lead to commits of other tasks or a release (such as «roll it out») no longer keep the handed-in task in Doing: when the turn changed nothing but commits and none of them names the task, it goes back to Review. A task without an issue keeps the old rule — a commit naming nothing may be its own.
|
|
16
|
+
|
|
17
|
+
## [2.0.0-beta.5]
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Going on with interrupted work: a card no longer says «Claude waits at its prompt» about a session that is at work after the board brought it back — the record is let go without a note, as it was before 2.0.0-beta.4.
|
|
22
|
+
- A title or description the board refused no longer makes an open card send its edit again and again: it stays in the field as typed, the card says it is not saved, and the rest of the edit is saved once.
|
|
23
|
+
- Health: the Russian text of the task «Fix» opens puts its counts of lines and characters in the right grammatical form.
|
|
24
|
+
- Health: «Fix» opens one task even when it is pressed many times at once, and the board refuses it for a CLAUDE.md that is within the norms, by the same rule as the button.
|
|
25
|
+
- Chat: a workflow or a background command still running in a turn older than the ones shown is listed while Claude waits for it, and work with no end and no word for over 2 hours is shown as «no word from it for over 2 h» instead of running for ever.
|
|
26
|
+
- A tmux updated while its old server still runs no longer answers «try again in a few seconds» — the board says which tmux server to stop.
|
|
27
|
+
- A limit line split by the terminal inside the date or the words is read whole, so the reset time is right.
|
|
28
|
+
- Dropping a group of cards shows the page's and the board's refusals in one window.
|
|
29
|
+
- Over Wi-Fi, a HEAD request for a page a guest may open passes like GET.
|
|
30
|
+
- `kb note` with a task typed as `1` after `DM-1` no longer writes the same note twice.
|
|
31
|
+
- A merge commit is told by its parents, not by a message starting with «Merge».
|
|
32
|
+
|
|
11
33
|
## [2.0.0-beta.4]
|
|
12
34
|
|
|
13
35
|
### Added
|
package/bin/kb
CHANGED
|
@@ -77,12 +77,12 @@ function key(id) {
|
|
|
77
77
|
}
|
|
78
78
|
// after the board wrote the note (code 1), and the retry is a new kb with nothing remembered — the same key lets
|
|
79
79
|
// the board find what it already wrote instead of writing it twice. The same words from the same session again
|
|
80
|
-
// stay one note; that repeats nothing a person would want twice.
|
|
81
|
-
function noteKey(
|
|
82
|
-
return 'kb-' + createHash('sha256').update(`${
|
|
80
|
+
// stay one note; that repeats nothing a person would want twice. The task is not in the key: the board keeps keys
|
|
81
|
+
function noteKey(body) {
|
|
82
|
+
return 'kb-' + createHash('sha256').update(`${SESSION}\n${body}`).digest('hex').slice(0, 40);
|
|
83
83
|
}
|
|
84
84
|
async function postNote(id, body) {
|
|
85
|
-
const c = await api('POST', `/api/tasks/${key(id)}/comments`, { body, author: 'claude', client_key: noteKey(
|
|
85
|
+
const c = await api('POST', `/api/tasks/${key(id)}/comments`, { body, author: 'claude', client_key: noteKey(body) }, NOTE);
|
|
86
86
|
if (c.deleted) die(`kb: the same note was on ${id} and was deleted there — not written again; change the words to add it`);
|
|
87
87
|
return c;
|
|
88
88
|
}
|
package/bin/local-kanban
CHANGED
|
@@ -13,6 +13,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
13
13
|
import { existsSync, lstatSync, writeFileSync, mkdirSync, symlinkSync } from 'node:fs';
|
|
14
14
|
import { join, dirname, sep } from 'node:path';
|
|
15
15
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
16
|
+
import { bin } from '../src/bin-path.js'; // a program by its full path, as the board starts them
|
|
16
17
|
|
|
17
18
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
19
|
|
|
@@ -56,7 +57,7 @@ function ghReady() {
|
|
|
56
57
|
// the sync needs more than authentication: without the project scope create_issue succeeds
|
|
57
58
|
// but the card never lands on the Projects v2 board, and the user hunts for the cause
|
|
58
59
|
try {
|
|
59
|
-
const out = execFileSync('gh', ['auth', 'status'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
60
|
+
const out = execFileSync(bin('gh'), ['auth', 'status'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
60
61
|
return { ok: true, project: /\bproject\b/.test(out) };
|
|
61
62
|
} catch {
|
|
62
63
|
return { ok: false, project: false };
|
package/package.json
CHANGED
package/public/js/board.js
CHANGED
|
@@ -588,8 +588,8 @@ function renderMultiPanel() {
|
|
|
588
588
|
}
|
|
589
589
|
export function refusedManyText(head, refused, t = tr) {
|
|
590
590
|
const by = new Map();
|
|
591
|
-
for (const { key, e } of refused) {
|
|
592
|
-
const why = t(refusalText(e).reason);
|
|
591
|
+
for (const { key, e, why: said } of refused) {
|
|
592
|
+
const why = said ?? t(refusalText(e).reason);
|
|
593
593
|
by.set(why, [...(by.get(why) || []), key]);
|
|
594
594
|
}
|
|
595
595
|
const keys = (ks) => (ks.length > 6 ? `${ks.slice(0, 6).join(', ')} +${ks.length - 6}` : ks.join(', '));
|
|
@@ -973,14 +973,10 @@ async function onDropGroup(e, status, body) {
|
|
|
973
973
|
.filter(Boolean)
|
|
974
974
|
.filter((t) => !DRAG_LOCKED.has(t.status))
|
|
975
975
|
.filter((t) => !moveBlocked(t.status, status, { manual: taskIsNoclaude(t) }));
|
|
976
|
-
const
|
|
977
|
-
.filter((t) => !movable.includes(t))
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
.find(Boolean) || tr('Claude moves these columns itself');
|
|
981
|
-
styledAlert(`${refused.length} ${plural(refused.length, 'task', 'tasks')}: ${why}`);
|
|
982
|
-
}
|
|
983
|
-
if (!movable.length) { dropPh.remove(); return; }
|
|
976
|
+
const failed = ids.map((id) => state.tasks.find((x) => x.id === id)).filter(Boolean)
|
|
977
|
+
.filter((t) => !movable.includes(t))
|
|
978
|
+
.map((t) => ({ key: t.key, why: moveBlocked(t.status, status, { manual: taskIsNoclaude(t) }) || tr('Claude moves these columns itself') }));
|
|
979
|
+
if (!movable.length) { dropPh.remove(); sayRefusedMany('Not moved', failed); return; }
|
|
984
980
|
|
|
985
981
|
const draggedSet = new Set(movable.map((t) => t.id));
|
|
986
982
|
const cards = [...body.querySelectorAll('.card')].filter((c) => !draggedSet.has(Number(c.dataset.id)));
|
|
@@ -995,7 +991,6 @@ async function onDropGroup(e, status, body) {
|
|
|
995
991
|
|
|
996
992
|
dropPh.remove();
|
|
997
993
|
dropSends = true;
|
|
998
|
-
const failed = [];
|
|
999
994
|
for (let i = 0; i < movable.length; i++) {
|
|
1000
995
|
if (fixedOrder && movable[i].status === status) continue;
|
|
1001
996
|
try { await api('PATCH', `/api/tasks/${seg(movable[i].id)}`, fixedOrder ? { status } : { status, position: prev + step * (i + 1) }, { quiet: true }); }
|
package/public/js/chat.js
CHANGED
|
@@ -86,7 +86,8 @@ function stepRow(it, working = false) {
|
|
|
86
86
|
const tail = it.running ? '' : [it.wrote ? timesWords(it.wrote, it.ms) : dur(it.ms), it.error ? '' : (it.ms !== undefined ? '✓' : '')].filter(Boolean).join(' ');
|
|
87
87
|
const steps = it.helper ? `<span class="chat-step-n">${it.helper.steps} ${esc(plural(it.helper.steps, 'step', 'steps'))}</span>` : '';
|
|
88
88
|
const since = it.running ? clockOf(it.since || it.at) : '';
|
|
89
|
-
const bg = it.running ? `<span class="chat-bg">${working ? '<span class="chat-pulse"></span>' : ''}${esc(since ? `${tr('in the background since')} ${since}` : tr('in the background'))}</span>`
|
|
89
|
+
const bg = it.running ? `<span class="chat-bg">${working ? '<span class="chat-pulse"></span>' : ''}${esc(since ? `${tr('in the background since')} ${since}` : tr('in the background'))}</span>`
|
|
90
|
+
: it.lost ? `<span class="chat-bg">${esc(tr('no word from it for over 2 h'))}</span>` : '';
|
|
90
91
|
const what = it.pic ? `<button type="button" class="chat-step-t chat-pic" data-pic="${esc(it.pic)}" title="${esc(tr('Open the picture'))}">${ic('image', 12)}<span class="chat-pic-t">${esc(it.text)}</span></button>`
|
|
91
92
|
: `<span class="chat-step-t">${esc(it.text)}</span>`;
|
|
92
93
|
return `<div class="chat-step${it.error ? ' err' : ''}${it.running ? ' running' : ''}" title="${esc(it.text)}"><span class="chat-step-k">${esc(tr(STEP_KIND[it.tool] || 'Tool'))}</span>`
|
package/public/js/core.js
CHANGED
|
@@ -1480,6 +1480,11 @@ const I18N_RU = {
|
|
|
1480
1480
|
'only the owner of the board can do this': 'это может только владелец доски',
|
|
1481
1481
|
'a guest starts Claude only inside the board': 'гость запускает Claude только внутри доски',
|
|
1482
1482
|
'tmux did not answer in time — try again in a few seconds': 'tmux не ответил вовремя — повтори через несколько секунд',
|
|
1483
|
+
'no word from it for over 2 h': 'нет вестей больше 2 ч',
|
|
1484
|
+
'the CLAUDE.md of this project is within the norms now — there is nothing to fix': 'CLAUDE.md этого проекта уже в норме — чинить нечего',
|
|
1485
|
+
"tmux was updated while the board's terminals still run on the old one, and the new tmux cannot reach them. Close them with the command in Settings → Errors; the conversations are kept — «Continue» brings each one back.": 'tmux обновили, пока терминалы доски работают на старом, и новый tmux до них не достаёт. Закрой их командой из «Настроек → Ошибки»; разговоры сохранятся — «Продолжить» вернёт каждый.',
|
|
1486
|
+
'tmux was updated under the running terminals of the board — close them, the command is in Settings → Errors': 'tmux обновили под работающими терминалами доски — закрой их, команда в «Настройках → Ошибки»',
|
|
1487
|
+
'tmux was updated under the running terminals of the board': 'tmux обновили под работающими терминалами доски',
|
|
1483
1488
|
'the project has no CLAUDE.md of its own any more': 'у проекта больше нет своего CLAUDE.md',
|
|
1484
1489
|
'Fix': 'Починить',
|
|
1485
1490
|
'Open the task «Shrink CLAUDE.md» and start Claude on it — only while nobody works in the folder of the project': 'Завести задачу «Ужать CLAUDE.md» и сразу запустить на ней Claude — только пока в папке проекта никто не работает',
|
package/public/js/drawer.js
CHANGED
|
@@ -8,7 +8,7 @@ import { getSetting, openSettingsModal, plural, setClaudeView } from './settings
|
|
|
8
8
|
import { onPhone } from './phone-rules.js';
|
|
9
9
|
import { NARROW, closeTopLayer, currentProject, layersOpen, overlayLayer, popLayer, pushLayer, selectProject, styledAlert, styledConfirm, watchLayers } from './sidebar.js';
|
|
10
10
|
import { SHORT_MONTHS, askUsageNow, autoGrow, claudeInfoHTML, copyText, onClaudeInfo, launchInClaude, openSessionInClaude, refresh, relTime, renderTimeline, resumeOnBoard, taskPromptText } from './sse.js';
|
|
11
|
-
import { canRetry, changedFields, createCommentKeys, createTurns, createUnsentComments, createUnsentEdits, imageTooLarge, pullExternal, refusalLine, refusalText, refusedFields, resendDelay } from './unsaved.js';
|
|
11
|
+
import { canRetry, changedFields, createCommentKeys, createRefusedTyped, createTurns, createUnsentComments, createUnsentEdits, imageTooLarge, pullExternal, refusalLine, refusalText, refusedFields, resendDelay } from './unsaved.js';
|
|
12
12
|
import { renderAsks } from './asks.js';
|
|
13
13
|
import { stillOpen } from './review-rules.js';
|
|
14
14
|
import { wireTermCopy } from './term-copy.js';
|
|
@@ -2469,6 +2469,7 @@ export async function doAutosave() {
|
|
|
2469
2469
|
}
|
|
2470
2470
|
|
|
2471
2471
|
const unsentEdits = createUnsentEdits();
|
|
2472
|
+
const refusedTyped = createRefusedTyped();
|
|
2472
2473
|
const editInTurn = createTurns();
|
|
2473
2474
|
function queueTaskEdit(key, fields) {
|
|
2474
2475
|
unsentEdits.fail(key, fields);
|
|
@@ -2480,7 +2481,8 @@ async function resendTaskEdit(key) {
|
|
|
2480
2481
|
const fields = { ...queued };
|
|
2481
2482
|
const here = key === state.drawerKey && drawerShown() && drawerSnapshot && !drawerLocked;
|
|
2482
2483
|
if (here) { clearTimeout(autosaveTimer); autosaveTimer = null; }
|
|
2483
|
-
const
|
|
2484
|
+
const form = here ? formToSave() : null;
|
|
2485
|
+
const dirty = here ? refusedTyped.without(key, changedFields(form, drawerSnapshot), form) : {};
|
|
2484
2486
|
if (descConflicts.has(key)) { delete dirty.description; delete fields.description; }
|
|
2485
2487
|
const sent = { ...fields, ...dirty };
|
|
2486
2488
|
// base the last word won, and a `kb desc` made while offline was erased when the connection came back
|
|
@@ -2493,6 +2495,7 @@ async function resendTaskEdit(key) {
|
|
|
2493
2495
|
const back = refusalText(e).gone ? [] : refusedFields(e, sent);
|
|
2494
2496
|
if (back.length) { // the rest of the edit is not refused with the field: words typed offline still get there, open card or not
|
|
2495
2497
|
unsentEdits.supersede(key, back);
|
|
2498
|
+
refusedTyped.keep(key, back, sent);
|
|
2496
2499
|
if (here && state.drawerKey === key) { takeBackRefused(back, e); sayFieldRefused(key, e); }
|
|
2497
2500
|
else styledAlert(`${key}: ${refusalLine('The change is not saved', e, tr)}`);
|
|
2498
2501
|
const left = { ...dirty };
|
|
@@ -2516,7 +2519,7 @@ async function resendTaskEdit(key) {
|
|
|
2516
2519
|
unsentEdits.settle(key, fields);
|
|
2517
2520
|
unsentEdits.settle(key, dirty);
|
|
2518
2521
|
if (here && state.drawerKey === key && drawerSnapshot) Object.assign(drawerSnapshot, dirty);
|
|
2519
|
-
if (here && state.drawerKey === key && refusedNote !== key) showSaveNote(null);
|
|
2522
|
+
if (here && state.drawerKey === key && refusedNote !== key && !refusedTyped.has(key)) showSaveNote(null);
|
|
2520
2523
|
}
|
|
2521
2524
|
const unsentCreates = new Map();
|
|
2522
2525
|
function editRefusedText(e) {
|
|
@@ -2587,8 +2590,8 @@ async function doAutosaveOnce() {
|
|
|
2587
2590
|
const full = formToSave();
|
|
2588
2591
|
const { title = '', description, ...common } = full;
|
|
2589
2592
|
if (state.drawerKey) {
|
|
2590
|
-
const patch = drawerSnapshot ? changedFields(full, drawerSnapshot) : full;
|
|
2591
2593
|
const key = state.drawerKey;
|
|
2594
|
+
const patch = drawerSnapshot ? refusedTyped.without(key, changedFields(full, drawerSnapshot), full) : full;
|
|
2592
2595
|
if (descConflicts.has(key)) delete patch.description;
|
|
2593
2596
|
if (!Object.keys(patch).length) return;
|
|
2594
2597
|
const body = 'description' in patch && drawerSnapshot ? { ...patch, description_base: descBaseOf(key) } : patch;
|
|
@@ -2607,6 +2610,7 @@ async function doAutosaveOnce() {
|
|
|
2607
2610
|
if (canRetry(e)) queueTaskEdit(key, patch);
|
|
2608
2611
|
// says it); the card moved on to another task while the request went: a refusal is said with the task's key
|
|
2609
2612
|
if (!e?.offline && !r.gone) {
|
|
2613
|
+
refusedTyped.keep(key, back, patch);
|
|
2610
2614
|
if (key === state.drawerKey) takeBackRefused(back, e);
|
|
2611
2615
|
if (restQueued) queueTaskEdit(key, rest); // as data: the card may close before it is sent
|
|
2612
2616
|
if (key === state.drawerKey) { if (back.length) sayFieldRefused(key, e); else showSaveNote(editRefusedText(e)); }
|
|
@@ -2619,7 +2623,7 @@ async function doAutosaveOnce() {
|
|
|
2619
2623
|
if ('description' in body) descSent(key, patch.description, body.description_base);
|
|
2620
2624
|
}
|
|
2621
2625
|
if ('description' in body) queuedBase.delete(key); // the description got there: nothing waits from that base
|
|
2622
|
-
if (key === state.drawerKey) showSaveNote(null);
|
|
2626
|
+
if (key === state.drawerKey && !refusedTyped.has(key)) showSaveNote(null);
|
|
2623
2627
|
if (unsentEdits.supersede(key, Object.keys(patch))) dropUnsaved(`task:${key}`);
|
|
2624
2628
|
const { description: mine, ...rest } = patch;
|
|
2625
2629
|
if (drawerSnapshot && state.drawerKey === key) Object.assign(drawerSnapshot, saved?.description_conflict ? rest : patch);
|
|
@@ -7,6 +7,8 @@ export function fileState(f, norms) {
|
|
|
7
7
|
return 'ok';
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export const fixable = (main, norms) => ['heavy', 'long'].includes(fileState(main, norms));
|
|
11
|
+
|
|
10
12
|
const RANK = { heavy: 0, long: 1, ok: 2 };
|
|
11
13
|
|
|
12
14
|
/** The projects split for the section: with their own CLAUDE.md (worst first, then the most loaded at the start), without
|
package/public/js/health.js
CHANGED
|
@@ -6,7 +6,7 @@ import { selectProject, styledAlert, styledConfirm } from './sidebar.js';
|
|
|
6
6
|
import { refusalLine } from './unsaved.js';
|
|
7
7
|
import { openDrawer } from './drawer.js';
|
|
8
8
|
import { launchInClaude, taskPromptText } from './sse.js';
|
|
9
|
-
import { fileState, healthCounts, healthGroups, startTotal } from './health-rules.js';
|
|
9
|
+
import { fileState, fixable, healthCounts, healthGroups, startTotal } from './health-rules.js';
|
|
10
10
|
|
|
11
11
|
const FRESH_MS = 30000;
|
|
12
12
|
const DOCS_URL = 'https://code.claude.com/docs/en/memory';
|
|
@@ -51,7 +51,7 @@ function projectHTML(p, d) {
|
|
|
51
51
|
: `<span class="hl-size muted">${esc(tr('no CLAUDE.md of its own'))}</span>`;
|
|
52
52
|
const start = `<span class="hl-start${total.over ? ' hl-heavy' : ''}" title="${esc(tr('Everything a session reads when it starts in this folder, with your own files'))}">`
|
|
53
53
|
+ `${esc(tr('at the start'))}: ${esc(num(total.chars))} ${esc(plural(total.chars, 'character', 'characters'))}</span>`;
|
|
54
|
-
const fix =
|
|
54
|
+
const fix = p.main && fixable(p.main, norms)
|
|
55
55
|
? `<button type="button" class="btn-ghost hl-fix" data-slug="${esc(p.slug)}" title="${esc(tr('Open the task «Shrink CLAUDE.md» and start Claude on it — only while nobody works in the folder of the project'))}">${esc(tr('Fix'))}</button>`
|
|
56
56
|
: '';
|
|
57
57
|
const files = [...p.files, ...p.nested];
|
|
@@ -91,6 +91,7 @@ const FAIL_WORDS = {
|
|
|
91
91
|
manual: 'a task done by hand cannot be given to Claude',
|
|
92
92
|
running: 'a Claude of the board already works on these tasks',
|
|
93
93
|
'tmux-slow': 'tmux did not answer in time',
|
|
94
|
+
'tmux-broken': 'tmux was updated under the running terminals of the board',
|
|
94
95
|
};
|
|
95
96
|
/** The words for an automation's `error` — English, the Russian is in I18N_RU; null for a line that names no reason it knows. */
|
|
96
97
|
export const failWord = (error) => FAIL_WORDS[String(error ?? '').match(/^([a-z-]+): /)?.[1]] ?? null;
|
package/public/js/sse.js
CHANGED
|
@@ -649,6 +649,7 @@ const BOARD_WHY = {
|
|
|
649
649
|
'no-claude': 'Claude Code (the claude command) is not found on this computer — install it, or pick another way to start tasks in Settings.',
|
|
650
650
|
'too-long': "The prompt is too long for the board's terminal — start it in the Terminal or the Claude app (Settings), or shorten it.",
|
|
651
651
|
'tmux-slow': 'tmux did not answer in time, so the board cannot tell whether a Claude already works on these tasks — press ▶ again in a few seconds.',
|
|
652
|
+
'tmux-broken': "tmux was updated while the board's terminals still run on the old one, and the new tmux cannot reach them. Close them with the command in Settings → Errors; the conversations are kept — «Continue» brings each one back.",
|
|
652
653
|
};
|
|
653
654
|
/** Continue the board's session `session` of the task `key` in its terminal; true — started. */
|
|
654
655
|
export async function resumeOnBoard({ key, session, btn, again = false }) {
|
package/public/js/unsaved.js
CHANGED
|
@@ -73,6 +73,34 @@ export function refusedFields(e, sent) {
|
|
|
73
73
|
return 'status' in sent ? ['status'] : [];
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/* A refused title, description or priority stays in the form as typed — the card says it is not saved — but goes again only
|
|
77
|
+
once it is changed: left differing from the snapshot, every resend and autosave took it with the rest, the server refused
|
|
78
|
+
the whole PATCH again, and the resend called itself — two thousand requests in ten seconds while the card stayed open,
|
|
79
|
+
and the rest of the edit never got there. Labels and status go back to the server's instead (takeBackRefused in
|
|
80
|
+
drawer.js). Pure. */
|
|
81
|
+
export function createRefusedTyped() {
|
|
82
|
+
const byKey = new Map(); // task key → { field: the value the server refused }
|
|
83
|
+
return {
|
|
84
|
+
keep(key, fields, sent) {
|
|
85
|
+
const kept = byKey.get(key) || {};
|
|
86
|
+
for (const f of fields) if (f !== 'labels' && f !== 'status' && f in sent) kept[f] = sent[f];
|
|
87
|
+
if (Object.keys(kept).length) byKey.set(key, kept);
|
|
88
|
+
},
|
|
89
|
+
/** The edit without what the server refused and the form still holds unchanged; a field changed since goes again. */
|
|
90
|
+
without(key, patch, form) {
|
|
91
|
+
const kept = byKey.get(key);
|
|
92
|
+
if (!kept) return patch;
|
|
93
|
+
for (const f of Object.keys(kept)) {
|
|
94
|
+
if (form[f] !== kept[f]) delete kept[f];
|
|
95
|
+
else delete patch[f];
|
|
96
|
+
}
|
|
97
|
+
if (!Object.keys(kept).length) byKey.delete(key);
|
|
98
|
+
return patch;
|
|
99
|
+
},
|
|
100
|
+
has: (key) => byKey.has(key),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
76
104
|
export const RESEND_MS = 3000;
|
|
77
105
|
export const RESEND_MAX_MS = 60000;
|
|
78
106
|
/** How long to wait before the next try, `n` — how many tries in a row already failed. */
|
|
@@ -235,7 +263,9 @@ const KNOWN = [
|
|
|
235
263
|
[/^the terminal did not open/, 'the terminal did not open — copy the command instead; the details are in Settings → Errors'],
|
|
236
264
|
[/^a guest starts Claude inside the board only$/, 'a guest starts Claude only inside the board'],
|
|
237
265
|
[/^the project has no CLAUDE\.md of its own$/, 'the project has no CLAUDE.md of its own any more'],
|
|
266
|
+
[/^the CLAUDE\.md of this project is within the norms/, 'the CLAUDE.md of this project is within the norms now — there is nothing to fix'],
|
|
238
267
|
[/^tmux did not answer in time — try again$/, 'tmux did not answer in time — try again in a few seconds'],
|
|
268
|
+
[/^tmux was updated while the board's terminals still run on the old one/, 'tmux was updated under the running terminals of the board — close them, the command is in Settings → Errors'],
|
|
239
269
|
[/^unknown labels: /, 'this board has no such label'],
|
|
240
270
|
];
|
|
241
271
|
export function refusalText(e) {
|
package/src/auto-health.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { realpath } from 'node:fs/promises';
|
|
2
|
-
import { relative, isAbsolute } from 'node:path';
|
|
2
|
+
import { relative, isAbsolute, sep } from 'node:path';
|
|
3
3
|
import { PORT } from './config.js';
|
|
4
4
|
import { db, kvGet, kvSet, logError, uiLang } from './db.js';
|
|
5
5
|
import { NORMS, healthView } from './health.js';
|
|
6
6
|
import { readRegistry, stateOf } from './claude-live.js';
|
|
7
7
|
import { noclaudeFilter } from './task-shape.js';
|
|
8
|
+
import { fixable } from '../public/js/health-rules.js';
|
|
8
9
|
|
|
9
10
|
export const HEALTH_SWITCH = 'automations.health';
|
|
10
11
|
const LAST_KEY = 'automations.health.last';
|
|
@@ -14,11 +15,14 @@ const TITLES = { ru: 'Ужать CLAUDE.md', en: 'Shrink CLAUDE.md' };
|
|
|
14
15
|
export const HEALTH_DOCS = 'https://code.claude.com/docs/en/memory';
|
|
15
16
|
const fmt = (n) => n.toLocaleString('ru-RU');
|
|
16
17
|
const WORDS = {
|
|
17
|
-
ru: (file, chars, lines, by = 'автоматизация') => `${file}: ${fmt(chars)} знаков и ${fmt(lines)} строк при норме ${fmt(NORMS.file_chars)} знаков и ${fmt(NORMS.lines)} строк. Claude Code предупреждает о большом файле на старте и хуже следует длинным инструкциям.\n\nОптимизировать по документации Claude Code: ${HEALTH_DOCS}\n- в файле оставить общее для любой работы в проекте;\n- подробности по темам — в правила .claude/rules/<тема>.md с paths: (грузятся, только когда Claude работает с этими файлами) или в CLAUDE.md подпапки;\n- @-импорты место не экономят: файл грузится вместе с CLAUDE.md;\n- устаревшее переписать, историю починок не копить — она в git log.\n\nЗадачу завела доска: раздел «Здоровье», ${by}.`,
|
|
18
|
+
ru: (file, chars, lines, by = 'автоматизация') => `${file}: ${fmt(chars)} ${WORDS.ruN(chars, ['знак', 'знака', 'знаков'])} и ${fmt(lines)} ${WORDS.ruN(lines, ['строка', 'строки', 'строк'])} при норме ${fmt(NORMS.file_chars)} знаков и ${fmt(NORMS.lines)} строк. Claude Code предупреждает о большом файле на старте и хуже следует длинным инструкциям.\n\nОптимизировать по документации Claude Code: ${HEALTH_DOCS}\n- в файле оставить общее для любой работы в проекте;\n- подробности по темам — в правила .claude/rules/<тема>.md с paths: (грузятся, только когда Claude работает с этими файлами) или в CLAUDE.md подпапки;\n- @-импорты место не экономят: файл грузится вместе с CLAUDE.md;\n- устаревшее переписать, историю починок не копить — она в git log.\n\nЗадачу завела доска: раздел «Здоровье», ${by}.`,
|
|
18
19
|
en: (file, chars, lines, by = 'an automation') => `${file}: ${fmt(chars)} characters and ${fmt(lines)} lines, the norm is ${fmt(NORMS.file_chars)} characters and ${fmt(NORMS.lines)} lines. Claude Code warns about a large file at the start and follows long instructions worse.\n\nOptimize it by the docs of Claude Code: ${HEALTH_DOCS}\n- keep in the file what any work in the project needs;\n- the details of a topic go to rules .claude/rules/<topic>.md with paths: (they load only when Claude works with those files) or to the CLAUDE.md of a subfolder;\n- @-imports save no room: the file loads together with the CLAUDE.md;\n- rewrite what is out of date, keep no history of fixes — it is in git log.\n\nThe board opened this task: the Health section, ${by}.`,
|
|
19
20
|
button: { ru: 'кнопка «Починить»', en: 'the button «Fix»' }, // who opened it, for the words above
|
|
21
|
+
ruN: (n, [one, few, many]) => { const a = n % 10, b = n % 100; return a === 1 && b !== 11 ? one : a >= 2 && a <= 4 && (b < 12 || b > 14) ? few : many; },
|
|
20
22
|
};
|
|
21
23
|
|
|
24
|
+
export const ruCount = WORDS.ruN; // for the test of the forms
|
|
25
|
+
|
|
22
26
|
/** The projects whose own CLAUDE.md is past the norm. Pure. */
|
|
23
27
|
export const heavyProjects = (view) => (view.projects || []).filter((p) => p.main && p.main.chars > view.norms.file_chars);
|
|
24
28
|
|
|
@@ -44,7 +48,7 @@ export async function tickHealth(app, now) {
|
|
|
44
48
|
const WORK = ['prep', 'doing', 'ready', 'deploy'];
|
|
45
49
|
const within = (folder, cwd) => {
|
|
46
50
|
const rel = relative(folder, cwd);
|
|
47
|
-
return rel === '' || (!rel.startsWith(
|
|
51
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
48
52
|
};
|
|
49
53
|
/** The sessions at work in `folder` among the registry's entries ({cwd, alive, status}): their count. Pure. */
|
|
50
54
|
export function claudesAt(folder, entries) {
|
|
@@ -64,16 +68,26 @@ export async function fixHolds(project, registry = readRegistry) {
|
|
|
64
68
|
return { sessions: claudesAt(folder, entries), keys };
|
|
65
69
|
}
|
|
66
70
|
|
|
71
|
+
const fixing = new Map(); // slug → the press in flight
|
|
67
72
|
/** The task of «Fix» for a project: one still open of the same title (backlog → To do), else a new one in To do.
|
|
68
73
|
{key, created} or {code, error, reason, …} of a refusal. */
|
|
69
|
-
export
|
|
70
|
-
const
|
|
74
|
+
export function healthFixTask(app, slug, registry) {
|
|
75
|
+
const key = String(slug || '');
|
|
76
|
+
const run = (fixing.get(key) || Promise.resolve()).then(() => fixOnce(app, key, registry));
|
|
77
|
+
const done = run.catch(() => {});
|
|
78
|
+
fixing.set(key, done);
|
|
79
|
+
done.then(() => { if (fixing.get(key) === done) fixing.delete(key); });
|
|
80
|
+
return run;
|
|
81
|
+
}
|
|
82
|
+
async function fixOnce(app, slug, registry) {
|
|
83
|
+
const p = db.prepare('SELECT id, slug, name, path, category, archived FROM projects WHERE slug = ?').get(slug);
|
|
71
84
|
if (!p) return { code: 404, error: 'project not found' };
|
|
72
85
|
if (p.archived) return { code: 400, error: 'the project is archived' };
|
|
73
86
|
const view = await healthView([p]);
|
|
74
87
|
const h = view.projects[0];
|
|
75
88
|
if (!p.path || h.folder === null || h.missing) return { code: 409, reason: 'no-folder', error: 'the project has no folder on this computer — set it in the project' };
|
|
76
89
|
if (!h.main) return { code: 409, reason: 'no-claude-md', error: 'the project has no CLAUDE.md of its own' };
|
|
90
|
+
if (!fixable(h.main, view.norms)) return { code: 409, reason: 'within-norm', error: 'the CLAUDE.md of this project is within the norms — there is nothing to fix' };
|
|
77
91
|
const held = await fixHolds(p, registry);
|
|
78
92
|
if (held.sessions || held.keys.length) return { code: 409, reason: 'busy', error: 'Claude works in the folder of this project — fix it once the work there is over', ...held };
|
|
79
93
|
const open = db.prepare(`SELECT p.prefix || '-' || t.task_no AS key, t.status FROM tasks t JOIN projects p ON p.id = t.project_id
|
package/src/auto-resume.js
CHANGED
|
@@ -26,10 +26,10 @@ const own = { host: `127.0.0.1:${PORT}` };
|
|
|
26
26
|
const WORDS = {
|
|
27
27
|
ru: { resumed: 'Доска продолжила прерванную сессию сама.', told: 'Лимит сброшен: доска попросила Claude продолжить.', go: 'Доска: лимит подписки сбросился. Продолжай с того места, где остановился.', goOn: 'Доска: сессия была прервана. Продолжай с того места, где остановился.',
|
|
28
28
|
upResume: 'Доска подняла прерванную сессию, но попросить Claude продолжить не смогла: он ждёт у приглашения.', upLimit: 'Лимит сброшен, но попросить Claude продолжить доска не смогла: он ждёт у приглашения.',
|
|
29
|
-
why: { tmux: 'tmux не отвечал', field: 'в поле Claude что-то набрано', err: 'слова не дошли до терминала'
|
|
29
|
+
why: { tmux: 'tmux не отвечал', field: 'в поле Claude что-то набрано', err: 'слова не дошли до терминала' } },
|
|
30
30
|
en: { resumed: 'The board went on with the interrupted session by itself.', told: 'The limit has reset: the board told Claude to go on.', go: 'The board: the subscription limit has reset. Go on from where you stopped.', goOn: 'The board: the session was interrupted. Go on from where you stopped.',
|
|
31
31
|
upResume: 'The board brought the interrupted session back but could not tell Claude to go on: it waits at its prompt.', upLimit: 'The limit has reset, but the board could not tell Claude to go on: it waits at its prompt.',
|
|
32
|
-
why: { tmux: 'tmux did not answer', field: "something is typed in Claude's field", err: 'the words did not reach the terminal'
|
|
32
|
+
why: { tmux: 'tmux did not answer', field: "something is typed in Claude's field", err: 'the words did not reach the terminal' } },
|
|
33
33
|
};
|
|
34
34
|
const say = () => WORDS[uiLang() === 'en' ? 'en' : 'ru'];
|
|
35
35
|
|
|
@@ -115,7 +115,7 @@ const forget = (sid) => { stopped.delete(sid); keepStopped(); };
|
|
|
115
115
|
function giveUp(sid, was) {
|
|
116
116
|
forget(sid);
|
|
117
117
|
const t = resolveTask(was.key);
|
|
118
|
-
const why = was.held_by ? say().why[was.held_by
|
|
118
|
+
const why = was.held_by ? say().why[was.held_by] ?? '' : '';
|
|
119
119
|
logError('server', 'automations', `gave up telling ${was.key} to go on (${was.why}): ${was.held_by || 'no look got that far'}`, sid);
|
|
120
120
|
if (t) boardNote(t.id, `${was.why === 'limit' ? say().upLimit : say().upResume}${why ? ` (${why})` : ''}`);
|
|
121
121
|
}
|
|
@@ -161,7 +161,8 @@ export async function tickResume(app, now, { waiting = new Set(), limited = fals
|
|
|
161
161
|
if (!was) continue;
|
|
162
162
|
const late = now - was.at > SAY_WAIT_MS;
|
|
163
163
|
// the words of a resume belong to the minutes after it: a day later they would start work nobody expects
|
|
164
|
-
|
|
164
|
+
// «it waits at its prompt» is said of a Claude at its prompt only: one at work (taken up by a person, a long turn) or gone
|
|
165
|
+
if (was.why === 'resume' && late) { if (r.claude === 'idle') giveUp(r.session, was); else forget(r.session); continue; }
|
|
165
166
|
if (r.claude === 'limit') {
|
|
166
167
|
// too early it stops at the limit again; the chain of tries holds how many times
|
|
167
168
|
const at = resetAfter(r.resets, was.at - LINE_BACK_MS);
|
package/src/chat-feed.js
CHANGED
|
@@ -4,7 +4,7 @@ import { watch } from 'node:fs';
|
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
5
|
import { emit } from './bus.js';
|
|
6
6
|
import { helpersOf, humanText, ownWords, readTail, resultText, toolSummary } from './work-feed.js';
|
|
7
|
-
import { inBackground, STOPS, wentBackground } from './own-wait.js';
|
|
7
|
+
import { inBackground, OWN_WAIT_MAX_MS, STOPS, wentBackground } from './own-wait.js';
|
|
8
8
|
|
|
9
9
|
export const CHAT_TEXT_MAX = 20000; // a message longer than this is cut, with "…"
|
|
10
10
|
export const TURNS_DEFAULT = 15; // the newest turns a chat gets; "earlier" asks for more
|
|
@@ -571,15 +571,22 @@ export async function transcriptChat(path, { turns = TURNS_DEFAULT } = {}) {
|
|
|
571
571
|
const n = Math.min(TURNS_MAX, Math.max(1, turns));
|
|
572
572
|
const shown = all.slice(-n);
|
|
573
573
|
const isHelper = (it) => it.kind === 'tool' && HELPER_TOOLS.has(it.tool);
|
|
574
|
-
// One that ended counts only for a while: reading every helper of a long session to see if it woke costs too much
|
|
574
|
+
// One that ended counts only for a while: reading every helper of a long session to see if it woke costs too much.
|
|
575
|
+
// background…» by it (own-wait.js) over an empty list
|
|
575
576
|
const clock = Date.now();
|
|
576
|
-
const
|
|
577
|
-
const
|
|
577
|
+
const ends = new Map(); // id of work running in the background → the end of the turn that started it
|
|
578
|
+
const turnEnd = (t) => t.items.reduce((m, it) => Math.max(m, it.at), t.at);
|
|
579
|
+
for (const t of all) for (const it of t.items) if (it.kind === 'tool' && it.running) ends.set(it.id, turnEnd(t));
|
|
580
|
+
const older = all.slice(0, Math.max(0, all.length - n)).flatMap((t) => t.items.filter((it) => it.kind === 'tool'
|
|
581
|
+
&& (it.running || (isHelper(it) && clock - (it.ended ?? 0) < WAKE_LOOK_MS))));
|
|
582
|
+
const wanted = new Set([...shown.flatMap((t) => t.items.filter(isHelper)), ...older.filter(isHelper)].map((it) => it.id));
|
|
578
583
|
let helpers = new Map();
|
|
579
584
|
if (wanted.size) try { helpers = await helpersOf(path, wanted); } catch { /* a helper we cannot read is left out */ }
|
|
580
585
|
const fresh = (id) => clock - (helpers.get(id)?.last ?? 0) < HELPER_LIVE_MS;
|
|
586
|
+
const lost = (id) => ends.has(id) && clock - Math.max(ends.get(id), helpers.get(id)?.last ?? 0) >= OWN_WAIT_MAX_MS;
|
|
581
587
|
const stepOut = ({ id, steps, asked, picture, markdown, before, resumed, ended, ...it }) => {
|
|
582
588
|
const h = helpers.get(id);
|
|
589
|
+
if (it.running && lost(id)) { it.running = false; it.lost = true; }
|
|
583
590
|
// too). While it writes: one that woke and went with its Claude leaves no second end
|
|
584
591
|
const woke = !it.running && ended !== undefined && h?.last > ended && fresh(id);
|
|
585
592
|
if (woke) { it.running = true; delete it.ms; }
|
|
@@ -590,8 +597,8 @@ export async function transcriptChat(path, { turns = TURNS_DEFAULT } = {}) {
|
|
|
590
597
|
...(markdown && !it.error && it.ms !== undefined ? { doc: id } : {}) };
|
|
591
598
|
};
|
|
592
599
|
const out = shown.map((t) => ({ ...t, at: new Date(t.at).toISOString(), items: t.items.map(stepOut) }));
|
|
593
|
-
//
|
|
594
|
-
const going = older.filter((it) => fresh(it.id)).map(stepOut).filter((it) => it.running);
|
|
600
|
+
// a helper that wrote nothing for a while is not counted: a Claude gone with the machine leaves no end of its helpers.
|
|
601
|
+
const going = older.filter((it) => (isHelper(it) && helpers.has(it.id) ? fresh(it.id) : !isHelper(it))).map(stepOut).filter((it) => it.running);
|
|
595
602
|
const waiting = c.waits.map((w) => ({ text: w.text, at: new Date(w.at).toISOString() }));
|
|
596
603
|
return { turns: out, ...(going.length ? { going } : {}), earlier_n: Math.max(0, all.length - n), model: c.model || null, waiting };
|
|
597
604
|
}
|
package/src/launch.js
CHANGED
|
@@ -5,7 +5,7 @@ import { delimiter, join } from 'node:path';
|
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { HOST_SESSION_RE, SESSION_ID_RE } from './claude-sessions.js';
|
|
7
7
|
import { claudeBin } from './config.js';
|
|
8
|
-
import { onPath } from './bin-path.js';
|
|
8
|
+
import { bin as fullPath, onPath } from './bin-path.js'; // `bin` is the claude a launch is given
|
|
9
9
|
|
|
10
10
|
/** What the Claude app takes in `?q=` (WA in its URL handler); longer text is refused, not cut. */
|
|
11
11
|
export const PROMPT_MAX = 14336;
|
|
@@ -100,7 +100,7 @@ export function terminalCommand({ text, resume, folder, session, url, effort, bi
|
|
|
100
100
|
export function launchSpec({ mode, text, folder, resume, host, session, url, effort, bin }, platform = process.platform) {
|
|
101
101
|
if (mode === 'desktop') {
|
|
102
102
|
if (platform !== 'darwin') throw Object.assign(new Error('the Claude app is opened this way only on macOS'), { statusCode: 501 });
|
|
103
|
-
return { cmd: 'open', args: [host ? continueUrl(host) : desktopUrl(text, folder)] };
|
|
103
|
+
return { cmd: fullPath('open'), args: [host ? continueUrl(host) : desktopUrl(text, folder)] };
|
|
104
104
|
}
|
|
105
105
|
return windowSpec(terminalCommand({ text, resume, folder, session, url, effort, bin }), platform, 'the prompt');
|
|
106
106
|
}
|
|
@@ -109,9 +109,9 @@ export function launchSpec({ mode, text, folder, resume, host, session, url, eff
|
|
|
109
109
|
window keeps (Linux). */
|
|
110
110
|
function windowSpec(line, platform, what, then = '; ') {
|
|
111
111
|
if (platform === 'darwin') {
|
|
112
|
-
return { cmd: 'osascript', args: ['-e', `tell application "Terminal" to do script ${appleString(line)}`, '-e', 'tell application "Terminal" to activate'] };
|
|
112
|
+
return { cmd: fullPath('osascript'), args: ['-e', `tell application "Terminal" to do script ${appleString(line)}`, '-e', 'tell application "Terminal" to activate'] };
|
|
113
113
|
}
|
|
114
|
-
if (platform === 'linux') return { cmd: 'x-terminal-emulator', args: ['-e', 'bash', '-lc', `${line}${then}exec bash`] };
|
|
114
|
+
if (platform === 'linux') return { cmd: fullPath('x-terminal-emulator'), args: ['-e', 'bash', '-lc', `${line}${then}exec bash`] };
|
|
115
115
|
throw Object.assign(new Error(`no terminal to open on this system — copy ${what} instead`), { statusCode: 501 });
|
|
116
116
|
}
|
|
117
117
|
|
package/src/phantom.js
CHANGED
|
@@ -158,6 +158,16 @@ export async function runPhantom(spec) {
|
|
|
158
158
|
|
|
159
159
|
/** The refusal of a request about a terminal tmux did not answer about (reason `tmux-slow`) — said alike everywhere. */
|
|
160
160
|
export const TMUX_SLOW = 'tmux did not answer in time — try again';
|
|
161
|
+
const TMUX_BROKEN = /protocol version mismatch/i;
|
|
162
|
+
export const tmuxBrokenWords = () => `tmux was updated while the board's terminals still run on the old one, and the new tmux cannot reach them — close them in Terminal with \`tmux -L ${tmuxSocket()} kill-server\` (the conversations are kept: «Continue» brings each one back)`;
|
|
163
|
+
let brokenSaid = 0;
|
|
164
|
+
function unknownPane(text) {
|
|
165
|
+
if (!TMUX_BROKEN.test(text)) return { dead: false, unknown: true };
|
|
166
|
+
if (Date.now() - brokenSaid > 3600e3) { brokenSaid = Date.now(); logError('server', 'tmux', tmuxBrokenWords(), text); }
|
|
167
|
+
return { dead: false, unknown: true, broken: true };
|
|
168
|
+
}
|
|
169
|
+
/** The refusal of a request about such a terminal: {error, reason} — `tmux-broken` for good, `tmux-slow` for this once. */
|
|
170
|
+
export const tmuxRefusal = (term) => (term?.broken ? { error: tmuxBrokenWords(), reason: 'tmux-broken' } : { error: TMUX_SLOW, reason: 'tmux-slow' });
|
|
161
171
|
const pane = (sid) => `=${sessionName(sid)}:`; // `=` — this very session, not one whose name starts so
|
|
162
172
|
|
|
163
173
|
const STATE_FORMAT = '#{pane_dead}|#{pane_dead_status}|#{window_width}|#{pane_pid}|#{pid}|#{pane_dead_signal}';
|
|
@@ -197,7 +207,7 @@ const NO_PANE = /can't find (session|window|pane)|no current target|no server ru
|
|
|
197
207
|
export async function phantomPane(sid) {
|
|
198
208
|
if (!SESSION_ID_RE.test(sid)) return null;
|
|
199
209
|
const r = await tmux(['list-panes', '-t', pane(sid), '-F', STATE_FORMAT]);
|
|
200
|
-
if (r.err) return NO_PANE.test(`${r.text} ${r.err.message}`) || SERVER_GONE.test(r.text) ? null : {
|
|
210
|
+
if (r.err) return NO_PANE.test(`${r.text} ${r.err.message}`) || SERVER_GONE.test(r.text) ? null : unknownPane(`${r.text} ${r.err.message}`);
|
|
201
211
|
return nudgeLostExit(sid, paneState(r.out), () => readState(sid));
|
|
202
212
|
}
|
|
203
213
|
|
|
@@ -254,7 +264,7 @@ export async function phantomView(sid, lines = SCREEN_LINES) {
|
|
|
254
264
|
const r = await tmux(['list-panes', '-t', pane(sid), '-F', STATE_FORMAT], mark,
|
|
255
265
|
['capture-pane', '-p', '-S', `-${lines}`, '-t', pane(sid)], mark,
|
|
256
266
|
['capture-pane', '-p', '-J', '-S', `-${lines}`, '-t', pane(sid)]);
|
|
257
|
-
if (r.err) return NO_PANE.test(`${r.text} ${r.err.message}`) || SERVER_GONE.test(r.text) ? null : {
|
|
267
|
+
if (r.err) return NO_PANE.test(`${r.text} ${r.err.message}`) || SERVER_GONE.test(r.text) ? null : unknownPane(`${r.text} ${r.err.message}`);
|
|
258
268
|
const [state, screen, joined] = r.out.split(`${cut}\n`);
|
|
259
269
|
if (joined === undefined) return null;
|
|
260
270
|
const st = await nudgeLostExit(sid, paneState(state), () => readState(sid));
|
|
@@ -610,13 +620,20 @@ export function paceOnScreen(screen) {
|
|
|
610
620
|
|
|
611
621
|
const LIMIT_HIT = /\b(?:usage|5-hour|weekly|session|opus|sonnet|plan) limit reached|\bhit your (?:[\w-]+ )?limit/i;
|
|
612
622
|
// joined with a space, and a line torn inside the time («12:» / «50am», «12:50a» / «m» at a width of 45) read «12» or «12:50»
|
|
613
|
-
//
|
|
614
|
-
|
|
623
|
+
// anywhere in the words and the date as well — «resets Oct 1|9 at 10am» read October 1 at 09:00, «Oc|t 19…» and «rese|ts
|
|
624
|
+
// 12am» read nothing. Each word is spelt with a space allowed between its letters (`spaced`), and put back without them.
|
|
625
|
+
const spaced = (w) => w.split('').join(' ?');
|
|
626
|
+
const LIMIT_RESETS = new RegExp(`${spaced('reset')}(?: ?s)?(?: ${spaced('at')})? (?:([a-z](?: ?[a-z]){2,8}) (\\d(?: ?\\d)?)( ?,)? (${spaced('at')} )?)?`
|
|
627
|
+
+ '(\\d(?: ?\\d)?(?: ?: ?\\d ?\\d)?(?: ?[ap] ?m\\b)?)', 'i');
|
|
628
|
+
const unspaced = (s) => s.replace(/\s+/g, '');
|
|
615
629
|
export function limitOnScreen(screen) {
|
|
616
630
|
const under = underPromptBox(screen);
|
|
617
631
|
if (!under || !LIMIT_HIT.test(under)) return null;
|
|
618
632
|
const m = LIMIT_RESETS.exec(under);
|
|
619
|
-
|
|
633
|
+
if (!m) return { resets: null };
|
|
634
|
+
const [, month, day, comma, at, time] = m;
|
|
635
|
+
const date = month ? `${unspaced(month)} ${unspaced(day)}${comma ? ',' : ''} ${at ? 'at ' : ''}` : '';
|
|
636
|
+
return { resets: `${date}${unspaced(time)}` };
|
|
620
637
|
}
|
|
621
638
|
export async function boardLook() {
|
|
622
639
|
const none = { live: [], questions: [], background: [], over: [], limited: [] };
|
|
@@ -679,7 +696,7 @@ export async function taskTerminal(taskId, { screen = false } = {}) {
|
|
|
679
696
|
WHERE s.task_id = ? ORDER BY s.last_seen DESC, s.first_seen DESC LIMIT 5`).all(taskId);
|
|
680
697
|
for (const r of rows) {
|
|
681
698
|
const st = screen ? await phantomView(r.session_id) : await phantomPane(r.session_id);
|
|
682
|
-
if (st?.unknown) return { session: r.session_id, unknown: true };
|
|
699
|
+
if (st?.unknown) return { session: r.session_id, unknown: true, ...(st.broken ? { broken: true } : {}) };
|
|
683
700
|
if (st) {
|
|
684
701
|
return { session: r.session_id, dead: st.dead, ...(st.dead ? { exit: st.exit, interrupted: endedBySignal(st) } : {}), cols: st.cols,
|
|
685
702
|
...(screen ? { screen: st.screen, wraps: st.wraps, links: st.links } : {}),
|
package/src/routes/chat.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
import { resolveTask, taskAttachmentPaths } from './tasks.js';
|
|
3
|
-
import { CHAT_COLS, REPLY_MAX,
|
|
3
|
+
import { CHAT_COLS, REPLY_MAX, claudeAtPrompt, paceOnScreen, phantomResize, phantomScreen, phantomSend, taskTerminal, tmuxRefusal, watched } from '../phantom.js';
|
|
4
4
|
import { SESSION_ID_RE, taskSessions, transcriptNow } from '../claude-sessions.js';
|
|
5
5
|
import { DOC_MAX, PICTURE_MAX, PICTURE_TYPES, SHOT_RE, TURNS_DEFAULT, TURNS_MAX, chatDoc, chatHash, chatPicture, chatShot, isMarkdown, pictureKindOf, saidCommand, transcriptChat, watchChat } from '../chat-feed.js';
|
|
6
6
|
import { createReadStream } from 'node:fs';
|
|
@@ -88,7 +88,7 @@ export default async function chatRoutes(app) {
|
|
|
88
88
|
return t;
|
|
89
89
|
};
|
|
90
90
|
const noClaude = (reply, term) => (term?.unknown
|
|
91
|
-
? reply.code(409).send(
|
|
91
|
+
? reply.code(409).send(tmuxRefusal(term))
|
|
92
92
|
: reply.code(409).send({ error: 'no Claude of the board works on this task — the chat can only be read', reason: 'gone' }));
|
|
93
93
|
|
|
94
94
|
app.get('/api/tasks/:id/chat', async (req, reply) => {
|
package/src/routes/projects.js
CHANGED
|
@@ -292,7 +292,7 @@ export default async function projectRoutes(app) {
|
|
|
292
292
|
if (!spec) return reply.code(501).send({ error: `no folder dialog on ${process.platform}` });
|
|
293
293
|
const [cmd, args] = spec;
|
|
294
294
|
const res = await new Promise((done) => {
|
|
295
|
-
execFile(cmd, args, { timeout: 180000 }, (err, out, errOut) => done({ err, out: String(out || '').trim(), errOut: String(errOut || '') }));
|
|
295
|
+
execFile(bin(cmd), args, { timeout: 180000 }, (err, out, errOut) => done({ err, out: String(out || '').trim(), errOut: String(errOut || '') }));
|
|
296
296
|
});
|
|
297
297
|
if (res.err) {
|
|
298
298
|
if (!res.out) return { path: null, cancelled: true };
|
package/src/routes/system.js
CHANGED
|
@@ -13,7 +13,7 @@ import { listBackups, backupNow, backupPath, backupState, ATTACH_MIRROR } from '
|
|
|
13
13
|
import { inspectBackup, startPreview, stopPreview, currentPreview, UPLOAD_PREFIX } from '../restore.js';
|
|
14
14
|
import { langOverride, ghOwnerEnv, ghRepoEnv, launchDryRun, PORT } from '../config.js';
|
|
15
15
|
import { claudeWord, launchOptions, launchProblem, launchSpec, runLaunch } from '../launch.js';
|
|
16
|
-
import { TMUX_COMMAND_MAX,
|
|
16
|
+
import { TMUX_COMMAND_MAX, boardProblem, claudeAtPrompt, folderTrusted, lookedLately, markTaskless, phantomAvailable, phantomPane, phantomSpec, phantomStop, runPhantom, taskTerminal, tmuxBin, tmuxCommandBytes, tmuxRefusal } from '../phantom.js';
|
|
17
17
|
import { ADOPT_TOKEN, freshTerminalSessions, isBoardSession, markBoardSession, markResumed, prepHolder, tieLaunched } from '../claude-sessions.js';
|
|
18
18
|
import { CHECK_LABEL, OPEN_CHECK, doneRules, isCheck, leadCheck } from '../review-check.js';
|
|
19
19
|
import { liveTick, readRegistry } from '../claude-live.js';
|
|
@@ -61,12 +61,12 @@ export function isNewer(candidate, current) {
|
|
|
61
61
|
|
|
62
62
|
export function updatePlan({ packaged, name, root }) {
|
|
63
63
|
if (!packaged) {
|
|
64
|
-
return { how: 'git', cmd: 'git', args: ['pull', '--ff-only'], then: { cmd: 'npm', args: ['install', '--no-fund', '--no-audit'] } };
|
|
64
|
+
return { how: 'git', cmd: bin('git'), args: ['pull', '--ff-only'], then: { cmd: bin('npm'), args: ['install', '--no-fund', '--no-audit'] } };
|
|
65
65
|
}
|
|
66
66
|
const args = ['install', '-g', `${name}@latest`];
|
|
67
67
|
const prefix = globalPrefixOf(root, name);
|
|
68
68
|
if (prefix) args.push('--prefix', prefix);
|
|
69
|
-
return { how: 'npm', cmd: 'npm', args };
|
|
69
|
+
return { how: 'npm', cmd: bin('npm'), args };
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
export async function runUpdate(plan, exec) {
|
|
@@ -596,7 +596,7 @@ export default async function systemRoutes(app) {
|
|
|
596
596
|
keys.forEach((k) => launching.add(k));
|
|
597
597
|
try {
|
|
598
598
|
const running = [];
|
|
599
|
-
const unsure = [];
|
|
599
|
+
const unsure = []; let broken = false;
|
|
600
600
|
for (const t of tasks) {
|
|
601
601
|
let term = await taskTerminal(t.id);
|
|
602
602
|
// returned the task, the last round) makes room, or the fix waited for its terminal for up to six hours. Only the fix of
|
|
@@ -605,9 +605,10 @@ export default async function systemRoutes(app) {
|
|
|
605
605
|
await phantomStop(term.session);
|
|
606
606
|
term = await taskTerminal(t.id);
|
|
607
607
|
}
|
|
608
|
-
if (term?.unknown) unsure.push(t.key); else if (term && !term.dead) running.push(t.key);
|
|
608
|
+
if (term?.unknown) { unsure.push(t.key); broken ||= Boolean(term.broken); } else if (term && !term.dead) running.push(t.key);
|
|
609
609
|
}
|
|
610
610
|
if (running.length) return reply.code(409).send({ error: `Claude of the board already works on ${running.join(', ')}`, running });
|
|
611
|
+
if (unsure.length && broken) return reply.code(409).send({ ...tmuxRefusal({ broken }), keys: unsure });
|
|
611
612
|
if (unsure.length) {
|
|
612
613
|
return reply.code(409).send({ error: `tmux did not answer in time: whether a Claude of the board works on ${unsure.join(', ')} is not known — try again`, reason: 'tmux-slow', keys: unsure });
|
|
613
614
|
}
|
|
@@ -661,7 +662,7 @@ export default async function systemRoutes(app) {
|
|
|
661
662
|
}
|
|
662
663
|
async function resumeSession(b, p, tasks, reply, sid, keys) {
|
|
663
664
|
const pane = await phantomPane(sid);
|
|
664
|
-
if (pane?.unknown) return reply.code(409).send({
|
|
665
|
+
if (pane?.unknown) return reply.code(409).send({ ...tmuxRefusal(pane), keys });
|
|
665
666
|
if (pane && !pane.dead) return reply.code(409).send({ error: `Claude of the board already works on ${keys.join(', ')}`, running: keys });
|
|
666
667
|
const name = keys.length > 1 ? `${keys[0]} +${keys.length - 1}` : keys[0];
|
|
667
668
|
const spec = phantomSpec({ sid, name, folder: p.path, permission: b.permission, port: PORT, allow: doneRules(tasks), effort: b.effort, resume: true });
|
package/src/routes/tasks.js
CHANGED
|
@@ -10,7 +10,7 @@ import { resolveProject } from './projects.js';
|
|
|
10
10
|
import { kick } from '../sync/worker.js';
|
|
11
11
|
import { LABELS } from '../sync/github.js';
|
|
12
12
|
import { repoHttpsBase } from '../repo-base.js';
|
|
13
|
-
import { STAT_FORMAT, mirrorName, statCommit, wordsOf } from '../shipped.js';
|
|
13
|
+
import { STAT_FORMAT, mirrorName, namesTask, statCommit, wordsOf } from '../shipped.js';
|
|
14
14
|
import { isManual, listShape, noclaudeFilter, snapshot, taskEvent } from '../task-shape.js';
|
|
15
15
|
import { requestSession, requestHostSession, bindSession, taskSessions, mayReprep, unclaimedLaunch, claimLaunch, markLaunch } from '../claude-sessions.js';
|
|
16
16
|
import { taskFeed } from '../work-feed.js';
|
|
@@ -19,7 +19,7 @@ import { currentWorkProjects, liveTasks, terminalQuestions } from '../claude-liv
|
|
|
19
19
|
import { activeBoardSessions, doneRuns, nowRuns } from '../claude-now.js';
|
|
20
20
|
import { closeFinishedTerminals, taskTerminal } from '../phantom.js';
|
|
21
21
|
import { CHECK_LABEL, OPEN_CHECK, isCheck, leadCheck, openCheck, secondCheck } from '../review-check.js';
|
|
22
|
-
import { treePrint } from '../tree-print.js';
|
|
22
|
+
import { treePrint, turnCommits } from '../tree-print.js';
|
|
23
23
|
import { bin } from '../bin-path.js';
|
|
24
24
|
|
|
25
25
|
const ATTACH_DIR = join(DATA_DIR, 'attachments');
|
|
@@ -65,6 +65,15 @@ async function commitUrlFor(task) {
|
|
|
65
65
|
|
|
66
66
|
// and not the newest commit of the project, whoever made it
|
|
67
67
|
const STAT_SCAN = 300;
|
|
68
|
+
/** The words that name `task` in a commit of the repository at `path` (wordsOf) and the prefixes of the board. */
|
|
69
|
+
async function taskWords(task, path) {
|
|
70
|
+
const issueNo = task.gh_issue_number;
|
|
71
|
+
const mirror = mirrorName(ghOwner(), ghRepo());
|
|
72
|
+
const base = issueNo && mirror ? await repoHttpsBase(path) : null;
|
|
73
|
+
const home = Boolean(base) && base.toLowerCase() === `https://github.com/${mirror}`.toLowerCase();
|
|
74
|
+
const known = new Set(db.prepare('SELECT upper(prefix) AS prefix FROM projects').all().map((p) => p.prefix));
|
|
75
|
+
return { words: wordsOf(task.key, issueNo, mirror, home), known };
|
|
76
|
+
}
|
|
68
77
|
function attachGitStat(task, startedAt) {
|
|
69
78
|
const project = db.prepare('SELECT path FROM projects WHERE id = ?').get(task.project_id);
|
|
70
79
|
if (!project?.path) return;
|
|
@@ -73,14 +82,11 @@ function attachGitStat(task, startedAt) {
|
|
|
73
82
|
const git = (args) => new Promise((done) => execFile(bin('git'), ['-C', project.path, ...args], { timeout: 5000, maxBuffer: 16 * 1024 * 1024 }, (err, out) => done(err ? null : out)));
|
|
74
83
|
(async () => {
|
|
75
84
|
try {
|
|
76
|
-
const
|
|
77
|
-
const base = issueNo && mirror ? await repoHttpsBase(project.path) : null;
|
|
78
|
-
const home = Boolean(base) && base.toLowerCase() === `https://github.com/${mirror}`.toLowerCase();
|
|
85
|
+
const { words, known } = await taskWords(task, project.path);
|
|
79
86
|
const log = await git(['log', '-n', String(STAT_SCAN), `--format=${STAT_FORMAT}`]);
|
|
80
87
|
if (log === null) return; // not a git repository, or no git — nothing to show
|
|
81
|
-
const known = new Set(db.prepare('SELECT upper(prefix) AS prefix FROM projects').all().map((p) => p.prefix));
|
|
82
88
|
const since = startedAt ? Math.floor(new Date(startedAt + 'Z').getTime() / 1000) : null;
|
|
83
|
-
const hash = statCommit(log,
|
|
89
|
+
const hash = statCommit(log, words, { issue: issueNo, since, known });
|
|
84
90
|
if (!hash) return;
|
|
85
91
|
const stdout = await git(['log', '-1', '--format=%h|%ct|%s', '--shortstat', hash]);
|
|
86
92
|
const lines = String(stdout || '').trim().split('\n').filter(Boolean);
|
|
@@ -361,8 +367,16 @@ export async function settleTurns(states) {
|
|
|
361
367
|
const elsewhere = db.prepare('SELECT 1 FROM task_events WHERE session_id = ? AND task_id != ? AND id > ? LIMIT 1').get(m.sid, st.id, m.event);
|
|
362
368
|
if (!elsewhere) {
|
|
363
369
|
const before = await m.print;
|
|
364
|
-
const
|
|
365
|
-
|
|
370
|
+
const path = db.prepare('SELECT p.path FROM tasks t JOIN projects p ON p.id = t.project_id WHERE t.id = ?').get(st.id)?.path;
|
|
371
|
+
const now = await treePrint(path);
|
|
372
|
+
if (before === null || now === null) continue; // cannot tell
|
|
373
|
+
if (before !== now) {
|
|
374
|
+
const log = await turnCommits(path, before, now, STAT_FORMAT);
|
|
375
|
+
const t = log === null ? null : resolveTask(String(st.id));
|
|
376
|
+
if (!t) continue; // changed files — or cannot tell
|
|
377
|
+
const { words, known } = await taskWords(t, path);
|
|
378
|
+
if (namesTask(log, words, { issue: t.gh_issue_number, known })) continue; // committed for it
|
|
379
|
+
}
|
|
366
380
|
}
|
|
367
381
|
back.push({ id: st.id, ...m });
|
|
368
382
|
}
|
package/src/routes/terminal.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolveTask } from './tasks.js';
|
|
2
2
|
import { sayToTerminal } from '../terminal-say.js';
|
|
3
3
|
import { typing } from '../chat-queue.js';
|
|
4
|
-
import { COLS_MAX, COLS_MIN, KEYS, REPLY_MAX,
|
|
4
|
+
import { COLS_MAX, COLS_MIN, KEYS, REPLY_MAX, noteLook, phantomMessage, phantomResize, phantomStop, questionOpen, taskTerminal, tmuxRefusal, widthFor } from '../phantom.js';
|
|
5
5
|
import { logError } from '../db.js';
|
|
6
6
|
import { scalarQuery } from '../query-params.js';
|
|
7
7
|
import { createScreens } from '../term-screens.js';
|
|
@@ -26,7 +26,7 @@ export default async function terminalRoutes(app) {
|
|
|
26
26
|
const term = await taskTerminal(t.id, { screen: true });
|
|
27
27
|
if (!term) return { session: null };
|
|
28
28
|
noteLook(term.session);
|
|
29
|
-
if (term.unknown) return { session: term.session, unknown: true };
|
|
29
|
+
if (term.unknown) return { session: term.session, unknown: true, ...(term.broken ? tmuxRefusal(term) : {}) };
|
|
30
30
|
// a Claude that has exited cannot redraw itself: its last screen stays as it was laid out. A column or two
|
|
31
31
|
// wider is not worth a redraw: the same card measures 46 in Chromium and 45 in Safari, and two of them
|
|
32
32
|
// open at once would otherwise lay the window out again every second, one after the other (review)
|
|
@@ -48,7 +48,7 @@ export default async function terminalRoutes(app) {
|
|
|
48
48
|
if (!text && !key) return reply.code(400).send({ error: 'text or key' });
|
|
49
49
|
const term = await taskTerminal(t.id);
|
|
50
50
|
if (!term) return reply.code(404).send({ error: 'no Claude of the board works on this task' });
|
|
51
|
-
if (term.unknown) return reply.code(409).send(
|
|
51
|
+
if (term.unknown) return reply.code(409).send(tmuxRefusal(term));
|
|
52
52
|
if (term.dead) return reply.code(409).send({ error: 'Claude has exited in this terminal' });
|
|
53
53
|
const r = await typing(term.session, () => sayToTerminal(t, term.session, { text, key }));
|
|
54
54
|
if (r.err) {
|
|
@@ -67,7 +67,7 @@ export default async function terminalRoutes(app) {
|
|
|
67
67
|
}
|
|
68
68
|
const term = await taskTerminal(t.id);
|
|
69
69
|
if (!term) return reply.code(404).send({ error: 'no Claude of the board works on this task', reason: 'gone' });
|
|
70
|
-
if (term.unknown) return reply.code(409).send(
|
|
70
|
+
if (term.unknown) return reply.code(409).send(tmuxRefusal(term));
|
|
71
71
|
const r = await phantomMessage(term.session, t.key, text.trim());
|
|
72
72
|
if (r.refused) {
|
|
73
73
|
const why = { question: 'Claude is asking a question in its terminal', busy: 'Claude is in the middle of its work', gone: 'Claude has exited in this terminal' };
|
package/src/share.js
CHANGED
|
@@ -266,7 +266,7 @@ function handle(req, res) {
|
|
|
266
266
|
forgetDoorStop(req.socket.remoteAddress);
|
|
267
267
|
return letIn(res, port, req.url);
|
|
268
268
|
}
|
|
269
|
-
const free = req.method === 'GET' && FREE_PATH.test(path);
|
|
269
|
+
const free = (req.method === 'GET' || req.method === 'HEAD') && FREE_PATH.test(path);
|
|
270
270
|
if (!free && !hasPass(req, port)) {
|
|
271
271
|
noteRefusal(req.socket.remoteAddress, cookieOf(req, cookieName(port)) ? 'oldpass' : 'nokey');
|
|
272
272
|
if (path.startsWith('/api/')) return refuse(req, res, 401, 'the key of the board is needed');
|
package/src/shipped.js
CHANGED
|
@@ -57,16 +57,23 @@ export function placeOf(t, main, side, onMain) {
|
|
|
57
57
|
return t.branch === 'in' || main.some(mine) || side.some(mine) ? 'main' : 'none';
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
export const STAT_FORMAT = `%H%n%ct%n%s%n%B${SEP}`;
|
|
60
|
+
export const STAT_FORMAT = `%H%n%ct %P%n%s%n%B${SEP}`;
|
|
61
61
|
export function statCommit(out, words, { issue = null, since = null, known = null } = {}) {
|
|
62
|
-
const commits = parseLog(out, known); // `tip` holds the commit time here
|
|
62
|
+
const commits = parseLog(out, known); // `tip` holds the commit time and the parents here
|
|
63
63
|
const own = commits.find((c) => words.some((w) => c.words.has(w)));
|
|
64
64
|
if (own || issue || since == null) return own?.hash || null;
|
|
65
65
|
const head = commits[0];
|
|
66
|
-
|
|
66
|
+
const [time, ...parents] = (head?.tip || '').split(' ').filter(Boolean);
|
|
67
|
+
if (!head || Number(time) < since || ownKeys(head.subject, known).length || parents.length > 1) return null;
|
|
67
68
|
return head.hash;
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
export function namesTask(out, words, { issue = null, known = null } = {}) {
|
|
72
|
+
const commits = parseLog(out, known);
|
|
73
|
+
if (!commits.length) return true;
|
|
74
|
+
return commits.some((c) => words.some((w) => c.words.has(w)) || (!issue && !ownKeys(c.subject, known).length));
|
|
75
|
+
}
|
|
76
|
+
|
|
70
77
|
export function branchesOf(key, names) {
|
|
71
78
|
const k = new RegExp(`(^|[-/_])${key}($|[-/_])`, 'i');
|
|
72
79
|
return names.filter((n) => k.test(n.replace(/^(origin\/)?claude\//, '')));
|
package/src/sync/github.js
CHANGED
|
@@ -2,12 +2,13 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { db, ghOwner, ghRepo, uiLang } from '../db.js';
|
|
4
4
|
import { ghBinEnv } from '../config.js';
|
|
5
|
+
import { bin } from '../bin-path.js';
|
|
5
6
|
const GH_CANDIDATES = [
|
|
6
7
|
'/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh',
|
|
7
8
|
'/home/linuxbrew/.linuxbrew/bin/gh', `${process.env.HOME}/.local/bin/gh`,
|
|
8
9
|
];
|
|
9
|
-
const GH_FOUND = GH_CANDIDATES.find((p) => existsSync(p)) ||
|
|
10
|
-
const ghBin = () => ghBinEnv() || GH_FOUND;
|
|
10
|
+
const GH_FOUND = GH_CANDIDATES.find((p) => existsSync(p)) || null;
|
|
11
|
+
const ghBin = () => ghBinEnv() || GH_FOUND || bin('gh');
|
|
11
12
|
|
|
12
13
|
const PERMANENT = [
|
|
13
14
|
/validation failed/i,
|
package/src/tree-print.js
CHANGED
|
@@ -29,6 +29,16 @@ export async function treePrint(path) {
|
|
|
29
29
|
if (untracked.length > UNTRACKED_MAX || !top) return null; // too many to look at, or no root to find them: cannot tell
|
|
30
30
|
const root = top.toString('utf8').trim();
|
|
31
31
|
const stamps = await Promise.all(untracked.map((f) => stat(join(root, f)).then((s) => `${f}\0${s.size}\0${s.mtimeMs}`, () => `${f}\0-`)));
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
const rest = createHash('sha1').update(status).update('\0').update(diff || '').update('\0').update(stamps.join('\0')).digest('hex');
|
|
33
|
+
return `${head ? head.toString('utf8').trim() : ''}:${rest}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function turnCommits(path, before, now, format) {
|
|
37
|
+
const cut = (p) => { const i = String(p || '').indexOf(':'); return i > 0 ? [p.slice(0, i), p.slice(i + 1)] : [null, null]; };
|
|
38
|
+
const [a, restA] = cut(before);
|
|
39
|
+
const [b, restB] = cut(now);
|
|
40
|
+
if (!a || !b || a === b || restA !== restB) return null;
|
|
41
|
+
if (await git(path, ['merge-base', '--is-ancestor', a, b]) === null) return null; // a branch switched, a reset: cannot tell
|
|
42
|
+
const out = await git(path, ['log', `--format=${format}`, `${a}..${b}`]);
|
|
43
|
+
return out === null ? null : out.toString('utf8');
|
|
34
44
|
}
|