local-kanban 2.0.0-beta.4 → 2.0.0-beta.5

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 CHANGED
@@ -8,6 +8,22 @@ 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.5]
12
+
13
+ ### Fixed
14
+
15
+ - 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.
16
+ - 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.
17
+ - Health: the Russian text of the task «Fix» opens puts its counts of lines and characters in the right grammatical form.
18
+ - 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.
19
+ - 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.
20
+ - 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.
21
+ - A limit line split by the terminal inside the date or the words is read whole, so the reset time is right.
22
+ - Dropping a group of cards shows the page's and the board's refusals in one window.
23
+ - Over Wi-Fi, a HEAD request for a page a guest may open passes like GET.
24
+ - `kb note` with a task typed as `1` after `DM-1` no longer writes the same note twice.
25
+ - A merge commit is told by its parents, not by a message starting with «Merge».
26
+
11
27
  ## [2.0.0-beta.4]
12
28
 
13
29
  ### 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(id, body) {
82
- return 'kb-' + createHash('sha256').update(`${String(id).toUpperCase()}\n${SESSION}\n${body}`).digest('hex').slice(0, 40);
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(id, body) }, NOTE);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-kanban",
3
- "version": "2.0.0-beta.4",
3
+ "version": "2.0.0-beta.5",
4
4
  "license": "MIT",
5
5
  "author": "Dannylamao",
6
6
  "repository": {
@@ -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 refused = ids.map((id) => state.tasks.find((x) => x.id === id)).filter(Boolean)
977
- .filter((t) => !movable.includes(t));
978
- if (refused.length) {
979
- const why = refused.map((t) => moveBlocked(t.status, status, { manual: taskIsNoclaude(t) }))
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 — только пока в папке проекта никто не работает',
@@ -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 dirty = here ? changedFields(formToSave(), drawerSnapshot) : {};
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
@@ -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 = st === 'heavy' || st === 'long'
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 }) {
@@ -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) {
@@ -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('..') && !isAbsolute(rel));
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 async function healthFixTask(app, slug, registry) {
70
- const p = db.prepare('SELECT id, slug, name, path, category, archived FROM projects WHERE slug = ?').get(String(slug || ''));
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
@@ -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: 'слова не дошли до терминала', state: 'Claude так и не встал у приглашения' } },
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', state: 'Claude never stood 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' } },
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.split(':')[0]] : '';
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
- if (was.why === 'resume' && late) { giveUp(r.session, was); continue; }
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 older = all.slice(0, Math.max(0, all.length - n)).flatMap((t) => t.items.filter((it) => isHelper(it) && (it.running || clock - (it.ended ?? 0) < WAKE_LOOK_MS)));
577
- const wanted = new Set([...shown.flatMap((t) => t.items.filter(isHelper)), ...older].map((it) => it.id));
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
- // one that wrote nothing for a while is not counted: a Claude gone with the machine leaves no end of its helpers
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 : { dead: false, unknown: true };
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 : { dead: false, unknown: true };
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
- // with no am/pm — the words then waited for the wrong half of the day. A space may stand anywhere in the time.
614
- const LIMIT_RESETS = /resets?(?: at)? ((?:[a-z]{3,9} \d{1,2},? (?:at )?)?)(\d(?: ?\d)?(?: ?: ?\d ?\d)?(?: ?[ap] ?m\b)?)/i;
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
- return { resets: m ? `${m[1].replace(/\s+/g, ' ')}${m[2].replace(/\s+/g, '')}` : null };
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 } : {}),
@@ -1,6 +1,6 @@
1
1
 
2
2
  import { resolveTask, taskAttachmentPaths } from './tasks.js';
3
- import { CHAT_COLS, REPLY_MAX, TMUX_SLOW, claudeAtPrompt, paceOnScreen, phantomResize, phantomScreen, phantomSend, taskTerminal, watched } from '../phantom.js';
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({ error: TMUX_SLOW, reason: 'tmux-slow' })
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) => {
@@ -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 };
@@ -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, TMUX_SLOW, boardProblem, claudeAtPrompt, folderTrusted, lookedLately, markTaskless, phantomAvailable, phantomPane, phantomSpec, phantomStop, runPhantom, taskTerminal, tmuxBin, tmuxCommandBytes } from '../phantom.js';
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({ error: TMUX_SLOW, reason: 'tmux-slow', keys });
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 });
@@ -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, TMUX_SLOW, noteLook, phantomMessage, phantomResize, phantomStop, questionOpen, taskTerminal, widthFor } from '../phantom.js';
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({ error: TMUX_SLOW, reason: 'tmux-slow' });
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({ error: TMUX_SLOW, reason: 'tmux-slow' });
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,13 +57,14 @@ 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
- if (!head || Number(head.tip) < since || ownKeys(head.subject, known).length || /^Merge /.test(head.subject)) return null;
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
 
@@ -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)) || 'gh';
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,