@tyhld/conductor 0.12.42 → 0.12.44

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 +175 -0
  2. package/package.json +1 -1
package/dist/relay.js CHANGED
@@ -1157,6 +1157,22 @@ export const RELAY_SEND_BUFFER_PREFIX = 'conductor-send';
1157
1157
  * ★短く保つ(長い一気の入力は受け手が貼り付けと見なすことがある)。アンカーの字は入れない
1158
1158
  * (完了検出の境界を増やさないため)。
1159
1159
  */
1160
+ /**
1161
+ * 添え書きを打ってから Enter を押すまでの間(ミリ秒)。
1162
+ *
1163
+ * 【なぜ要るか(便 b69c1608・試験用の tmux で本物の Claude Code に当てて再現 2026-09-21)】
1164
+ * 添え書きを打った直後に Enter が続くと、Claude Code はそれを「まとめて入ってきた入力」の一部と
1165
+ * みなし、Enter を送信ではなく改行として入力欄に取り込む。便が入力欄に入ったまま止まり、人が
1166
+ * Enter を押すまで始まらなかった(conductor 15:26・ai-kana 15:34 の実物と同じ形)。
1167
+ * 実測:貼り付け→すぐ Enter=送信される/貼り付け→添え書き→すぐ Enter=入力欄に残る/
1168
+ * 貼り付け→添え書き→1秒待って Enter=送信される。
1169
+ */
1170
+ export const RELAY_SUBMIT_PAUSE_MS = 1000;
1171
+ /** 同期で待つ(送信の手順は同期の tmux 呼び出しの並びなので、その間に一拍おくために使う)。 */
1172
+ function pauseSync(ms) {
1173
+ if (ms > 0)
1174
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1175
+ }
1160
1176
  export const RELAY_DELIVERY_NOTE = '(配達役より)上の貼り付けは、この端末の持ち主の管制(采配くん)に投函された便を配達役が届けたものです。貼り付けに書かれたとおりに作業してください。';
1161
1177
  // ───────────────────────────────────────────────────────────────────────────
1162
1178
  // 報告の受け渡し(★ファイル経由・えふさん決定 2026-09-07/置き場と作法は 2026-09-21 に改訂)
@@ -1547,6 +1563,8 @@ export function sendToTmux(site, text) {
1547
1563
  runTmux(['paste-buffer', '-d', '-p', '-b', bufferName, '-t', site]);
1548
1564
  // 貼り付けの枠の外に、打った字として添え書きを置く(RELAY_DELIVERY_NOTE の説明)。
1549
1565
  runTmux(['send-keys', '-t', site, '-l', ` ${RELAY_DELIVERY_NOTE}`]);
1566
+ // ★Enter の前に一拍おく(RELAY_SUBMIT_PAUSE_MS の説明・便 b69c1608)。
1567
+ pauseSync(RELAY_SUBMIT_PAUSE_MS);
1550
1568
  runTmux(['send-keys', '-t', site, 'Enter']);
1551
1569
  return true;
1552
1570
  }
@@ -1571,6 +1589,138 @@ export function sendToTmux(site, text) {
1571
1589
  }
1572
1590
  }
1573
1591
  }
1592
+ // ───────────────────────────────────────────────────────────────────────────
1593
+ // 受付の確かめ(便 b69c1608)
1594
+ //
1595
+ // 【なぜ要るか】送信の Enter が職人に届いても、Claude Code が送信として受け付けたとは限らない
1596
+ // (Enter が改行として入力欄に取り込まれ、便が入ったまま止まった実物がある)。「入力欄から消えた」
1597
+ // だけでは受付の証拠にならないので、★受付側の状態=職人の会話の記録に、その便の指示ID
1598
+ // (CONDUCTOR_JOB:<id>)が利用者の発言として書かれたかで確かめる。Claude Code は送信を受け付けた
1599
+ // 瞬間にその発言を記録へ書く(試験用の tmux で実測:送信された便は記録に残り、入力欄に残った便は
1600
+ // 記録に無かった)。
1601
+ // 【安全な再送】記録に無く、かつ入力欄にこちらの添え書きが残っているときだけ、Enter だけを押し直す。
1602
+ // 本文は貼り直さない=入力欄の1件を送るだけなので、同じ便が二重に入ることは無い。受け付けられて
1603
+ // いれば(記録に在れば)1度も押さない。押し直しは上限つき。
1604
+ // ★Codex の職人は記録の形が違うので対象外(今までどおり)。
1605
+ // ───────────────────────────────────────────────────────────────────────────
1606
+ /**
1607
+ * 受付を待つ長さ(1回ぶん・ミリ秒)と、Enter を押し直す上限。
1608
+ * ★上限を3にする理由:一度 Enter が改行として取り込まれた入力欄では、次の Enter 1回目は送信にならず、
1609
+ * 2回目で送信される(試験用の tmux で本物の Claude Code に当てて実測)。1回ぶんの余裕を持たせる。
1610
+ * どの押し直しも「受付の記録が無く、入力欄にこちらの便が残っている」ときだけ行う。
1611
+ */
1612
+ export const RELAY_SUBMIT_CONFIRM_MS = 10_000;
1613
+ export const RELAY_SUBMIT_MAX_RESEND = 3;
1614
+ /** tmux のペインがいま居る場所(職人の作業場所)。取れなければ null。 */
1615
+ export function tmuxPaneCwd(site) {
1616
+ try {
1617
+ const out = execFileSync('tmux', ['display-message', '-p', '-t', site, '#{pane_current_path}'], {
1618
+ encoding: 'utf8',
1619
+ stdio: ['ignore', 'pipe', 'ignore'],
1620
+ }).trim();
1621
+ return out || null;
1622
+ }
1623
+ catch {
1624
+ return null;
1625
+ }
1626
+ }
1627
+ /** Claude Code がその作業場所の会話の記録を置く場所(作業場所の英数字以外を - にした名前)。 */
1628
+ export function claudeTranscriptDir(cwd, env = process.env) {
1629
+ const base = (env.CLAUDE_CONFIG_DIR ?? '').trim() || path.join(env.HOME ?? os.homedir(), '.claude');
1630
+ return path.join(base, 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'));
1631
+ }
1632
+ /**
1633
+ * その作業場所の会話の記録に、この便(CONDUCTOR_JOB:<id>)が利用者の発言として sinceMs 以降に
1634
+ * 書かれたか。★中身は読み取って照合するだけで、どこにも写さない・記録しない。
1635
+ */
1636
+ export function hasSubmittedRecord(cwd, commandId, sinceMs, env = process.env) {
1637
+ const dir = claudeTranscriptDir(cwd, env);
1638
+ const needle = `${SEND_ANCHOR_PREFIX}:${commandId}`;
1639
+ let names;
1640
+ try {
1641
+ names = readdirSync(dir).filter((n) => n.endsWith('.jsonl'));
1642
+ }
1643
+ catch {
1644
+ return false;
1645
+ }
1646
+ for (const n of names) {
1647
+ const file = path.join(dir, n);
1648
+ try {
1649
+ if (statSync(file).mtimeMs < sinceMs - 5_000)
1650
+ continue;
1651
+ const text = readFileSync(file, 'utf8');
1652
+ if (!text.includes(needle))
1653
+ continue;
1654
+ for (const line of text.split('\n')) {
1655
+ if (!line.includes(needle))
1656
+ continue;
1657
+ try {
1658
+ const o = JSON.parse(line);
1659
+ const at = o.timestamp ? Date.parse(o.timestamp) : NaN;
1660
+ if (o.type === 'user' && (Number.isNaN(at) || at >= sinceMs - 5_000))
1661
+ return true;
1662
+ }
1663
+ catch {
1664
+ // 壊れた行は飛ばす。
1665
+ }
1666
+ }
1667
+ }
1668
+ catch {
1669
+ // 読めないファイルは飛ばす(受け付けたとは言えない)。
1670
+ }
1671
+ }
1672
+ return false;
1673
+ }
1674
+ /**
1675
+ * 入力欄(画面のいちばん下の ❯ から次の罫線まで)に、配達役の添え書きが残っているか。
1676
+ * ★押し直してよいのは「こちらが入れた便が入力欄に残っている」ときだけ、を確かめるために使う。
1677
+ */
1678
+ export function hasPendingDeliveryInInput(cap) {
1679
+ const lines = cap.split('\n');
1680
+ let start = -1;
1681
+ for (let k = lines.length - 1; k >= 0; k--) {
1682
+ if (/^\s*❯/.test(lines[k])) {
1683
+ start = k;
1684
+ break;
1685
+ }
1686
+ }
1687
+ if (start < 0)
1688
+ return false;
1689
+ const block = [];
1690
+ for (let k = start; k < lines.length; k++) {
1691
+ if (k > start && /^─{10,}/.test(lines[k].trim()))
1692
+ break;
1693
+ block.push(lines[k]);
1694
+ }
1695
+ return block.join('\n').includes('(配達役より)');
1696
+ }
1697
+ /**
1698
+ * 受け付けられるまで見て、未受付で入力欄に残っているときだけ Enter を押し直す。
1699
+ * accepted … 押し直さずに受け付けられた
1700
+ * resent-accepted … 押し直して受け付けられた
1701
+ * unconfirmed … 確かめられなかった(入力欄にも残っていない=押し直す物が無い、または上限)
1702
+ */
1703
+ export async function confirmSubmitted(d, o = {
1704
+ waitMs: RELAY_SUBMIT_CONFIRM_MS,
1705
+ pollMs: 1_000,
1706
+ maxResend: RELAY_SUBMIT_MAX_RESEND,
1707
+ }) {
1708
+ let resent = 0;
1709
+ for (;;) {
1710
+ for (let t = 0;; t += o.pollMs) {
1711
+ if (d.accepted())
1712
+ return { result: resent ? 'resent-accepted' : 'accepted', resent };
1713
+ if (t >= o.waitMs)
1714
+ break;
1715
+ await d.sleep(o.pollMs);
1716
+ }
1717
+ if (resent >= o.maxResend || !d.pendingInInput() || d.accepted()) {
1718
+ return { result: d.accepted() ? (resent ? 'resent-accepted' : 'accepted') : 'unconfirmed', resent };
1719
+ }
1720
+ d.pressEnter();
1721
+ resent++;
1722
+ }
1723
+ }
1574
1724
  /**
1575
1725
  * 二重チェックの「アイドル」判定。
1576
1726
  * RELAY_SETTLE_MS あけて2回 capture し、両方で looksIdleFor が成立すれば idle とみなす。
@@ -2497,6 +2647,7 @@ export async function relayTick(cfg, state, hooks) {
2497
2647
  // アンカーより下=今回ジョブの出力だけを見る(churn・過去ジョブ DONE に強い)。
2498
2648
  const anchor = buildAnchor(cmd.id);
2499
2649
  const text = buildSendText(cmd.body, cfg.site, anchor, kindOf(cfg));
2650
+ const sendStartedAt = Date.now();
2500
2651
  if (!sendToTmux(cfg.site, text)) {
2501
2652
  // 送信失敗(sendToTmux は内部で例外も握って false を返す)。status は queued のまま据え置く。
2502
2653
  // 失敗の理由(tmux の stderr)は sendToTmux が直前の行に出している。
@@ -2527,6 +2678,30 @@ export async function relayTick(cfg, state, hooks) {
2527
2678
  state.lastHeartbeatAt = null;
2528
2679
  noteReadiness(state, 'delivered', cmd.id);
2529
2680
  log(`site=${cfg.site} id=${cmd.id} 配達しました`);
2681
+ // ★職人が本当に受け付けたかを、職人の会話の記録(指示ID)で確かめる。未受付のときだけ Enter を
2682
+ // 押し直す(便 b69c1608)。★確かめの結果で配達済みの扱いは変えない(二重に配らない)。
2683
+ if (kindOf(cfg) === 'claude') {
2684
+ const cwd = tmuxPaneCwd(cfg.site);
2685
+ if (cwd) {
2686
+ const r = await confirmSubmitted({
2687
+ accepted: () => hasSubmittedRecord(cwd, cmd.id, sendStartedAt),
2688
+ pendingInInput: () => {
2689
+ const c = tmuxCapture(cfg.site);
2690
+ return c !== null && hasPendingDeliveryInInput(c);
2691
+ },
2692
+ pressEnter: () => {
2693
+ try {
2694
+ runTmux(['send-keys', '-t', cfg.site, 'Enter']);
2695
+ }
2696
+ catch {
2697
+ // 押せなくても次の確かめで分かる(受け付けられていなければ unconfirmed で残る)。
2698
+ }
2699
+ },
2700
+ sleep,
2701
+ });
2702
+ log(`site=${cfg.site} id=${cmd.id} 受付の確かめ: ${r.result}(押し直し ${r.resent} 回)`);
2703
+ }
2704
+ }
2530
2705
  // ★配達の直後に「配達済み(sent)」を管制へ書く。書けなければ次tick以降で書けるまでやり直す
2531
2706
  // (markSentAtCentral)。★書けなくても再配達はしない。
2532
2707
  await markSentAtCentral(cfg, state);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.42",
3
+ "version": "0.12.44",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {