@tyhld/conductor 0.12.51 → 0.12.53

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.
Files changed (2) hide show
  1. package/dist/relay.js +525 -66
  2. package/package.json +1 -1
package/dist/relay.js CHANGED
@@ -925,8 +925,14 @@ export function isTerminalCommandStatus(status) {
925
925
  * cancelled/canceled/abandoned のみを見るので、再配達の可否ではこの2つを足して判断する。
926
926
  * - dismissed: ②画面で人が却下(devlog-tracker: commands/[id]/dismiss/route.ts が status='dismissed')
927
927
  * - timeout : 中央 sweep が doneAt を打って終端化(devlog-tracker: lib/conductor.ts の sweep)
928
+ * - instruction_missing : 本文未着(便 7d810bbb)。管制が占有を外した=配り役は追わない
928
929
  */
929
- const CENTRAL_EXTRA_TERMINAL_STATUSES = new Set(['dismissed', 'timeout']);
930
+ const CENTRAL_EXTRA_TERMINAL_STATUSES = new Set(['dismissed', 'timeout', 'instruction_missing']);
931
+ /**
932
+ * 管制の「本文未着」(★便 7d810bbb・中央 ai-kana 214c474e)。職人は仕事を受け取っていない。
933
+ * 管制は現場の占有を外し、人の判断(配り直す/中身を見る)を待つ。★配り役はこの便を追わない・配り直さない。
934
+ */
935
+ export const INSTRUCTION_MISSING_STATUS = 'instruction_missing';
930
936
  /**
931
937
  * 中央がこの指示を「もう終わっている」と言っているか(純粋関数)。
932
938
  * done / failed / cancelled / canceled / abandoned / dismissed / timeout のいずれか。
@@ -1395,11 +1401,31 @@ export function pendingReportIds(env, limit = PENDING_REPORTS_MAX) {
1395
1401
  // ・★配達役は完了にしない・便の状態の値を書かない(中央も完了にしない=止まった便を「回収必要」まで進めるだけ)。
1396
1402
  // ・★配達役がいま追っている便の報告は送らない。それは完了の道(completeWithReport)が読んで完了と一緒に
1397
1403
  // 渡す。先に processed/ へ移すと、completeWithReport が「渡し済み」とみなして完了を書かなくなる。
1404
+ // ★例外は「作業なし・本文未着」の報告だけ(isInstructionMissingReport・便 7d810bbb)。この報告は
1405
+ // 完了にしない物なので、完了の道が書かなくなってもよい(むしろ完了にしてはいけない)。
1398
1406
  // ───────────────────────────────────────────────────────────────────────────
1399
1407
  /** 1回に送る報告ファイルの件数の上限(★中央の FILE_INTAKE_LIMIT と同じ値にすること)。 */
1400
1408
  export const REPORT_FILES_BATCH = 20;
1401
1409
  /** 1周で送る件数の上限(多すぎる分は次の周に回す=落とさない)。 */
1402
1410
  export const REPORT_FILES_MAX_PER_ROUND = 100;
1411
+ /**
1412
+ * 報告ファイルが「作業なし・本文未着」を示しているか(★便 7d810bbb)。純粋関数。
1413
+ *
1414
+ * 【なぜ要るか(実障害 af960f79)】本文を受け取れなかった職人が「本文未着・作業なし」の報告を置いたが、
1415
+ * その便は配り役が追跡中だったので送られず(下の決まり)、30分の時間切れまで管制に届かなかった。
1416
+ * 【★ここで決めるのは「追跡中でも送ってよいか」だけ】完了にはしない・便の状態の値も書かない。
1417
+ * 管制は中身の言葉ではなく便の状態で扱う(中央 214c474e:未着の便は完了にも PASS にもしない)。
1418
+ * 【判定】両方がそろったときだけ true(片方だけでは送らない=通常の完了報告を巻き込まない):
1419
+ * ① 作業なし … `outcome: no_change`(囲みが閉じていない古い形でも行として読む)
1420
+ * ② 本文未着 … 「本文未着」または `instruction_missing` と書かれている
1421
+ */
1422
+ export function isInstructionMissingReport(raw) {
1423
+ const text = String(raw ?? '');
1424
+ const parsed = parseReport(text);
1425
+ const noWork = parsed.outcome === 'no_change' || /^[ \t]*outcome[ \t]*:[ \t]*no_change[ \t]*$/im.test(text);
1426
+ const missing = /本文未着|instruction_missing/i.test(text);
1427
+ return noWork && missing;
1428
+ }
1403
1429
  /**
1404
1430
  * 未処理の報告ファイルの全文を中央へ送り、受け取られた物だけ処理済みにする。
1405
1431
  * 返り: 送った件数・処理済みにした件数・未処理のまま残した件数。
@@ -1411,11 +1437,13 @@ export async function sendPendingReportFiles(cfg, opts = {}) {
1411
1437
  const exclude = new Set(opts.exclude ?? []);
1412
1438
  const items = [];
1413
1439
  for (const id of pendingReportIds(env, REPORT_FILES_MAX_PER_ROUND).ids) {
1414
- if (exclude.has(id))
1415
- continue;
1416
1440
  for (const file of pendingCandidates(id, env)) {
1417
1441
  const raw = readIfFilled(file);
1418
1442
  if (raw !== null) {
1443
+ // ★追跡中の便の報告は送らない(今までどおり)。ただし「作業なし・本文未着」だけは送る
1444
+ // (便 7d810bbb)。完了の道はその便を完了にしないので、ここで送らないと管制に届かない。
1445
+ if (exclude.has(id) && !isInstructionMissingReport(raw))
1446
+ break;
1419
1447
  items.push({ id, raw });
1420
1448
  break;
1421
1449
  }
@@ -1669,12 +1697,8 @@ function runTmux(args) {
1669
1697
  * ★tmux の言い分(stderr)をログに出す。以前は `failed` としか出ず原因が追えなかった。
1670
1698
  */
1671
1699
  export function sendToTmux(site, text) {
1672
- const bufferName = `${RELAY_SEND_BUFFER_PREFIX}-${site}-${process.pid}-${Date.now()}`;
1673
- let file = null;
1674
1700
  try {
1675
- file = writeSendTmpFile(text);
1676
- runTmux(['load-buffer', '-b', bufferName, file]);
1677
- runTmux(['paste-buffer', '-d', '-p', '-b', bufferName, '-t', site]);
1701
+ pasteToTmux(site, text);
1678
1702
  // 貼り付けの枠の外に、打った字として添え書きを置く(RELAY_DELIVERY_NOTE の説明)。
1679
1703
  runTmux(['send-keys', '-t', site, '-l', ` ${RELAY_DELIVERY_NOTE}`]);
1680
1704
  // ★Enter の前に一拍おく(RELAY_SUBMIT_PAUSE_MS の説明・便 b69c1608)。
@@ -1684,13 +1708,30 @@ export function sendToTmux(site, text) {
1684
1708
  }
1685
1709
  catch (err) {
1686
1710
  log(`WARN site=${site} 本文の投入に失敗(${text.length}文字): ${describeExecError(err)}`);
1711
+ return false;
1712
+ }
1713
+ }
1714
+ /**
1715
+ * 本文を一時ファイル → tmux のバッファ → 職人の窓へ貼る(貼るだけ。添え書きも Enter も送らない)。
1716
+ * 失敗したら例外がそのまま飛ぶ。一時ファイルは必ず消し、貼る前に転んだバッファも消す
1717
+ * (sendToTmux の【後始末】の説明)。
1718
+ */
1719
+ function pasteToTmux(site, text) {
1720
+ const bufferName = `${RELAY_SEND_BUFFER_PREFIX}-${site}-${process.pid}-${Date.now()}`;
1721
+ let file = null;
1722
+ try {
1723
+ file = writeSendTmpFile(text);
1724
+ runTmux(['load-buffer', '-b', bufferName, file]);
1725
+ runTmux(['paste-buffer', '-d', '-p', '-b', bufferName, '-t', site]);
1726
+ }
1727
+ catch (err) {
1687
1728
  try {
1688
1729
  execFileSync('tmux', ['delete-buffer', '-b', bufferName], { stdio: 'ignore' });
1689
1730
  }
1690
1731
  catch {
1691
1732
  // 貼れていれば -d で既に消えている。無いバッファを消そうとしたエラーは無視してよい。
1692
1733
  }
1693
- return false;
1734
+ throw err;
1694
1735
  }
1695
1736
  finally {
1696
1737
  if (file !== null) {
@@ -1704,6 +1745,165 @@ export function sendToTmux(site, text) {
1704
1745
  }
1705
1746
  }
1706
1747
  // ───────────────────────────────────────────────────────────────────────────
1748
+ // 貼り付けが入力欄に入ったのを確かめてから送る(便 963ddca3・★添え書きだけが届く事故の根治)
1749
+ //
1750
+ // 【何が起きたか(実障害 2026-09-23 minoru 便 af960f79/2026-09-24 ai-kana 便 0b637256)】
1751
+ // 職人の会話の記録(実物)を読むと、どちらも利用者の発言として記録されたのは
1752
+ // 「 (配達役より)上の貼り付けは…」の75字だけで、貼り付け(本文と便印)は入力欄に一度も入って
1753
+ // いなかった。★配達役は貼り付けが入ったかを確かめず、続けて添え書きを打って Enter を押していた。
1754
+ // だから職人が貼り付けを受け取れなかった瞬間に、添え書きだけが単独で送信された。
1755
+ // 2件とも職人の tmux ができてから 11〜30 秒の、職人を起動した直後の最初の便だった
1756
+ // (同じ期間の会話を新しくした後の配達 12 件はすべて届いている)。
1757
+ // 【なぜ貼り付けが消えるか(試験用の tmux で本物の Claude Code 2.1.281 に当てて再現)】
1758
+ // 貼り付けと添え書きが【1回の読み込みでまとめて】職人に届くと、Claude Code は貼り付けを捨て、
1759
+ // 添え書きだけを入力欄に残す(入力欄は「 (配達役より)…」=実障害の記録と1字も違わない)。
1760
+ // 配達役は貼り付けの直後に添え書きを打つので、職人が起動直後で読み込みが遅れていると、両方が
1761
+ // たまって一度に読まれる(★ここは推定:実障害2件はどちらも起動直後の最初の便で、/clear から記録
1762
+ // までが 8.8・9.2 秒と、届いた便のほとんど(6.1〜7.9 秒)より遅かった。職人の読み込みの遅れそのものは
1763
+ // 記録に残っていない)。
1764
+ // そこへ1秒後の Enter が別に届き、添え書きだけが送信された。
1765
+ // 【根治】「貼る → 入力欄に入ったのを画面で確かめる → 添え書きを打つ → 入力欄に貼り付けと添え書きが
1766
+ // そろっているのを確かめる → Enter」。★そろっていなければ Enter を押さない。
1767
+ // =貼り付けが入らないかぎり、何も送信されない(添え書きだけが先に・単独で届く形が無い)。
1768
+ // 入らなければ、画面が入力待ちに戻るのを確かめてから貼り直す(上限あり)。時間で決め打ちしない。
1769
+ // ★入力欄に人が打ちかけた字があれば貼らない(人の入力に混ぜない)。
1770
+ // ★消すのは、配達役が打った添え書きの字だけ(貼り付けが入らず、添え書きだけが入力欄に残ったとき)。
1771
+ // ───────────────────────────────────────────────────────────────────────────
1772
+ /** 貼ってから入力欄に入るのを待つ長さ(1回ぶん・ミリ秒)。★待ちの上限であって、決め打ちの間ではない。 */
1773
+ export const RELAY_PASTE_LAND_MS = 5_000;
1774
+ /** 1回の配達の中で貼る回数の上限(入力欄に入らなかったときの貼り直しを含む)。 */
1775
+ export const RELAY_PASTE_MAX_TRIES = 3;
1776
+ /**
1777
+ * 画面のいちばん下の入力欄(最後の ❯ の行から次の罫線まで)の文字。入力欄が無ければ null。
1778
+ * ★折り返しは元に戻さない(呼び出し側で joinWrapped する)。
1779
+ */
1780
+ export function inputBlock(cap) {
1781
+ const lines = cap.split('\n');
1782
+ let start = -1;
1783
+ for (let k = lines.length - 1; k >= 0; k--) {
1784
+ if (/^\s*❯/.test(lines[k])) {
1785
+ start = k;
1786
+ break;
1787
+ }
1788
+ }
1789
+ if (start < 0)
1790
+ return null;
1791
+ const block = [];
1792
+ for (let k = start; k < lines.length; k++) {
1793
+ if (k > start && /^─{10,}/.test(lines[k].trim()))
1794
+ break;
1795
+ block.push(lines[k]);
1796
+ }
1797
+ return block.join('\n');
1798
+ }
1799
+ /** 画面の折り返しを戻す(改行と、続きの行の頭の空白を取る)。 */
1800
+ function joinWrapped(s) {
1801
+ return s.replace(/\n[ \t]*/g, '');
1802
+ }
1803
+ /**
1804
+ * 入力欄に、この便の貼り付けが入っているか。
1805
+ * ・長い貼り付けは Claude Code が `[Pasted text #1 +N lines]` と畳んで見せる(実測)。
1806
+ * ・短い貼り付けはそのまま見える=本文の末尾の便印(CONDUCTOR_JOB:<id>)が完全一致で在るか。
1807
+ */
1808
+ export function hasPasteInInput(cap, commandId) {
1809
+ const block = inputBlock(cap);
1810
+ if (block === null)
1811
+ return false;
1812
+ if (/\[Pasted text #\d+/.test(block))
1813
+ return true;
1814
+ return hasExactJobStamp(joinWrapped(block), commandId);
1815
+ }
1816
+ /** 入力欄に、配達役の添え書きが(全部)入っているか。 */
1817
+ function hasNoteInInput(cap) {
1818
+ const block = inputBlock(cap);
1819
+ return block !== null && block.replace(/\s/g, '').includes(RELAY_DELIVERY_NOTE.replace(/\s/g, ''));
1820
+ }
1821
+ /**
1822
+ * 入力欄が「配達役が貼ってよい」状態か:入力欄が在り、貼り付け・添え書き・便印のどれも無い。
1823
+ * ★人が打ちかけた字は画面の上では Claude Code の薄い字の例(Try "…")と見分けられないので、
1824
+ * ここでは「配達役の物が残っていない」ことだけを見る(人の打ちかけは受付の確かめが別に守る)。
1825
+ */
1826
+ function inputIsClear(cap, commandId) {
1827
+ const block = inputBlock(cap);
1828
+ if (block === null)
1829
+ return false;
1830
+ return !hasPasteInInput(cap, commandId) && !block.includes('(配達役より)');
1831
+ }
1832
+ /**
1833
+ * 貼り付けが入力欄に入ったのを確かめてから、添え書きを打って送る(Claude の職人向け)。
1834
+ * ★Enter を押すのは「入力欄に貼り付けと添え書きの両方がある」ときだけ。
1835
+ */
1836
+ export async function deliverToTmux(site, text, commandId, d = {
1837
+ capture: () => tmuxCapture(site),
1838
+ paste: (t) => pasteToTmux(site, t),
1839
+ typeNote: () => runTmux(['send-keys', '-t', site, '-l', ` ${RELAY_DELIVERY_NOTE}`]),
1840
+ // 打った字の数だけ BSpace(先頭の空白1字を含む)。★配達役が打った字しか消さない。
1841
+ eraseNote: () => runTmux(['send-keys', '-t', site, '-N', String([...` ${RELAY_DELIVERY_NOTE}`].length), 'BSpace']),
1842
+ enter: () => runTmux(['send-keys', '-t', site, 'Enter']),
1843
+ pause: pauseSync,
1844
+ sleep,
1845
+ ready: () => isIdleStable(site, 'claude'),
1846
+ }, o = {
1847
+ landMs: RELAY_PASTE_LAND_MS,
1848
+ pollMs: 250,
1849
+ maxTries: RELAY_PASTE_MAX_TRIES,
1850
+ }) {
1851
+ let pastes = 0;
1852
+ const landed = async () => {
1853
+ for (let t = 0;; t += o.pollMs) {
1854
+ const cap = d.capture();
1855
+ if (cap !== null && hasPasteInInput(cap, commandId))
1856
+ return true;
1857
+ if (t >= o.landMs)
1858
+ return false;
1859
+ await d.sleep(o.pollMs);
1860
+ }
1861
+ };
1862
+ try {
1863
+ for (let round = 0; round < o.maxTries; round++) {
1864
+ // ★入力を受け取れる状態(入力待ちで落ち着いた画面)を確かめてから触る。
1865
+ if (!(await d.ready())) {
1866
+ await d.sleep(o.pollMs);
1867
+ continue;
1868
+ }
1869
+ const cap = d.capture();
1870
+ if (cap === null)
1871
+ return { result: 'failed', pastes };
1872
+ // ★まだ1度も貼っていないのに入力欄に貼り付けがあるなら、それが配達役の物だと言えるのは
1873
+ // この便の印(完全一致)が見えるときだけ。畳まれた貼り付けは誰の物か分からないので触らない。
1874
+ if (pastes === 0 && hasPasteInInput(cap, commandId) && !hasExactJobStamp(joinWrapped(inputBlock(cap) ?? ''), commandId)) {
1875
+ return { result: 'not-clear', pastes };
1876
+ }
1877
+ if (!hasPasteInInput(cap, commandId)) {
1878
+ // 前の回の貼り付けも入っていない。配達役の物ではない残りがあれば貼らない。
1879
+ if (!inputIsClear(cap, commandId))
1880
+ return { result: 'not-clear', pastes };
1881
+ d.paste(text);
1882
+ pastes++;
1883
+ if (!(await landed()))
1884
+ continue; // ★入らなかった=何も打たずに次の回へ
1885
+ }
1886
+ if (!hasNoteInInput(d.capture() ?? '')) {
1887
+ d.typeNote();
1888
+ d.pause(RELAY_SUBMIT_PAUSE_MS);
1889
+ }
1890
+ const before = d.capture();
1891
+ if (before !== null && hasPasteInInput(before, commandId) && hasNoteInInput(before)) {
1892
+ d.enter();
1893
+ return { result: 'submitted', pastes };
1894
+ }
1895
+ // ★そろっていない=Enter を押さない。添え書きだけが残っていれば、打った字だけ消す。
1896
+ if (before !== null && !hasPasteInInput(before, commandId) && hasNoteInInput(before))
1897
+ d.eraseNote();
1898
+ }
1899
+ }
1900
+ catch (err) {
1901
+ log(`WARN site=${site} 本文の投入に失敗(${text.length}文字): ${describeExecError(err)}`);
1902
+ return { result: 'failed', pastes };
1903
+ }
1904
+ return { result: 'not-landed', pastes };
1905
+ }
1906
+ // ───────────────────────────────────────────────────────────────────────────
1707
1907
  // 受付の確かめ(便 b69c1608)
1708
1908
  //
1709
1909
  // 【なぜ要るか】送信の Enter が職人に届いても、Claude Code が送信として受け付けたとは限らない
@@ -1785,28 +1985,151 @@ export function hasSubmittedRecord(cwd, commandId, sinceMs, env = process.env) {
1785
1985
  }
1786
1986
  return false;
1787
1987
  }
1988
+ // ───────────────────────────────────────────────────────────────────────────
1989
+ // 受領確認(配達開始 → 受領確認 → 実行開始・★えふさん確定 2026-09-23・便 7d810bbb)
1990
+ //
1991
+ // 【なぜ要るか(実障害 2026-09-23・便 af960f79/minoru)】
1992
+ // 会話を新しくした直後に本文の貼り付けが落ち、添え書きだけが送信された。職人は便の印も本文も
1993
+ // 受け取っていないのに、配り役は「送れた=配達した」として中央を【実行中(sent)】にしていたので、
1994
+ // 30分の時間切れまで誰も気づけなかった。中央は 214c474e で「配達中(delivering) → 受領確認 →
1995
+ // 実行中(sent)」と「本文未着(instruction_missing)」の道を用意した。配り役はここでその知らせを送る。
1996
+ //
1997
+ // 【★ACK の条件(えふさん確定・1文字も変えない)】
1998
+ // ACKは「画面に何か出た」ではなく instruction_id 一致を必須にする。
1999
+ // 職人画面に `CONDUCTOR_JOB:<instruction_id>` 相当の便印が実際に現れ、送ったinstruction_idと
2000
+ // 完全一致したことを確認して初めてACK。
2001
+ //
2002
+ // 【★「相当の便印」に会話の記録を使う理由】
2003
+ // ・画面の入力欄に印が見えていても、まだ送信されていないことがある(b69c1608 の実物=Enter が改行に
2004
+ // 取り込まれた)。それを受領と数えると、af960f79 と同じ「受け取っていないのに実行中」が起きる。
2005
+ // ・職人(Claude Code)は全画面表示で、送信された発言の印は数秒で流れて消える(ADR-023)。
2006
+ // ★職人が送信を受け付けた瞬間に、その発言が会話の記録へ書かれる。そこに【この便を貼った後の時刻で】
2007
+ // 【利用者の発言として】印が在り、【指示IDが完全一致】したときだけ受領とみなす。
2008
+ // ───────────────────────────────────────────────────────────────────────────
2009
+ /** 指示IDとして続きうる文字(これが印の直後に続いたら「別の便の印」=一致とみなさない)。 */
2010
+ const JOB_ID_CHAR = /[0-9A-Za-z_-]/;
1788
2011
  /**
1789
- * 入力欄(画面のいちばん下の ❯ から次の罫線まで)に、配達役の添え書きが残っているか。
1790
- * ★押し直してよいのは「こちらが入れた便が入力欄に残っている」ときだけ、を確かめるために使う。
2012
+ * 文字列の中に、この便の印(`CONDUCTOR_JOB:<id>`)が【完全一致】で在るか。純粋関数。
2013
+ * ・`CONDUCTOR_JOB:<id>` の直後に指示IDの文字が続く物(=別の便の印・前方一致)は数えない。
2014
+ * ・別の便の印がいくつ在っても、この便の印が無ければ false。
1791
2015
  */
1792
- export function hasPendingDeliveryInInput(cap) {
1793
- const lines = cap.split('\n');
1794
- let start = -1;
1795
- for (let k = lines.length - 1; k >= 0; k--) {
1796
- if (/^\s*❯/.test(lines[k])) {
1797
- start = k;
1798
- break;
1799
- }
2016
+ export function hasExactJobStamp(text, commandId) {
2017
+ if (!commandId)
2018
+ return false;
2019
+ const needle = `${SEND_ANCHOR_PREFIX}:${commandId}`;
2020
+ for (let at = text.indexOf(needle); at >= 0; at = text.indexOf(needle, at + 1)) {
2021
+ const next = text.charAt(at + needle.length);
2022
+ if (next === '' || !JOB_ID_CHAR.test(next))
2023
+ return true;
1800
2024
  }
1801
- if (start < 0)
2025
+ return false;
2026
+ }
2027
+ /**
2028
+ * この便を貼った後(sinceMs 以降)に、職人の会話の記録へ、この便の印が【利用者の発言として】
2029
+ * 【完全一致で】書かれたか。★受領確認(ACK)の唯一の根拠。
2030
+ * ・時刻が sinceMs より前の記録(=前の便・前に配った同じ便の残り)は数えない(猶予も取らない)。
2031
+ * ・時刻の無い記録は数えない(新しく出たと言えない)。
2032
+ * ・利用者の発言でない記録(職人の返答などに印が引用された物)は数えない。
2033
+ * ★中身は照合するだけで、どこにも写さない・記録しない。
2034
+ */
2035
+ export function hasJobStampSince(cwd, commandId, sinceMs, env = process.env) {
2036
+ const dir = claudeTranscriptDir(cwd, env);
2037
+ const needle = `${SEND_ANCHOR_PREFIX}:${commandId}`;
2038
+ let names;
2039
+ try {
2040
+ names = readdirSync(dir).filter((n) => n.endsWith('.jsonl'));
2041
+ }
2042
+ catch {
1802
2043
  return false;
1803
- const block = [];
1804
- for (let k = start; k < lines.length; k++) {
1805
- if (k > start && /^─{10,}/.test(lines[k].trim()))
2044
+ }
2045
+ for (const n of names) {
2046
+ const file = path.join(dir, n);
2047
+ try {
2048
+ // 貼った後に書かれていないファイルは見ない(★ファイルの時刻は粗いことがあるので、ここだけ5秒の猶予。
2049
+ // 新しく出たかの判定そのものは、下の行ごとの時刻で猶予なしに行う)。
2050
+ if (statSync(file).mtimeMs < sinceMs - 5_000)
2051
+ continue;
2052
+ const text = readFileSync(file, 'utf8');
2053
+ if (!text.includes(needle))
2054
+ continue;
2055
+ for (const line of text.split('\n')) {
2056
+ if (!hasExactJobStamp(line, commandId))
2057
+ continue;
2058
+ try {
2059
+ const o = JSON.parse(line);
2060
+ const at = o.timestamp ? Date.parse(o.timestamp) : NaN;
2061
+ if (o.type === 'user' && Number.isFinite(at) && at >= sinceMs)
2062
+ return true;
2063
+ }
2064
+ catch {
2065
+ // 壊れた行は飛ばす。
2066
+ }
2067
+ }
2068
+ }
2069
+ catch {
2070
+ // 読めないファイルは飛ばす(受け取ったとは言えない)。
2071
+ }
2072
+ }
2073
+ return false;
2074
+ }
2075
+ /** 印が現れなかったときに貼り直す上限(★えふさん確定「2回まで貼り直し」)。 */
2076
+ export const RELAY_ACK_MAX_REPASTE = 2;
2077
+ /**
2078
+ * 印が現れるのを待ち、現れなければ貼り直す(★2回まで)。★自分では中央へ何も送らない。
2079
+ *
2080
+ * 1回ぶん … 受付の確かめ(confirmSubmitted):印を待ち、入力欄に便が残っていれば Enter だけ押し直す。
2081
+ * 現れない … 次のどれかなら【貼り直さない】(二重に入れない・割り込まない):
2082
+ * ・入力欄にこちらの便が残っている(貼り直すと入力欄に2つ入る)
2083
+ * ・職人がアイドルでない(添え書きだけ受け取って返事をしている最中など)
2084
+ * どれでもなければ本文を貼り直す(上限 RELAY_ACK_MAX_REPASTE 回)。
2085
+ * ★受領とみなすのは stampSeen(送った指示IDと完全一致・貼った後に新しく出た印)が true のときだけ。
2086
+ */
2087
+ export async function awaitDeliveryAck(d, o = {
2088
+ waitMs: RELAY_SUBMIT_CONFIRM_MS,
2089
+ pollMs: 1_000,
2090
+ maxResend: RELAY_SUBMIT_MAX_RESEND,
2091
+ maxRepaste: RELAY_ACK_MAX_REPASTE,
2092
+ }) {
2093
+ let repasted = 0;
2094
+ let resent = 0;
2095
+ let reason = '';
2096
+ for (let round = 0;; round++) {
2097
+ const r = await confirmSubmitted({ accepted: d.stampSeen, pendingInInput: d.pendingInInput, pressEnter: d.pressEnter, sleep: d.sleep }, o);
2098
+ resent += r.resent;
2099
+ if (r.result !== 'unconfirmed')
2100
+ return { received: true, repasted, resent, reason: '' };
2101
+ if (round >= o.maxRepaste) {
2102
+ reason = reason || `貼り直し${repasted}回のあとも印が現れない`;
1806
2103
  break;
1807
- block.push(lines[k]);
2104
+ }
2105
+ if (d.pendingInInput()) {
2106
+ reason = '入力欄に便が残ったまま送信されない(貼り直すと二重に入るので貼り直さない)';
2107
+ break;
2108
+ }
2109
+ if (!(await d.idle())) {
2110
+ reason = '職人がアイドルでないので貼り直さなかった';
2111
+ continue; // この回は貼り直さずに、もう一度だけ印を待つ(回数は数える=待ちは有限)
2112
+ }
2113
+ if (await d.repaste()) {
2114
+ repasted++;
2115
+ reason = '';
2116
+ }
2117
+ else {
2118
+ reason = '貼り直しに失敗';
2119
+ }
1808
2120
  }
1809
- return block.join('\n').includes('(配達役より)');
2121
+ // ★返す直前にもう一度だけ見る(最後の待ちの直後に印が出た場合を取りこぼさない)。
2122
+ if (d.stampSeen())
2123
+ return { received: true, repasted, resent, reason: '' };
2124
+ return { received: false, repasted, resent, reason };
2125
+ }
2126
+ /**
2127
+ * 入力欄(画面のいちばん下の ❯ から次の罫線まで)に、配達役の添え書きが残っているか。
2128
+ * ★押し直してよいのは「こちらが入れた便が入力欄に残っている」ときだけ、を確かめるために使う。
2129
+ */
2130
+ export function hasPendingDeliveryInInput(cap) {
2131
+ const block = inputBlock(cap);
2132
+ return block !== null && block.includes('(配達役より)');
1810
2133
  }
1811
2134
  // ───────────────────────────────────────────────────────────────────────────
1812
2135
  // 1便1context:配る直前に職人の会話を新しくする(便 87a1a28a・★既定オフ)
@@ -1823,13 +2146,10 @@ export function hasPendingDeliveryInInput(cap) {
1823
2146
  // ───────────────────────────────────────────────────────────────────────────
1824
2147
  /** 会話を新しくできたかを待つ長さ(ミリ秒)。 */
1825
2148
  export const RELAY_FRESH_CONFIRM_MS = 10_000;
1826
- /**
1827
- * 会話が新しくなってから配るまでに置く間(ミリ秒)。
1828
- * ★実測(試験用の tmux・本物の Claude Code):新しい会話のファイルができた1秒後に貼り付けると、
1829
- * 入力欄がまだ整っておらず【貼り付けた本文が落ち、添え書きだけが送信された】(職人は「貼り付けが
1830
- * 届いていない」と答えて止まった)。間を置いたうえで、画面が入力待ちで落ち着いたことも確かめる。
1831
- */
1832
- export const RELAY_FRESH_SETTLE_MS = 3_000;
2149
+ // ★会話が新しくなった後に「決め打ちの間」は置かない(便 963ddca3)。以前は 3 秒待っていたが、
2150
+ // 職人を起動した直後の最初の便では 3 秒待っても貼り付けが落ちた(実障害2件)。入力を受け取れる
2151
+ // 状態かは時間では分からないので、①画面が入力待ちで落ち着くのを待ち、②貼った後に入力欄へ入った
2152
+ // のを画面で確かめる(deliverToTmux)。入らなければ何も送らずに貼り直す。
1833
2153
  /** その作業場所の会話の記録のファイル名の一覧。読めなければ空。 */
1834
2154
  export function listTranscripts(cwd, env = process.env) {
1835
2155
  try {
@@ -1859,9 +2179,14 @@ export async function freshenContext(site, cwd, d = {
1859
2179
  }
1860
2180
  for (let t = 0;; t += o.pollMs) {
1861
2181
  if (d.listSessions().some((n) => !before.has(n))) {
1862
- // ★新しい会話ができた直後は入力欄が整っていない(RELAY_FRESH_SETTLE_MS の説明)。
1863
- await d.sleep(RELAY_FRESH_SETTLE_MS);
1864
- return d.ready();
2182
+ // ★新しい会話ができたら、画面が入力待ちで落ち着くのを待つ(上限は waitMs・決め打ちの間は置かない)。
2183
+ for (let u = 0;; u += o.pollMs) {
2184
+ if (await d.ready())
2185
+ return true;
2186
+ if (u >= o.waitMs)
2187
+ return false;
2188
+ await d.sleep(o.pollMs);
2189
+ }
1865
2190
  }
1866
2191
  if (t >= o.waitMs)
1867
2192
  return false;
@@ -2163,9 +2488,14 @@ export async function markSentAtCentral(cfg, state) {
2163
2488
  return false;
2164
2489
  if (pending.sentMarked)
2165
2490
  return true;
2166
- if (await patchStatus(cfg, pending.id, 'sent')) {
2491
+ // ★Claude の職人へ配った便は「配達中(delivering)」を書く(便 7d810bbb)。実行中(sent)へ進めるのは
2492
+ // 受領の知らせ(sendAckAtCentral)だけ。★それ以外(Codex・孤児の復元)は今までどおり sent。
2493
+ const mark = pending.markAs ?? 'sent';
2494
+ if (await patchStatus(cfg, pending.id, mark)) {
2167
2495
  pending.sentMarked = true;
2168
- log(`site=${cfg.site} id=${pending.id} 配達済み(sent)を管制へ記録`);
2496
+ log(mark === 'delivering'
2497
+ ? `site=${cfg.site} id=${pending.id} 配達中(delivering=受領確認待ち)を管制へ記録`
2498
+ : `site=${cfg.site} id=${pending.id} 配達済み(sent)を管制へ記録`);
2169
2499
  return true;
2170
2500
  }
2171
2501
  // 書けなかった。理由を中央に聞いて、やり直す意味があるかだけ判断する。
@@ -2178,7 +2508,7 @@ export async function markSentAtCentral(cfg, state) {
2178
2508
  state.lastHeartbeatAt = null;
2179
2509
  return false;
2180
2510
  }
2181
- if (centralStatus === 'sent') {
2511
+ if (centralStatus === 'sent' || (mark === 'delivering' && centralStatus === 'delivering')) {
2182
2512
  // 既に印が付いている(別経路/409 の相手が自分自身だった等)。書き直す必要はない。
2183
2513
  pending.sentMarked = true;
2184
2514
  log(`site=${cfg.site} id=${pending.id} 中央は既に sent=配達済みの記録は完了とみなす`);
@@ -2188,6 +2518,72 @@ export async function markSentAtCentral(cfg, state) {
2188
2518
  `(中央status=${centralStatus ?? '不明'})→次tickで再試行(★再配達はしない)`);
2189
2519
  return false;
2190
2520
  }
2521
+ /**
2522
+ * 受領の知らせを管制へ送る(★便 7d810bbb・中央の受け口は ai-kana 214c474e)。
2523
+ * POST /api/conductor/commands/<id>/ack body { site, received: true | false }
2524
+ * 返り { ok, id, status, applied, reason }(applied:false=管制は二重に動かさなかった)。
2525
+ *
2526
+ * 【送る条件】pending.ack が決まっていて、まだ送っておらず、★「配達中」の印を先に書けていること
2527
+ * (印より先に受領を送ると、管制は queued の便として受け流す=知らせが消える)。
2528
+ * 【★職人の作業を止めない】ここは管制へ知らせるだけ。職人の窓には1文字も触らない。
2529
+ * 管制が応えない・遅い(上限 RELAY_FETCH_TIMEOUT_MS)・5xx のときは false を返し、次tickでやり直す。
2530
+ * 【received:false を送れたら】その便の追跡をやめる(占有を外す)。★配り直しはしない
2531
+ * (配り直すのは人が管制で【配り直す】を押したとき=そのとき管制が queued に戻す)。
2532
+ *
2533
+ * @returns 知らせを送り終えた(または送る物が無い)なら true。
2534
+ */
2535
+ export async function sendAckAtCentral(cfg, state, logLine = log) {
2536
+ const pending = state.pending;
2537
+ if (pending === null || pending.ack === undefined)
2538
+ return true;
2539
+ if (pending.ackSent)
2540
+ return true;
2541
+ if (!pending.sentMarked)
2542
+ return false; // 配達中の印が先。書けてから送る。
2543
+ const received = pending.ack === 'received';
2544
+ let done = false;
2545
+ try {
2546
+ const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(pending.id)}/ack`, {
2547
+ method: 'POST',
2548
+ headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
2549
+ body: JSON.stringify({ site: cfg.site, received }),
2550
+ // ★上限時間は必須(無応答の管制で輪が止まるのを防ぐ・RELAY_FETCH_TIMEOUT_MS 参照)。
2551
+ signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS),
2552
+ });
2553
+ const data = (await res.json().catch(() => null));
2554
+ const why = String(data?.reason ?? data?.error ?? '').slice(0, 200);
2555
+ if (res.ok) {
2556
+ done = true;
2557
+ logLine(`site=${cfg.site} id=${pending.id} 受領の知らせ(received:${received}) -> ` +
2558
+ `status=${String(data?.status ?? '?')} applied=${String(data?.applied ?? '?')}${why ? `(${why})` : ''}`);
2559
+ }
2560
+ else if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) {
2561
+ // 送り直しても変わらない断り(現場ちがい・知らない便・形の誤り)。★繰り返さない。
2562
+ done = true;
2563
+ logLine(`WARN site=${cfg.site} id=${pending.id} 受領の知らせ(received:${received}) -> HTTP ${res.status}` +
2564
+ `(送り直さない)${why ? `: ${why}` : ''}`);
2565
+ }
2566
+ else {
2567
+ logLine(`site=${cfg.site} id=${pending.id} 受領の知らせ(received:${received}) -> HTTP ${res.status}` +
2568
+ '(次の周でやり直す・職人の作業は止めない)');
2569
+ }
2570
+ }
2571
+ catch (err) {
2572
+ logLine(`site=${cfg.site} id=${pending.id} 受領の知らせ(received:${received}) -> 失敗: ` +
2573
+ `${err instanceof Error ? err.message : String(err)}(次の周でやり直す・職人の作業は止めない)`);
2574
+ }
2575
+ if (!done)
2576
+ return false;
2577
+ pending.ackSent = true;
2578
+ if (!received) {
2579
+ // ★本文未着:追跡をやめる(占有を外す)。★配り直さない=abandoned にも入れない
2580
+ // (人が【配り直す】を押したら管制が queued に戻す。そのときは普通に配る)。
2581
+ logLine(`site=${cfg.site} id=${pending.id} 本文未着として管制へ知らせた→追跡をやめる(配り直すのは人の手番)`);
2582
+ state.pending = null;
2583
+ state.lastHeartbeatAt = null;
2584
+ }
2585
+ return true;
2586
+ }
2191
2587
  /**
2192
2588
  * このtickで生存ハートビート(bare)を送るかを決める純粋関数(I/Oなし=単体テスト可能)。
2193
2589
  *
@@ -2585,6 +2981,25 @@ export async function relayTick(cfg, state, hooks) {
2585
2981
  if (state.pending === null)
2586
2982
  return; // 中央が終端=追跡を畳んだ。次tickで通常経路へ。
2587
2983
  }
2984
+ // ── 受領の知らせ(★便 7d810bbb・送れるまで毎tick やり直す・職人の作業は止めない) ──
2985
+ if (state.pending.ack === 'missing') {
2986
+ // ★本文未着と決めた便。職人は仕事を受け取っていないので、完了検出も生存通知もしない。
2987
+ // 知らせを送れたら sendAckAtCentral が追跡をやめる。★配り直さない。
2988
+ await sendAckAtCentral(cfg, state);
2989
+ if (state.pending !== null && Date.now() - state.pending.sentAt > RELAY_PENDING_TIMEOUT_MS) {
2990
+ // 管制へ知らせられないまま上限を過ぎた。★印も書けていなければ、管制は queued のまま返す
2991
+ // =勝手に配り直さないよう、この常駐の中では二度と配らない(ADR-023 の穴止めと同じ)。
2992
+ if (!state.pending.sentMarked)
2993
+ state.abandoned.add(state.pending.id);
2994
+ log(`WARN site=${cfg.site} id=${state.pending.id} 本文未着の知らせを管制へ送れないまま上限→追跡をやめる(配り直しはしない)`);
2995
+ state.pending = null;
2996
+ state.lastHeartbeatAt = null;
2997
+ }
2998
+ return;
2999
+ }
3000
+ if (state.pending.ack === 'received' && !state.pending.ackSent) {
3001
+ await sendAckAtCentral(cfg, state);
3002
+ }
2588
3003
  // アンカー(今回送信の境界)より下だけを見て DONE:{site} 単独行を探す。
2589
3004
  // churn で古い DONE が押し出されても・過去ジョブの DONE が窓に残っても、誤検出/見逃しなし。
2590
3005
  // ★アンカーが既に流れて消えている場合は、一度アンカーを見ていれば画面全体を見る
@@ -2627,7 +3042,9 @@ export async function relayTick(cfg, state, hooks) {
2627
3042
  const idle = looksIdleFor(cap, kindOf(cfg));
2628
3043
  if (state.pending.sentMarked && idle) {
2629
3044
  const centralStatus = await fetchCommandStatus(cfg, state.pending.id);
2630
- if (isTerminalCommandStatus(centralStatus)) {
3045
+ // ★本文未着(instruction_missing)も追跡をやめる(便 7d810bbb)。管制は占有を外して人の判断待ちに
3046
+ // 出している=ここで待ち続けると次の便が拾えない。★配り直さない(人が決める)。
3047
+ if (isTerminalCommandStatus(centralStatus) || centralStatus === INSTRUCTION_MISSING_STATUS) {
2631
3048
  log(`site=${cfg.site} id=${state.pending.id} は中央で終端(status=${centralStatus})を確認` +
2632
3049
  '(外部完了検出・キュー有無に依らず)→ 追跡解除し次tickで新規取得');
2633
3050
  state.pending = null;
@@ -2858,7 +3275,27 @@ export async function relayTick(cfg, state, hooks) {
2858
3275
  : undefined;
2859
3276
  const text = buildSendText(cmd.body, cfg.site, anchor, kindOf(cfg), context);
2860
3277
  const sendStartedAt = Date.now();
2861
- if (!sendToTmux(cfg.site, text)) {
3278
+ // ★Claude の職人へは受領確認を使う(便 7d810bbb)。Codex は会話の記録の形が違うので今までどおり。
3279
+ const useAck = kindOf(cfg) === 'claude';
3280
+ // ★Claude の職人へは「貼り付けが入力欄に入ったのを確かめてから添え書きと Enter」(便 963ddca3)。
3281
+ // 入らなければ何も送信しない=添え書きだけが届く形が無い。
3282
+ let landed = true;
3283
+ if (useAck) {
3284
+ const d = await deliverToTmux(cfg.site, text, cmd.id);
3285
+ if (d.result === 'failed' || d.result === 'not-clear') {
3286
+ // 職人には何も送っていない。status は queued のまま据え置く(今までの送信失敗と同じ)。
3287
+ log(d.result === 'failed'
3288
+ ? `本文の投入に失敗, keep queued: ${cfg.site}/${cmd.id}`
3289
+ : `WARN site=${cfg.site} id=${cmd.id} 入力欄に配達役の物ではない字が残っているので貼らずに次の周で確かめ直します`);
3290
+ return;
3291
+ }
3292
+ landed = d.result === 'submitted';
3293
+ if (!landed) {
3294
+ log(`WARN site=${cfg.site} id=${cmd.id} 貼り付けが入力欄に入らない(${d.pastes}回貼った)。` +
3295
+ '★何も送信していません→本文未着として管制へ知らせます');
3296
+ }
3297
+ }
3298
+ else if (!sendToTmux(cfg.site, text)) {
2862
3299
  // 送信失敗(sendToTmux は内部で例外も握って false を返す)。status は queued のまま据え置く。
2863
3300
  // 失敗の理由(tmux の stderr)は sendToTmux が直前の行に出している。
2864
3301
  log(`本文の投入に失敗, keep queued: ${cfg.site}/${cmd.id}`);
@@ -2881,40 +3318,62 @@ export async function relayTick(cfg, state, hooks) {
2881
3318
  delivered: true,
2882
3319
  sentMarked: false,
2883
3320
  screenDoneWide: false,
3321
+ ...(useAck ? { markAs: 'delivering' } : {}),
2884
3322
  };
2885
3323
  // 累計配達回数を通算(絶対上限 RELAY_DELIVER_MAX の基準)。
2886
3324
  markDelivered(state, cmd.id);
2887
3325
  // 新しい指示の生存ハートビートは先頭から数え直す(前指示のスロットルを引き継がない)。
2888
3326
  state.lastHeartbeatAt = null;
2889
3327
  noteReadiness(state, 'delivered', cmd.id);
2890
- log(`site=${cfg.site} id=${cmd.id} 配達しました`);
2891
- // ★職人が本当に受け付けたかを、職人の会話の記録(指示ID)で確かめる。未受付のときだけ Enter を
2892
- // 押し直す(便 b69c1608)。★確かめの結果で配達済みの扱いは変えない(二重に配らない)。
2893
- if (kindOf(cfg) === 'claude') {
2894
- const cwd = tmuxPaneCwd(cfg.site);
2895
- if (cwd) {
2896
- const r = await confirmSubmitted({
2897
- accepted: () => hasSubmittedRecord(cwd, cmd.id, sendStartedAt),
2898
- pendingInInput: () => {
2899
- const c = tmuxCapture(cfg.site);
2900
- return c !== null && hasPendingDeliveryInInput(c);
2901
- },
2902
- pressEnter: () => {
2903
- try {
2904
- runTmux(['send-keys', '-t', cfg.site, 'Enter']);
2905
- }
2906
- catch {
2907
- // 押せなくても次の確かめで分かる(受け付けられていなければ unconfirmed で残る)。
2908
- }
2909
- },
2910
- sleep,
2911
- });
2912
- log(`site=${cfg.site} id=${cmd.id} 受付の確かめ: ${r.result}(押し直し ${r.resent} 回)`);
2913
- }
2914
- }
2915
- // ★配達の直後に「配達済み(sent)」を管制へ書く。書けなければ次tick以降で書けるまでやり直す
3328
+ log(`site=${cfg.site} id=${cmd.id} ${landed ? '配達しました' : '配達を試みましたが貼り付けが入らず、何も送信していません'}`);
3329
+ // ★配った時点で管制へ印を書く(便 7d810bbb)。Claude の職人は「配達中(受領確認待ち)」、
3330
+ // それ以外は今までどおり「配達済み(sent)」。書けなければ次tick以降で書けるまでやり直す
2916
3331
  // (markSentAtCentral)。★書けなくても再配達はしない。
2917
3332
  await markSentAtCentral(cfg, state);
3333
+ if (!useAck || state.pending === null)
3334
+ return;
3335
+ if (!landed) {
3336
+ // ★貼り付けが入らなかった=職人は何も受け取っていない。本文未着として知らせ、追跡を外す
3337
+ // (7d810bbb の道)。★配り直さない・ほかの便も配らない(配り直すのは人の手番)。
3338
+ state.pending.ack = 'missing';
3339
+ state.pending.ackSent = false;
3340
+ await sendAckAtCentral(cfg, state);
3341
+ return;
3342
+ }
3343
+ // ── 受領確認(★えふさん確定:送った instruction_id と完全一致した印を確かめて初めて ACK) ──
3344
+ // 印を待ち、入力欄に残っていれば Enter だけ押し直し(b69c1608)、現れなければ2回まで貼り直す。
3345
+ // ★一致の確かめに使うのは、この便を貼った後(sendStartedAt 以降)に新しく出た記録だけ。
3346
+ const pending = state.pending;
3347
+ const cwd = tmuxPaneCwd(cfg.site);
3348
+ const r = cwd === null
3349
+ ? { received: false, repasted: 0, resent: 0, reason: '職人の作業場所が分からず、印を確かめられない(貼り直さない)' }
3350
+ : await awaitDeliveryAck({
3351
+ stampSeen: () => hasJobStampSince(cwd, cmd.id, sendStartedAt),
3352
+ pendingInInput: () => {
3353
+ const c = tmuxCapture(cfg.site);
3354
+ return c !== null && hasPendingDeliveryInInput(c);
3355
+ },
3356
+ pressEnter: () => {
3357
+ try {
3358
+ runTmux(['send-keys', '-t', cfg.site, 'Enter']);
3359
+ }
3360
+ catch {
3361
+ // 押せなくても次の確かめで分かる(印が出なければ未着として扱う)。
3362
+ }
3363
+ },
3364
+ idle: () => isIdleStable(cfg.site, kindOf(cfg)),
3365
+ // ★同じ本文を貼り直す(配達回数は数えない=1回の配達の中の貼り直し)。★貼り直しも、入力欄に
3366
+ // 入ったのを確かめてからでないと添え書きも Enter も送らない(便 963ddca3)。
3367
+ repaste: async () => (await deliverToTmux(cfg.site, text, cmd.id)).result === 'submitted',
3368
+ sleep,
3369
+ });
3370
+ log(`site=${cfg.site} id=${cmd.id} 受領の確かめ: ${r.received ? '印が一致(受領)' : `印が現れない(本文未着): ${r.reason}`}` +
3371
+ `(Enter押し直し ${r.resent} 回・貼り直し ${r.repasted} 回)`);
3372
+ if (state.pending !== pending)
3373
+ return; // 待っている間に畳まれていたら何もしない(念のため)。
3374
+ pending.ack = r.received ? 'received' : 'missing';
3375
+ pending.ackSent = false;
3376
+ await sendAckAtCentral(cfg, state);
2918
3377
  }
2919
3378
  finally {
2920
3379
  state.running = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.51",
3
+ "version": "0.12.53",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {