@tyhld/conductor 0.12.72 → 0.12.73

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/dist/cli.js CHANGED
@@ -16,6 +16,7 @@ import os from 'node:os';
16
16
  import path from 'node:path';
17
17
  import { RELAY_FETCH_TIMEOUT_MS, createRelayState, deliveryReport, parseAgentKind, reconnectDelayMs, relayTick, tmuxHasSession, pendingReportIds, sendPendingReportFiles, sweepProcessedReports, } from './relay.js';
18
18
  import { createHeadlessState, headlessNextDelayMs, headlessTick, parseRunnerMode, } from './headless.js';
19
+ import { runViewCommand } from './view.js';
19
20
  import { createRelayRunner } from './relay-runner.js';
20
21
  import { installProcessGuards } from './process-guard.js';
21
22
  import { registerNudgeTarget, unregisterNudgeTarget, watchNudges, } from './nudge.js';
@@ -468,6 +469,12 @@ function printUsage() {
468
469
  ' conductor ear この機に1本の「耳」(合図の受け取り役)',
469
470
  ' conductor build-runner この機に1本の「司令塔」(試作の段取り役)',
470
471
  ' conductor setup <現場名> [フォルダ] [--profile <default|proto>] 采配くんをこのPCに設置する(初回の1回だけ)',
472
+ ' conductor view <現場名> [--detail] その現場の今を見るだけの画面(kc の実体)',
473
+ '',
474
+ '見るだけの画面(conductor view = kc 現場名):',
475
+ ' いま職人が何をしているかを、やさしい日本語で見るだけの画面です。',
476
+ ' ★職人(Claude Code)・tmux・relay を起動しません。入力も受け付けません(抜けるだけ)。',
477
+ ' --detail を付けると、職人の生の記録(秘密は隠したもの)も続けて出します。',
471
478
  '',
472
479
  '設置の型(--profile。既定は default=今までどおり):',
473
480
  ' default ふつうの現場。従来と同じ設定を置きます。',
@@ -545,6 +552,10 @@ async function main() {
545
552
  // ★引数はそのまま setup.sh へ渡す(judgement は全部インストーラ側)。
546
553
  runSetupCommand(process.argv.slice(3));
547
554
  break;
555
+ case 'view':
556
+ // ★見るだけの画面。職人も tmux も relay も起動せず、入力も受け付けない。
557
+ process.exit(runViewCommand(process.argv.slice(3)));
558
+ break;
548
559
  case 'version':
549
560
  case '-v':
550
561
  case '--version':
package/dist/headless.js CHANGED
@@ -36,6 +36,31 @@ export const HEADLESS_EVENT_FLUSH_MS = 2_000;
36
36
  export const HEADLESS_STOP_CHECK_MS = 15_000;
37
37
  /** イベントの text フィールドの上限(文字)。 */
38
38
  export const EVENT_TEXT_MAX = 500;
39
+ /** 記録1行から取り出すイベントの上限(件)。1行に content が大量に入っていても溢れさせない。 */
40
+ export const EVENT_MAX_PER_LINE = 8;
41
+ /** 1つの便で管制へ送るイベントの上限(件)。これを超えた分は捨てる(生の記録の全量は外へ出さない)。 */
42
+ export const EVENT_MAX_PER_JOB = 2_000;
43
+ /**
44
+ * 工程の区切りの1行を拾う印(職人の鉄則が出す形)。
45
+ * 例: `進捗: 既存の画面構成を確認しました` → 「既存の画面構成を確認しました」
46
+ * ★全角コロンも受ける(日本語入力のままでも通る形にする)。
47
+ */
48
+ export const PROGRESS_MARKER_RE = /^\s*進捗\s*[::]\s*(.+)$/;
49
+ /**
50
+ * 職人の発言から「工程の区切りの1行」を取り出す。印が無ければ null。
51
+ * ★複数行の発言の中に印の行があれば、最初の1本だけを拾う。
52
+ */
53
+ export function extractProgressLine(text) {
54
+ for (const line of text.split('\n')) {
55
+ const m = PROGRESS_MARKER_RE.exec(line);
56
+ if (m) {
57
+ const body = m[1].trim();
58
+ if (body.length > 0)
59
+ return body;
60
+ }
61
+ }
62
+ return null;
63
+ }
39
64
  /** resume 時に職人へ渡す続行指示。 */
40
65
  const RESUME_PROMPT = '前回の作業の続きを行ってください。中断された箇所から再開してください。';
41
66
  /** 実行ログの保存先ルート。 */
@@ -70,6 +95,25 @@ export function resolveGitOrigin(workDir) {
70
95
  return null;
71
96
  }
72
97
  }
98
+ /**
99
+ * その番号のプロセスが今も動いているかを OS に聞く。
100
+ *
101
+ * ★signal 0 は「送らずに存在と権限だけ確かめる」。動いていれば例外なし、
102
+ * 居なければ ESRCH で例外。番号が無い(起動に失敗した)ときは動いていない扱い。
103
+ * ★「作業中」と出してよいかの判定にこれを使う(えふさん確定 2026-10-02)。
104
+ */
105
+ export function isProcessAlive(pid) {
106
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0)
107
+ return false;
108
+ try {
109
+ process.kill(pid, 0);
110
+ return true;
111
+ }
112
+ catch (err) {
113
+ // EPERM = 居るが自分のものではない。ここでは「居る」として扱う。
114
+ return err?.code === 'EPERM';
115
+ }
116
+ }
73
117
  /** 実行中のジョブをファイルに書く(中継が死んでも拾い直せるように)。 */
74
118
  export function saveActiveJob(site, record) {
75
119
  try {
@@ -89,6 +133,38 @@ export function clearActiveJob(site) {
89
133
  }
90
134
  catch { /* ignore */ }
91
135
  }
136
+ /** 現場ごとの「直前に終わった仕事」の記録ファイルのパスを返す。 */
137
+ export function lastJobPath(site) {
138
+ return path.join(ACTIVE_JOB_DIR, `last-job-${site}.json`);
139
+ }
140
+ /** 直前に終わった仕事を書く。失敗しても作業は止めない。 */
141
+ export function saveLastJob(site, record) {
142
+ try {
143
+ mkdirSync(ACTIVE_JOB_DIR, { recursive: true });
144
+ writeFileSync(lastJobPath(site), JSON.stringify(record) + '\n');
145
+ }
146
+ catch (err) {
147
+ log(`WARN last-job の保存に失敗: ${err instanceof Error ? err.message : String(err)}`);
148
+ }
149
+ }
150
+ /** 直前に終わった仕事を読む。無ければ null。 */
151
+ export function loadLastJob(site) {
152
+ try {
153
+ const p = lastJobPath(site);
154
+ if (!existsSync(p))
155
+ return null;
156
+ const raw = readFileSync(p, 'utf8').trim();
157
+ if (!raw)
158
+ return null;
159
+ const rec = JSON.parse(raw);
160
+ if (!rec.commandId)
161
+ return null;
162
+ return rec;
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ }
92
168
  /** 起動時に前回の実行中ジョブを読む。無ければ null。 */
93
169
  export function loadActiveJob(site) {
94
170
  try {
@@ -288,30 +364,124 @@ export function sessionIdForCommand(commandId, attempt = 1) {
288
364
  const hex = attempt.toString(16).padStart(4, '0');
289
365
  return commandId.slice(0, -4) + hex;
290
366
  }
291
- /** stream-json の1行を CommandEvent に変換する。無関係な行は null。 */
292
- export function streamEventToCommandEvent(parsed, attempt) {
367
+ /**
368
+ * 操作1件を人が読める1行にする。
369
+ *
370
+ * ★出どころは職人が書く説明文(description)を第一とする(便 fbaba4b1 のえふさん確定)。
371
+ * description が無い道具(Read / Grep / Skill など)は、道具の名前と、
372
+ * 秘密を含まないと決めた項目(対象のファイル・探す語)だけを添える。
373
+ * ★生のコマンド文字列(input.command)は添えない。全量を PC の外へ出さないため。
374
+ */
375
+ export function toolUseLabel(name, input) {
376
+ const desc = typeof input?.description === 'string' ? input.description.trim() : '';
377
+ if (desc.length > 0)
378
+ return desc;
379
+ for (const key of ['file_path', 'path', 'notebook_path', 'pattern', 'skill', 'subagent_type']) {
380
+ const v = input?.[key];
381
+ if (typeof v === 'string' && v.trim().length > 0)
382
+ return `${name}: ${v.trim()}`;
383
+ }
384
+ return name;
385
+ }
386
+ /**
387
+ * 記録の中の「文字か、文字の配列か、text ブロックの配列」を1本の文字列にする。
388
+ * tool_result の content と、result 行の errors(★実物は素の文字列の配列)の両方に使う。
389
+ */
390
+ function flattenText(content) {
391
+ if (typeof content === 'string')
392
+ return content;
393
+ if (Array.isArray(content)) {
394
+ return content
395
+ .map((b) => {
396
+ if (typeof b === 'string')
397
+ return b;
398
+ if (typeof b === 'object' && b !== null)
399
+ return String(b.text ?? '');
400
+ return '';
401
+ })
402
+ .filter((s) => s.length > 0)
403
+ .join('\n');
404
+ }
405
+ return '';
406
+ }
407
+ /** 秘密を隠して上限で切った CommandEvent を作る。中身が空なら null。 */
408
+ function makeEvent(type, raw, at, attempt) {
409
+ const text = maskSecrets(raw.trim()).slice(0, EVENT_TEXT_MAX);
410
+ if (text.length === 0)
411
+ return null;
412
+ return { type, text, at, attempt };
413
+ }
414
+ /**
415
+ * stream-json の1行から CommandEvent を取り出す。無関係な行は空配列。
416
+ *
417
+ * 【実物の形(2026-10-02 この現場の便 fbaba4b1 の attempt-1.jsonl で実測)】
418
+ * ★`type:'assistant'` に subtype は無く、中身は `message.content` の配列。
419
+ * 配列の要素は `{type:'text',text}` / `{type:'thinking'}` / `{type:'tool_use',name,input}`。
420
+ * ★`type:'user'` も `message.content` の配列で、`{type:'tool_result',is_error,content}` が入る。
421
+ * ★`type:'result'` は `subtype:'success'`(result 項目あり)か
422
+ * `subtype:'error_during_execution'`(★result 項目が無く errors がある)。
423
+ *
424
+ * 【なぜ直したか】以前はここが `type:'assistant' && subtype:'text'` を見ていた。
425
+ * 実物にその形は1件も無いため、1便のあいだ送れていたのは最後の result 1件だけで、
426
+ * 管制から「いま何をしているか」が消えていた(=画面なし方式にした時の退行)。
427
+ *
428
+ * 【1行から複数件出る】1つの assistant 行に発言と操作が同時に入るので、配列を返す。
429
+ */
430
+ export function streamEventsFromLine(parsed, attempt) {
293
431
  const now = new Date().toISOString();
294
432
  const type = parsed.type;
295
433
  const subtype = parsed.subtype;
296
- if (type === 'assistant' && subtype === 'text') {
297
- const text = String(parsed.text ?? '').slice(0, EVENT_TEXT_MAX);
298
- if (text.length === 0)
434
+ const out = [];
435
+ const push = (e) => {
436
+ if (e !== null && out.length < EVENT_MAX_PER_LINE)
437
+ out.push(e);
438
+ };
439
+ const content = (() => {
440
+ const msg = parsed.message;
441
+ if (typeof msg !== 'object' || msg === null)
299
442
  return null;
300
- return { type: 'progress', text: maskSecrets(text), at: now, attempt };
443
+ const c = msg.content;
444
+ return Array.isArray(c) ? c : null;
445
+ })();
446
+ if (type === 'assistant' && content !== null) {
447
+ for (const block of content) {
448
+ if (block?.type === 'text') {
449
+ const text = String(block.text ?? '');
450
+ // ★工程の区切りの印が在ればその1行だけを送る(人向けの表示はこれを使う)。
451
+ const marked = extractProgressLine(text);
452
+ push(makeEvent('progress', marked ?? text, now, attempt));
453
+ }
454
+ else if (block?.type === 'tool_use') {
455
+ const input = typeof block.input === 'object' && block.input !== null
456
+ ? block.input
457
+ : undefined;
458
+ push(makeEvent('tool', toolUseLabel(String(block.name ?? 'unknown'), input), now, attempt));
459
+ }
460
+ // thinking は送らない(人向けの表示に使わないうえ、量が多い)。
461
+ }
462
+ return out;
301
463
  }
302
- if (type === 'assistant' && subtype === 'tool_use') {
303
- const name = String(parsed.name ?? 'unknown');
304
- return { type: 'tool', text: maskSecrets(`tool: ${name}`).slice(0, EVENT_TEXT_MAX), at: now, attempt };
464
+ if (type === 'user' && content !== null) {
465
+ for (const block of content) {
466
+ // ★失敗した操作だけを送る。成功した操作の生出力は量が多く、外へ出す必要もない。
467
+ if (block?.type === 'tool_result' && block.is_error === true) {
468
+ push(makeEvent('error', flattenText(block.content), now, attempt));
469
+ }
470
+ }
471
+ return out;
305
472
  }
306
473
  if (type === 'result') {
307
- if (subtype === 'error') {
308
- const errText = String(parsed.error ?? '').slice(0, EVENT_TEXT_MAX);
309
- return { type: 'error', text: maskSecrets(errText), at: now, attempt };
474
+ if (parsed.is_error === true || subtype === 'error_during_execution') {
475
+ // ★error_during_execution には result 項目が無い。errors を使う。
476
+ const text = String(parsed.result ?? '') || flattenText(parsed.errors) || `失敗しました (${subtype ?? 'result'})`;
477
+ push(makeEvent('error', text, now, attempt));
310
478
  }
311
- const resultText = String(parsed.result ?? '').slice(0, EVENT_TEXT_MAX);
312
- return { type: 'result', text: maskSecrets(resultText), at: now, attempt };
479
+ else {
480
+ push(makeEvent('result', String(parsed.result ?? ''), now, attempt));
481
+ }
482
+ return out;
313
483
  }
314
- return null;
484
+ return out;
315
485
  }
316
486
  /**
317
487
  * stream-json の1行から認証エラーを検出する。
@@ -372,14 +542,22 @@ export function isAuthError(job) {
372
542
  // ─────────────────────────────────────────────────────────────────────────────
373
543
  // 実行ログ
374
544
  // ─────────────────────────────────────────────────────────────────────────────
375
- /** 実行ログのディレクトリを作り、stdout / stderr の WriteStream を返す。 */
545
+ /** 人向けの進捗の記録ファイルのパスを返す(`kc 現場名` の通常表示が読む)。 */
546
+ export function eventsLogPath(commandId, attempt) {
547
+ return path.join(RUNS_DIR, commandId, `attempt-${attempt}.events.jsonl`);
548
+ }
549
+ /** 実行ログのディレクトリを作り、stdout / stderr / 進捗 の WriteStream を返す。 */
376
550
  function openRunLogs(commandId, attempt) {
377
551
  try {
378
552
  const dir = path.join(RUNS_DIR, commandId);
379
553
  mkdirSync(dir, { recursive: true });
380
554
  const stdoutLog = createWriteStream(path.join(dir, `attempt-${attempt}.jsonl`), { flags: 'a' });
381
555
  const stderrLog = createWriteStream(path.join(dir, `attempt-${attempt}.stderr.log`), { flags: 'a' });
382
- return { stdoutLog, stderrLog };
556
+ // ★生の記録には時刻が入っていない(実測 2026-10-02: assistant 行の項目は
557
+ // message/parent_tool_use_id/session_id/type/uuid だけ)。時刻つきの流れを出すには
558
+ // 拾った側で時刻を刻んで残すしかない。ここには秘密を隠して上限で切った物だけを書く。
559
+ const eventsLog = createWriteStream(eventsLogPath(commandId, attempt), { flags: 'a' });
560
+ return { stdoutLog, stderrLog, eventsLog };
383
561
  }
384
562
  catch (err) {
385
563
  log(`WARN 実行ログのディレクトリを作れません: ${err instanceof Error ? err.message : String(err)}`);
@@ -474,12 +652,15 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
474
652
  stopped: false,
475
653
  timedOut: false,
476
654
  eventBuffer: [],
655
+ eventsSent: 0,
656
+ lastActivityAt: Date.now(),
477
657
  lastFlushAt: Date.now(),
478
658
  lastStopCheckAt: Date.now(),
479
659
  stderrBuf: '',
480
660
  authErrorDetected: false,
481
661
  stdoutLog: logs?.stdoutLog ?? null,
482
662
  stderrLog: logs?.stderrLog ?? null,
663
+ eventsLog: logs?.eventsLog ?? null,
483
664
  durationMs: 0,
484
665
  };
485
666
  // spawn の 'error' イベント(ENOENT 等は非同期で届く。同期例外と両方捕まえる)
@@ -510,15 +691,23 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
510
691
  const rl = createInterface({ input: proc.stdout });
511
692
  rl.on('line', (line) => {
512
693
  job.stdoutLog?.write(line + '\n');
694
+ // ★記録が1行出たこと自体が「手が動いている」印。管制の「作業中」判定に使う。
695
+ job.lastActivityAt = Date.now();
513
696
  try {
514
697
  const parsed = JSON.parse(line);
515
698
  // ★stream-json から認証エラーを検出(stderr に出ない実物に対応)
516
699
  if (!job.authErrorDetected && detectStreamAuthError(parsed)) {
517
700
  job.authErrorDetected = true;
518
701
  }
519
- const event = streamEventToCommandEvent(parsed, attempt);
520
- if (event)
702
+ for (const event of streamEventsFromLine(parsed, attempt)) {
703
+ // ★1便あたりの件数に上限を持つ(生の記録の全量は PC の外へ送らない)。
704
+ if (job.eventsSent >= EVENT_MAX_PER_JOB)
705
+ break;
706
+ job.eventsSent += 1;
521
707
  job.eventBuffer.push(event);
708
+ // 時刻つきで手元にも残す(`kc 現場名` の通常表示がこれを読む)。
709
+ job.eventsLog?.write(JSON.stringify(event) + '\n');
710
+ }
522
711
  }
523
712
  catch {
524
713
  // JSON でないか壊れている行 — 無視
@@ -544,6 +733,7 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
544
733
  job.durationMs = Date.now() - job.startedAt;
545
734
  closeLog(job.stdoutLog);
546
735
  closeLog(job.stderrLog);
736
+ closeLog(job.eventsLog);
547
737
  });
548
738
  return job;
549
739
  }
@@ -627,7 +817,20 @@ async function checkRunningJob(cfg, state) {
627
817
  // ── 完了したプロセスの後始末 ──
628
818
  if (job.completed) {
629
819
  await flushEvents(cfg, job);
820
+ // ★消す前に本文を取る(`kc 現場名` が「直前の仕事」を出すために残す)。
821
+ const savedBody = loadActiveJob(cfg.site)?.body ?? '';
630
822
  clearActiveJob(cfg.site);
823
+ const noteLastJob = (outcome, detail) => {
824
+ saveLastJob(cfg.site, {
825
+ commandId: job.commandId,
826
+ body: savedBody,
827
+ outcome,
828
+ startedAt: job.startedAt,
829
+ finishedAt: Date.now(),
830
+ attempt: job.attempt,
831
+ ...(detail !== undefined ? { detail } : {}),
832
+ });
833
+ };
631
834
  if (job.timedOut) {
632
835
  // 上限時間で強制停止した → 管制へ理由つきで failed を記録する
633
836
  const elapsed = Math.round((Date.now() - job.startedAt) / 1000);
@@ -640,6 +843,7 @@ async function checkRunningJob(cfg, state) {
640
843
  }]);
641
844
  await patchStatus(cfg, job.commandId, 'failed', reason.slice(0, EVENT_TEXT_MAX));
642
845
  rs.abandoned.add(job.commandId);
846
+ noteLastJob('timeout', reason);
643
847
  state.job = null;
644
848
  rs.lastHeartbeatAt = null;
645
849
  log(`WARN site=${cfg.site} id=${job.commandId} ${reason}`);
@@ -653,6 +857,7 @@ async function checkRunningJob(cfg, state) {
653
857
  at: new Date().toISOString(),
654
858
  attempt: job.attempt,
655
859
  }]);
860
+ noteLastJob('stopped', '管制から止めました');
656
861
  state.job = null;
657
862
  rs.lastHeartbeatAt = null;
658
863
  log(`site=${cfg.site} id=${job.commandId} stop_requested で停止`);
@@ -673,6 +878,7 @@ async function checkRunningJob(cfg, state) {
673
878
  // ★中央へ failed を返す(次の便が止まらないように)
674
879
  await patchStatus(cfg, job.commandId, 'failed', authMsg);
675
880
  rs.abandoned.add(job.commandId);
881
+ noteLastJob('auth_error', authMsg);
676
882
  state.job = null;
677
883
  rs.lastHeartbeatAt = null;
678
884
  log(`WARN site=${cfg.site} id=${job.commandId} 認証エラー → 再試行しない`);
@@ -689,6 +895,7 @@ async function checkRunningJob(cfg, state) {
689
895
  await patchStatus(cfg, job.commandId, 'done');
690
896
  log(`site=${cfg.site} id=${job.commandId} 完了(exit=0, 報告なし)`);
691
897
  }
898
+ noteLastJob('done', ok ? '報告まで出しました' : '報告は出せませんでした');
692
899
  state.job = null;
693
900
  rs.lastHeartbeatAt = null;
694
901
  return;
@@ -714,6 +921,7 @@ async function checkRunningJob(cfg, state) {
714
921
  attempt: job.attempt,
715
922
  }]);
716
923
  rs.abandoned.add(job.commandId);
924
+ noteLastJob('failed', failReason);
717
925
  }
718
926
  else {
719
927
  // ★ pendingRetry に詰めて、次の tick で実際に spawn する(fetchCommand は sent を返さない)
@@ -747,11 +955,20 @@ async function checkRunningJob(cfg, state) {
747
955
  await stopJob(job);
748
956
  return;
749
957
  }
750
- // 生存ハートビート
958
+ // ── 生存ハートビート ──
959
+ // ★「実プロセスが動いている間だけ」送る(えふさん確定 2026-10-02)。
960
+ // job.completed は exit イベントで立つが、exit が届く前に OS 上のプロセスが
961
+ // 消えていることがある。だから OS に直接聞いて、動いていなければ送らない。
962
+ // 送らなければ中央の lastSeenAt が止まり、管制は「作業中」と出せなくなる。
963
+ const alive = isProcessAlive(job.process.pid);
751
964
  const throttleOk = rs.lastHeartbeatAt === null || now - rs.lastHeartbeatAt >= RELAY_HEARTBEAT_INTERVAL_MS;
752
- if (throttleOk) {
965
+ if (alive && throttleOk) {
753
966
  rs.lastHeartbeatAt = now;
754
- await sendHeartbeat(cfg, job.commandId, true);
967
+ await sendHeartbeat(cfg, job.commandId, true, {
968
+ startedAt: new Date(job.startedAt).toISOString(),
969
+ lastActivityAt: new Date(job.lastActivityAt).toISOString(),
970
+ ...(job.process.pid !== undefined ? { pid: job.process.pid } : {}),
971
+ });
755
972
  }
756
973
  // イベントバッファのフラッシュ
757
974
  if (now - job.lastFlushAt >= HEADLESS_EVENT_FLUSH_MS && job.eventBuffer.length > 0) {
@@ -832,6 +1049,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
832
1049
  return;
833
1050
  }
834
1051
  log(`site=${cfg.site} id=${saved.commandId} 手元の記録から孤児を検出(中継再起動後の拾い直し)`);
1052
+ const postedAt = saved.postedAt;
835
1053
  const pick = { id: saved.commandId, body: saved.body, site: cfg.site, sentAt: saved.startedAt };
836
1054
  const attempt = saved.attempt + 1;
837
1055
  state.attempts.set(pick.id, attempt);
@@ -852,7 +1070,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
852
1070
  return;
853
1071
  }
854
1072
  state.job = job;
855
- saveActiveJob(cfg.site, { commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined });
1073
+ saveActiveJob(cfg.site, { commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined, pid: job.process.pid, postedAt });
856
1074
  markDelivered(state.relay, pick.id);
857
1075
  state.relay.lastHeartbeatAt = null;
858
1076
  noteReadiness(state.relay, 'delivered', pick.id);
@@ -916,7 +1134,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
916
1134
  return;
917
1135
  }
918
1136
  state.job = job;
919
- saveActiveJob(cfg.site, { commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined });
1137
+ saveActiveJob(cfg.site, { commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined, pid: job.process.pid, postedAt: pick.sentAt });
920
1138
  markDelivered(state.relay, pick.id);
921
1139
  state.relay.lastHeartbeatAt = null;
922
1140
  noteReadiness(state.relay, 'delivered', pick.id);
@@ -1010,7 +1228,7 @@ export async function headlessTick(cfg, state, hooks) {
1010
1228
  return;
1011
1229
  }
1012
1230
  state.job = job;
1013
- saveActiveJob(cfg.site, { commandId: retry.cmd.id, body: retry.cmd.body, sessionId, attempt: retry.attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined });
1231
+ saveActiveJob(cfg.site, { commandId: retry.cmd.id, body: retry.cmd.body, sessionId, attempt: retry.attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined, pid: job.process.pid });
1014
1232
  rs.lastHeartbeatAt = null;
1015
1233
  noteReadiness(rs, 'delivered', retry.cmd.id);
1016
1234
  await sendCommandEvents(cfg, retry.cmd.id, [{
@@ -1067,7 +1285,7 @@ export async function headlessTick(cfg, state, hooks) {
1067
1285
  return;
1068
1286
  }
1069
1287
  state.job = job;
1070
- saveActiveJob(cfg.site, { commandId: cmd.id, body: cmd.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined });
1288
+ saveActiveJob(cfg.site, { commandId: cmd.id, body: cmd.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined, pid: job.process.pid, postedAt: cmd.sentAt });
1071
1289
  markDelivered(rs, cmd.id);
1072
1290
  rs.lastHeartbeatAt = null;
1073
1291
  noteReadiness(rs, 'delivered', cmd.id);
package/dist/relay.js CHANGED
@@ -204,6 +204,17 @@ export const BUSY_GLYPHS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '
204
204
  export function kindOf(cfg) {
205
205
  return cfg.agentKind ?? 'claude';
206
206
  }
207
+ /** 中央が返した日時らしい値を ms に直す。読めないときは undefined。 */
208
+ export function parseSentAt(raw) {
209
+ if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0)
210
+ return raw;
211
+ if (typeof raw === 'string' && raw.trim().length > 0) {
212
+ const t = Date.parse(raw);
213
+ if (Number.isFinite(t) && t > 0)
214
+ return t;
215
+ }
216
+ return undefined;
217
+ }
207
218
  /**
208
219
  * 確定判断の件数と字数の上限(★中央 ai-kana の DISPATCH_CONTEXT_LIMITS と同じ値にすること)。
209
220
  * 中央が守っているが、配り役でも同じ上限で守る(受け取った物が多すぎても本文を太らせない)。
@@ -2375,6 +2386,10 @@ export async function fetchCommand(cfg) {
2375
2386
  return null;
2376
2387
  }
2377
2388
  const cmd = { id: String(first.id), body: first.body };
2389
+ // ★投函の時刻は中央が返したときだけ受け取る(無ければ付けない=画面で断定しない)。
2390
+ const sentAt = parseSentAt(first.sentAt) ?? parseSentAt(first.createdAt);
2391
+ if (sentAt !== undefined)
2392
+ cmd.sentAt = sentAt;
2378
2393
  // ★親の便・確定判断は、中央が返した物だけを形を確かめて受け取る(違う形は捨てる)。
2379
2394
  if (typeof first.parentCommandId === 'string' && first.parentCommandId.trim()) {
2380
2395
  cmd.parentCommandId = first.parentCommandId.trim();
@@ -2669,7 +2684,7 @@ export function decideHeartbeat(alive, throttleOk) {
2669
2684
  * (=/seen の perm 系)。画面から質問を読む処理ごと撤去したため、送るのは body 無しの生存通知だけ。
2670
2685
  * 確認そのものは PermissionRequest フックが別の口(hook-requests)でデータとして届ける。
2671
2686
  */
2672
- export async function sendHeartbeat(cfg, id, busy = false) {
2687
+ export async function sendHeartbeat(cfg, id, busy = false, liveness) {
2673
2688
  try {
2674
2689
  // ★busy が【真偽値の true】のときだけ body を載せる。それ以外は今までと1バイトも変えない
2675
2690
  // (body 無しの PATCH)。中央も "true" や 1 のような真偽値でない値は無視する仕様。
@@ -2677,7 +2692,7 @@ export async function sendHeartbeat(cfg, id, busy = false) {
2677
2692
  ? {
2678
2693
  method: 'PATCH',
2679
2694
  headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
2680
- body: JSON.stringify({ busy: true }),
2695
+ body: JSON.stringify({ busy: true, ...(liveness ?? {}) }),
2681
2696
  }
2682
2697
  : { method: 'PATCH', headers: authHeaders(cfg) };
2683
2698
  const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(id)}/seen`, {
package/dist/view.js ADDED
@@ -0,0 +1,455 @@
1
+ /**
2
+ * @tyhld/conductor — `kc 現場名` の「見るだけの画面」(便 fbaba4b1)。
3
+ *
4
+ * 【なぜ要るか】画面なし方式(headless)にしたことで、職人が「いま何をしているか」が
5
+ * お客さまから見えなくなった。以前の `kc` は tmux と Claude Code を新しく起動するので、
6
+ * 画面なし方式の現場で打つと【仕事を受け取っていない空の職人】が開き、その裏では
7
+ * 別の職人が同じフォルダで作業している(実測 2026-10-02 メインPC)。
8
+ * =「見えない」だけでなく「触ると危ない」状態だった。
9
+ *
10
+ * 【この画面の約束(えふさん確定 2026-10-02)】
11
+ * ① 通常=人間向けの日本語/詳細=Claude Code の生ログ の2段構成。
12
+ * 以前の画面をそのまま流すのではなく、非エンジニアが読んで分かる形にする。
13
+ * ② 「🟠 作業中」と出してよいのは、実プロセスが動いている + 最近の作業記録がある
14
+ * 場合だけ。それ以外は 6 つの状態に明確に分ける(=「便を出した=作業開始」にしない)。
15
+ * ③ 投函・開始・実作業は別々に持つ。
16
+ *
17
+ * 【何をしないか】Claude Code・tmux・中継を起動しない。管制へ通信しない。
18
+ * 入力も受け付けない(抜ける操作だけ)。★この画面は手元の記録を読むだけ。
19
+ */
20
+ import { execFileSync } from 'node:child_process';
21
+ import { existsSync, readFileSync, statSync } from 'node:fs';
22
+ import path from 'node:path';
23
+ import os from 'node:os';
24
+ import { RUNS_DIR, eventsLogPath, isProcessAlive, loadActiveJob, loadLastJob, maskSecrets, } from './headless.js';
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+ // 定数
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+ /**
29
+ * 「最近の作業記録がある」と見なす上限(ms)。これを超えて記録が出ていなければ
30
+ * 実プロセスが動いていても「作業中」とは出さない(えふさん確定②)。
31
+ * ★長い1操作(試験の実行など)で記録が止まることがあるので、短くし過ぎない。
32
+ */
33
+ export const STALE_ACTIVITY_MS = 5 * 60_000;
34
+ /** 通常表示に出す「流れ」の行数の上限。 */
35
+ export const FLOW_LINES_MAX = 20;
36
+ /** 詳細表示に出す生ログの行数の上限。 */
37
+ export const DETAIL_LINES_MAX = 200;
38
+ /** 詳細表示の1行の長さの上限(文字)。生ログの1行は数千文字になるので切る。 */
39
+ export const DETAIL_LINE_CHARS_MAX = 600;
40
+ /** 画面に出す見出し(★文言はえふさん確定のまま)。 */
41
+ export const STATUS_LABEL = {
42
+ working: '🟠 作業中',
43
+ queued: '⏳ 順番待ち',
44
+ pc_down: '💤 PC停止中',
45
+ unconfirmed: '🟡 未確認',
46
+ awaiting_user: '🔵 操作・確認待ち',
47
+ done: '✅ 完了',
48
+ };
49
+ /** その状態が何を意味するかの一言(非エンジニアが読む)。 */
50
+ export const STATUS_NOTE = {
51
+ working: '職人が動いています',
52
+ queued: 'まだ職人を起動していません',
53
+ pc_down: 'このPCの見守り役が動いていません',
54
+ unconfirmed: '職人は止まった/終了しました(確認前です)',
55
+ awaiting_user: 'えふさんの手番です',
56
+ done: '終わりました',
57
+ };
58
+ /**
59
+ * 6つの状態のどれかを決める純粋関数(★「作業中」の条件を厳密にする所)。
60
+ *
61
+ * ★順番に意味がある。
62
+ * 1. 見守り役が止まっていれば、何も管制へ届かない=💤。
63
+ * 2. えふさんの手番は、動いているかどうかより先に出す(人を待たせないため)。
64
+ * 3. 実行中の仕事があるときは、実プロセスと最近の記録の【両方】がそろって初めて 🟠。
65
+ * そろわなければ 🟡(勝手に「作業中」と出さない)。
66
+ * 4. 実行中の仕事が無ければ、直前の結果が在れば ✅、無ければ ⏳。
67
+ */
68
+ export function decideSiteStatus(input) {
69
+ if (input.relayRunning === false)
70
+ return 'pc_down';
71
+ if (input.awaitingUser)
72
+ return 'awaiting_user';
73
+ if (input.hasActiveJob) {
74
+ if (!input.processAlive)
75
+ return 'unconfirmed';
76
+ if (input.sinceActivityMs === null)
77
+ return 'unconfirmed';
78
+ if (input.sinceActivityMs > STALE_ACTIVITY_MS)
79
+ return 'unconfirmed';
80
+ return 'working';
81
+ }
82
+ if (input.hasLastJob)
83
+ return 'done';
84
+ return 'queued';
85
+ }
86
+ // ─────────────────────────────────────────────────────────────────────────────
87
+ // 書式
88
+ // ─────────────────────────────────────────────────────────────────────────────
89
+ /** 時刻を「09:08」の形で出す。 */
90
+ export function hhmm(ms) {
91
+ const d = new Date(ms);
92
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
93
+ }
94
+ /**
95
+ * 日時を「今日09:08」「昨日20:47」「9/28 11:02」の形で出す(非エンジニア向け)。
96
+ * ★基準の時刻を渡せるようにして、試験が時計に依存しないようにする。
97
+ */
98
+ export function friendlyTime(ms, nowMs = Date.now()) {
99
+ const d = new Date(ms);
100
+ const startOfDay = (t) => {
101
+ const x = new Date(t);
102
+ x.setHours(0, 0, 0, 0);
103
+ return x.getTime();
104
+ };
105
+ const days = Math.round((startOfDay(nowMs) - startOfDay(ms)) / 86_400_000);
106
+ if (days === 0)
107
+ return `今日${hhmm(ms)}`;
108
+ if (days === 1)
109
+ return `昨日${hhmm(ms)}`;
110
+ return `${d.getMonth() + 1}/${d.getDate()} ${hhmm(ms)}`;
111
+ }
112
+ /** 経過時間を「26分」「2時間14分」の形で出す。 */
113
+ export function friendlyDuration(durationMs) {
114
+ const total = Math.max(0, Math.floor(durationMs / 60_000));
115
+ if (total < 60)
116
+ return `${total}分`;
117
+ const h = Math.floor(total / 60);
118
+ const m = total % 60;
119
+ return m === 0 ? `${h}時間` : `${h}時間${m}分`;
120
+ }
121
+ /**
122
+ * 便の本文から「仕事」の1行を作る。
123
+ *
124
+ * ★中央は題名を返していない(実測 2026-10-02: /commands が返すのは id と body)。
125
+ * だから本文の冒頭を使う。囲みの印([CC1返事案/…] など)と投函の前置きは落とす。
126
+ * ★題名が取れないときは空文字を返す(呼び出し側が「—」と出す=断定しない)。
127
+ */
128
+ export function jobTitleFromBody(body, max = 60) {
129
+ let head = (body ?? '').trim();
130
+ if (head.length === 0)
131
+ return '';
132
+ head = head.replace(/^\[[^\]]*\]\s*/, ''); // [CC1返事案/white] の囲みを落とす
133
+ head = head.replace(/^投函:.*?\/\s*現場:\s*\S+\s*/, ''); // 投函・現場の前置きを落とす
134
+ head = head.split('\n').map((l) => l.trim()).find((l) => l.length > 0) ?? '';
135
+ if (head.length === 0)
136
+ return '';
137
+ return [...head].length > max ? `${[...head].slice(0, max).join('')}…` : head;
138
+ }
139
+ /**
140
+ * 進捗の記録を読み、時刻つきの流れの行にする。
141
+ *
142
+ * ★出どころは「職人が書く操作の説明文」と「工程の区切りの1行」だけ(えふさん確定)。
143
+ * 追加の AI 呼び出しによる要約はしない。
144
+ * ★同じ文が続いたときは1行にまとめる(同じ説明文の道具を連続で使うと読みにくいため)。
145
+ */
146
+ export function flowLinesFromEvents(events, max = FLOW_LINES_MAX) {
147
+ const lines = [];
148
+ for (const e of events) {
149
+ if (e.type !== 'progress' && e.type !== 'tool' && e.type !== 'error' && e.type !== 'result')
150
+ continue;
151
+ const text = (e.text ?? '').trim();
152
+ if (text.length === 0)
153
+ continue;
154
+ const at = Date.parse(e.at);
155
+ if (!Number.isFinite(at))
156
+ continue;
157
+ const prefix = e.type === 'error' ? '⚠ ' : '';
158
+ const line = `${prefix}${text}`;
159
+ if (lines.length > 0 && lines[lines.length - 1].text === line)
160
+ continue;
161
+ lines.push({ at, text: line });
162
+ }
163
+ return lines.slice(-max);
164
+ }
165
+ /** 進捗の記録のファイルを読む(無ければ空)。 */
166
+ export function readFlowEvents(commandId, attempt) {
167
+ const p = eventsLogPath(commandId, attempt);
168
+ if (!existsSync(p))
169
+ return [];
170
+ const out = [];
171
+ for (const line of readFileSync(p, 'utf8').split('\n')) {
172
+ if (line.trim().length === 0)
173
+ continue;
174
+ try {
175
+ const e = JSON.parse(line);
176
+ if (typeof e.text === 'string' && typeof e.at === 'string')
177
+ out.push(e);
178
+ }
179
+ catch {
180
+ // 壊れている行は飛ばす
181
+ }
182
+ }
183
+ return out;
184
+ }
185
+ /** 生の記録ファイルのパス(詳細表示が読む)。 */
186
+ export function rawLogPath(commandId, attempt) {
187
+ return path.join(RUNS_DIR, commandId, `attempt-${attempt}.jsonl`);
188
+ }
189
+ /** 最後に記録が出た時刻(ms)。記録が無ければ null。 */
190
+ export function lastRecordAt(commandId, attempt) {
191
+ for (const p of [eventsLogPath(commandId, attempt), rawLogPath(commandId, attempt)]) {
192
+ try {
193
+ if (existsSync(p))
194
+ return statSync(p).mtimeMs;
195
+ }
196
+ catch {
197
+ // 読めないときは次を見る
198
+ }
199
+ }
200
+ return null;
201
+ }
202
+ /**
203
+ * えふさんの手番(確認待ち)かを生の記録から見る。
204
+ *
205
+ * ★フックの行は `system/hook_started` と `system/hook_response` が `hook_id` で対になる
206
+ * (実測 2026-10-02)。答えの行が来ていない PermissionRequest / Notification が
207
+ * 残っていれば、職人はその答えを待って止まっている=えふさんの手番。
208
+ */
209
+ export function awaitingUserFromRawLog(rawLines) {
210
+ const open = new Set();
211
+ for (const line of rawLines) {
212
+ if (line.trim().length === 0)
213
+ continue;
214
+ let row;
215
+ try {
216
+ row = JSON.parse(line);
217
+ }
218
+ catch {
219
+ continue;
220
+ }
221
+ if (row.type !== 'system')
222
+ continue;
223
+ const id = typeof row.hook_id === 'string' ? row.hook_id : null;
224
+ if (id === null)
225
+ continue;
226
+ const ev = String(row.hook_event ?? '');
227
+ if (row.subtype === 'hook_started' && (ev === 'PermissionRequest' || ev === 'Notification')) {
228
+ open.add(id);
229
+ }
230
+ else if (row.subtype === 'hook_response') {
231
+ open.delete(id);
232
+ }
233
+ }
234
+ return open.size > 0;
235
+ }
236
+ /**
237
+ * 生の記録から PR の番号を拾う。見つからなければ null。
238
+ * ★「確かめられたときだけ出す」ための関数。見つからないことを「PR はありません」とは読まない。
239
+ */
240
+ export function prNumberFromRawLog(rawLines) {
241
+ let found = null;
242
+ for (const line of rawLines) {
243
+ const m = /https:\/\/github\.com\/[^/\s"]+\/[^/\s"]+\/pull\/(\d+)/.exec(line);
244
+ if (m)
245
+ found = Number(m[1]);
246
+ }
247
+ return found;
248
+ }
249
+ // ─────────────────────────────────────────────────────────────────────────────
250
+ // 見守り役(中継)が動いているか
251
+ // ─────────────────────────────────────────────────────────────────────────────
252
+ /**
253
+ * この現場の中継(conductor@<現場名>)が動いているかを OS に聞く。
254
+ * 確かめられないときは null を返す(★「止まっています」と断定しない)。
255
+ *
256
+ * ★起動も再起動もしない。状態を聞くだけ。
257
+ */
258
+ export function relayRunningForSite(site) {
259
+ const probes = [
260
+ ['systemctl', ['--user', 'is-active', `conductor@${site}.service`]],
261
+ ['launchctl', ['print', `gui/${process.getuid?.() ?? 0}/com.tyhld.conductor.${site}`]],
262
+ ];
263
+ for (const [bin, args] of probes) {
264
+ try {
265
+ const out = execFileSync(bin, args, {
266
+ encoding: 'utf8',
267
+ timeout: 5_000,
268
+ stdio: ['ignore', 'pipe', 'ignore'],
269
+ });
270
+ return /\bactive\b|state = running/.test(out);
271
+ }
272
+ catch (err) {
273
+ const e = err;
274
+ // コマンドが無い(ENOENT)→ 次の方法を試す。
275
+ if (e?.code === 'ENOENT')
276
+ continue;
277
+ // コマンドは在って非0で返った=「動いていない」と答えた。
278
+ if (typeof e?.status === 'number')
279
+ return false;
280
+ continue;
281
+ }
282
+ }
283
+ return null;
284
+ }
285
+ /** 手元の記録だけを読んで SiteView を作る(★通信しない・何も起動しない)。 */
286
+ export function collectSiteView(site, nowMs = Date.now()) {
287
+ const active = loadActiveJob(site);
288
+ const last = loadLastJob(site);
289
+ const rawLines = (() => {
290
+ if (active === null)
291
+ return [];
292
+ const p = rawLogPath(active.commandId, active.attempt);
293
+ try {
294
+ if (!existsSync(p))
295
+ return [];
296
+ return readFileSync(p, 'utf8').split('\n');
297
+ }
298
+ catch {
299
+ return [];
300
+ }
301
+ })();
302
+ const lastActivityAt = active === null ? null : lastRecordAt(active.commandId, active.attempt);
303
+ const processAlive = active === null ? false : isProcessAlive(active.pid);
304
+ const awaitingUser = awaitingUserFromRawLog(rawLines);
305
+ const relayRunning = relayRunningForSite(site);
306
+ const status = decideSiteStatus({
307
+ relayRunning,
308
+ hasActiveJob: active !== null,
309
+ processAlive,
310
+ sinceActivityMs: lastActivityAt === null ? null : nowMs - lastActivityAt,
311
+ awaitingUser,
312
+ hasLastJob: last !== null,
313
+ });
314
+ const body = active?.body ?? last?.body ?? '';
315
+ return {
316
+ site,
317
+ status,
318
+ active,
319
+ last,
320
+ title: jobTitleFromBody(body),
321
+ postedAt: active?.postedAt ?? null,
322
+ startedAt: active?.startedAt ?? last?.startedAt ?? null,
323
+ lastActivityAt,
324
+ flow: active === null ? [] : flowLinesFromEvents(readFlowEvents(active.commandId, active.attempt)),
325
+ prNumber: prNumberFromRawLog(rawLines),
326
+ relayRunning,
327
+ nowMs,
328
+ };
329
+ }
330
+ const RULE = '────────────────────';
331
+ /**
332
+ * 通常表示(人間向けの日本語)を組み立てる。
333
+ * ★確かめられないことは「—」と出す。「ありません」と断定しない。
334
+ */
335
+ export function renderNormalView(v) {
336
+ const out = [];
337
+ const unknown = '—(確かめられません)';
338
+ out.push(`${v.site} ${STATUS_LABEL[v.status]} ${STATUS_NOTE[v.status]}`);
339
+ out.push(`仕事:${v.title.length > 0 ? v.title : unknown}`);
340
+ // ★投函・開始・実作業を別々に出す(えふさん確定③)。
341
+ out.push(`投函:${v.postedAt !== null ? friendlyTime(v.postedAt, v.nowMs) : '—(管制が持っています)'}`);
342
+ out.push(`開始:${v.startedAt !== null ? friendlyTime(v.startedAt, v.nowMs) : '—(まだ起動していません)'}`);
343
+ if (v.startedAt !== null) {
344
+ const until = v.status === 'working' ? v.nowMs : (v.lastActivityAt ?? v.last?.finishedAt ?? v.nowMs);
345
+ out.push(`実作業:${friendlyDuration(until - v.startedAt)}`);
346
+ }
347
+ else {
348
+ out.push('実作業:—');
349
+ }
350
+ out.push(`最後に動いた:${v.lastActivityAt !== null ? friendlyTime(v.lastActivityAt, v.nowMs) : unknown}`);
351
+ const current = v.flow.length > 0 ? v.flow[v.flow.length - 1].text : '';
352
+ out.push(`現在:${current.length > 0 ? current : unknown}`);
353
+ out.push(RULE);
354
+ if (v.flow.length > 0) {
355
+ for (const l of v.flow)
356
+ out.push(`${hhmm(l.at)} ${l.text}`);
357
+ }
358
+ else if (v.active !== null) {
359
+ out.push('(まだ進捗が出ていません)');
360
+ }
361
+ else if (v.last !== null) {
362
+ // 仕事が動いていないときは、直前の仕事の結果を出す。
363
+ out.push(`直前の仕事:${jobTitleFromBody(v.last.body) || unknown}`);
364
+ out.push(`結果:${LAST_OUTCOME_LABEL[v.last.outcome] ?? v.last.outcome}`);
365
+ if (v.last.detail !== undefined && v.last.detail.length > 0) {
366
+ out.push(`くわしく:${maskSecrets(v.last.detail)}`);
367
+ }
368
+ out.push(`終わった時刻:${friendlyTime(v.last.finishedAt, v.nowMs)}`);
369
+ }
370
+ else {
371
+ out.push('(この現場の記録はまだありません)');
372
+ }
373
+ out.push(RULE);
374
+ // ★PR・CI は確かめられたときだけ出す。
375
+ out.push(`PR:${v.prNumber !== null ? `#${v.prNumber}` : '—(この画面では確かめていません)'}`);
376
+ out.push('CI:—(この画面では確かめていません)');
377
+ if (v.relayRunning === null) {
378
+ out.push('※このPCの見守り役の状態は確かめられませんでした');
379
+ }
380
+ out.push('');
381
+ out.push('※見るだけの画面です(職人も作業部屋も見守り役も起動しません/入力は送れません)');
382
+ out.push('※Claude Code の生ログを見たいときは --detail を付けてください');
383
+ return out.join('\n');
384
+ }
385
+ /** 直前の仕事の終わり方の日本語。 */
386
+ export const LAST_OUTCOME_LABEL = {
387
+ done: '✅ 終わりました',
388
+ failed: '⚠ 失敗しました',
389
+ stopped: '⏹ 管制から止めました',
390
+ timeout: '⚠ 上限時間で止めました',
391
+ auth_error: '⚠ ログインが切れていました',
392
+ };
393
+ /**
394
+ * 詳細表示(Claude Code の生ログ)を組み立てる。
395
+ * ★秘密を隠してから出す。行数に上限を持つ。
396
+ */
397
+ export function renderDetailView(v, lines = []) {
398
+ const out = [];
399
+ out.push(`${v.site} 詳細(Claude Code の生ログ・末尾 ${DETAIL_LINES_MAX} 行まで)`);
400
+ out.push(RULE);
401
+ const tail = lines.filter((l) => l.trim().length > 0).slice(-DETAIL_LINES_MAX);
402
+ if (tail.length === 0) {
403
+ out.push('(生ログはまだありません)');
404
+ }
405
+ else {
406
+ for (const l of tail)
407
+ out.push(maskSecrets(l).slice(0, DETAIL_LINE_CHARS_MAX));
408
+ }
409
+ out.push(RULE);
410
+ out.push('※見るだけの画面です');
411
+ return out.join('\n');
412
+ }
413
+ /** 詳細表示に出す生ログの行を読む。 */
414
+ export function readRawLogLines(v) {
415
+ const rec = v.active ?? (v.last !== null ? { commandId: v.last.commandId, attempt: v.last.attempt } : null);
416
+ if (rec === null)
417
+ return [];
418
+ const p = rawLogPath(rec.commandId, rec.attempt);
419
+ try {
420
+ if (!existsSync(p))
421
+ return [];
422
+ return readFileSync(p, 'utf8').split('\n');
423
+ }
424
+ catch {
425
+ return [];
426
+ }
427
+ }
428
+ // ─────────────────────────────────────────────────────────────────────────────
429
+ // 入口(cli.ts が呼ぶ)
430
+ // ─────────────────────────────────────────────────────────────────────────────
431
+ /**
432
+ * `conductor view <現場名> [--detail]` の本体。
433
+ * ★何も起動しない・通信しない・入力を受け付けない。画面に出して終わる。
434
+ */
435
+ export function runViewCommand(argv) {
436
+ const detail = argv.includes('--detail');
437
+ const site = argv.find((a) => !a.startsWith('-'));
438
+ if (site === undefined || site.length === 0) {
439
+ console.error('使い方: conductor view <現場名> [--detail]');
440
+ return 2;
441
+ }
442
+ // ★現場のフォルダが無い=この PC の現場ではない。何も作らずに知らせる。
443
+ const projectDir = path.join(os.homedir(), 'projects', site);
444
+ if (!existsSync(projectDir)) {
445
+ console.error(`そんな現場はありません: ${site}`);
446
+ return 1;
447
+ }
448
+ const v = collectSiteView(site);
449
+ console.log(renderNormalView(v));
450
+ if (detail) {
451
+ console.log('');
452
+ console.log(renderDetailView(v, readRawLogLines(v)));
453
+ }
454
+ return 0;
455
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.72",
3
+ "version": "0.12.73",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,7 @@
22
22
  "scripts/update-guard.sh",
23
23
  "scripts/dist-source.sh",
24
24
  "scripts/conductor-run.sh",
25
+ "scripts/kc.sh",
25
26
  "scripts/codex",
26
27
  "!scripts/codex/__pycache__",
27
28
  "skills",
@@ -1041,14 +1041,16 @@ fi
1041
1041
  # =お客さまに tmux の3行を手で打ってもらう限り、同じ事故がいつでも起きる。
1042
1042
  # だから「kc <現場名>」の1語にまとめ、名前を間違えようがない形にする。
1043
1043
  #
1044
- # 【この中身は手で書いたものではない】
1045
- # Fshima の ~/.bashrc に入れて、ai-saku と migakia の2現場で実際に動かして
1046
- # 確かめた現物をそのまま移す。★作り直さない・引数を増やさない。
1044
+ # 【中身は ~/.bashrc に書かない(便 fbaba4b1)】
1045
+ # kc の実体は配布物の scripts/kc.sh で、設置係が ~/.tyhld/kc.sh へ置く。
1046
+ # ★ここで ~/.bashrc に足すのは「それを呼ぶだけ」の1枚。こうしておくと、次から
1047
+ # kc を直しても ~/.bashrc を書き換えに行かなくて済む(お客さまの手を借りない)。
1048
+ # 振る舞い(tmux 方式の3行・画面なし方式では見るだけの画面)は kc.sh が持つ。
1047
1049
  #
1048
1050
  # 【壊さないための決め】
1049
1051
  # ・~/.bashrc は【追記だけ】。既存の行は1つも書き換えない。
1050
1052
  # ・既に kc が定義されていれば何もしない(何度打っても二重にならない=冪等)。
1051
- # ・同じ名前の部屋が既にあれば【入るだけ】。動いている職人を作り直さない。
1053
+ # ・実体が無い機体では、今までどおり tmux 方式で開く控えを持たせる。
1052
1054
  # ═════════════════════════════════════════════════════════════════════════════
1053
1055
 
1054
1056
  step '⑫ 職人を呼ぶ合言葉(kc)を用意する'
@@ -1072,9 +1074,16 @@ else
1072
1074
 
1073
1075
  # ── 采配くん: 職人を呼ぶ合言葉 ────────────────────────────────────────────────
1074
1076
  # 使い方: kc <現場名> 例) kc my-app
1075
- # その現場の作業部屋(tmux)を用意し、職人(Claude Code)を開きます。
1076
- # ★部屋の名前は必ず現場名と同じにします(違うと指示が届きません)。
1077
+ # 画面なし方式の現場 … いま職人が何をしているかを「見るだけの画面」で出します。
1078
+ # tmux 方式の現場 … その現場の作業部屋(tmux)を用意し、職人を開きます。
1079
+ # ★中身は ~/.tyhld/kc.sh(采配くんの配布物)が持っています。ここは呼ぶだけなので、
1080
+ # 采配くんを更新すれば kc も新しくなります。このファイルは書き換えに来ません。
1077
1081
  kc() {
1082
+ if [ -r "$HOME/.tyhld/kc.sh" ]; then
1083
+ bash "$HOME/.tyhld/kc.sh" "$@"
1084
+ return $?
1085
+ fi
1086
+ # 実体がまだ無い機体のための控え(従来どおり tmux 方式で開きます)。
1078
1087
  local s="$1"
1079
1088
  if [ -n "$TMUX" ]; then echo "すでにtmuxの中です。Ctrl+b → d で一度抜けてください"; return 1; fi
1080
1089
  if [ -z "$s" ]; then echo "使い方: kc 現場名(例 kc TYHLD-platform)"; return 1; fi
@@ -1218,9 +1227,12 @@ ${BAR}
1218
1227
  それでも出ないときは、この画面をそのままサポートへお送りください。
1219
1228
 
1220
1229
  ${BAR}
1221
- 職人の画面を手で開きたいとき(tmux 方式・補助)
1230
+ いま職人が何をしているか見たいとき
1222
1231
  ${BAR}
1223
1232
 
1224
1233
  source ~/.bashrc && kc ${SITE}
1225
1234
 
1235
+ ★見るだけの画面です。職人を起動したり、口を出したりはしません。
1236
+ 生の記録まで見たいときは kc ${SITE} --detail
1237
+
1226
1238
  GUIDE
@@ -746,6 +746,24 @@ for guard_conf in "${GUARD_CONFIGS[@]}"; do
746
746
  fi
747
747
  done
748
748
 
749
+ # --- 合言葉 kc の実体を固定パスへ配置(便 fbaba4b1) -------------------------
750
+ # ★これまで kc の中身は ~/.bashrc の末尾に書き込まれていた。その形だと kc を直すたびに
751
+ # 全PCの ~/.bashrc を書き換えに行くことになる(お客さまの手で編集する=事故の種)。
752
+ # 番人と同じ「1台に1箇所」へ置き、~/.bashrc の kc は呼ぶだけにする。
753
+ KC_SRC="$SETUP_DIST_ROOT/scripts/kc.sh"
754
+ KC_DEST="$HOME/.tyhld/kc.sh"
755
+ if [[ -f "$KC_SRC" ]]; then
756
+ mkdir -p "$HOME/.tyhld"
757
+ if ! bash -n "$KC_SRC"; then
758
+ fail "合言葉 kc の実体の構文確認に失敗しました($KC_SRC)。固定パスは変更していません。"
759
+ fi
760
+ cp -f "$KC_SRC" "$KC_DEST"
761
+ chmod +x "$KC_DEST"
762
+ echo "[setup] 合言葉 kc の実体を配置: $KC_DEST(この1台の全現場が共有します)"
763
+ else
764
+ echo "[setup] 合言葉 kc の実体は配布物にありません(古い配布物。~/.bashrc の kc がそのまま使われます)"
765
+ fi
766
+
749
767
  # --- 番人フックを【ホームの ~/.claude/settings.json】へ1本だけ登録 ---
750
768
  # ★1台1箇所。ここに書けば、この機の全現場(全 tmux セッション)へ同時に効く。
751
769
  # 現場ごとの settings.json には番人フックを書かない=二重番人・取り残しを構造的に防ぐ(ADR-013)。
package/scripts/kc.sh ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bash
2
+ # 采配くん: 合言葉 kc の実体(便 fbaba4b1)
3
+ #
4
+ # 【なぜ配布物の中に置くか】
5
+ # これまで kc の中身は install.sh が ~/.bashrc の末尾へ書き込んでいた。
6
+ # ★その形だと、kc を直すたびに全PCの ~/.bashrc を書き換えに行くことになる
7
+ # (お客さまの手で編集する=事故の種)。だから中身はここ1か所に置き、
8
+ # ~/.bashrc の kc は「このファイルを呼ぶだけ」にする。
9
+ #
10
+ # 【2つの方式で振る舞いが違う】
11
+ # ・画面なし方式(headless)… 職人は便ごとに relay が起動する。
12
+ # ここで新しい Claude Code を開くと「便を持っていない空の職人」が開き、
13
+ # 裏で働いている本物の職人とは別物になる(実機 2026-10-02 に発生)。
14
+ # =★何も起動せず「見るだけの画面」(conductor view)を出す。
15
+ # ・tmux 方式(従来)… これまでどおり作業部屋を用意して職人を開く。
16
+ # ★振る舞いは1文字も変えない。
17
+ #
18
+ # 使い方: kc <現場名> [--detail]
19
+ set -uo pipefail
20
+
21
+ s="${1:-}"
22
+ shift || true
23
+
24
+ if [ -z "$s" ]; then
25
+ echo "使い方: kc 現場名(例 kc TYHLD-platform)"
26
+ exit 1
27
+ fi
28
+ if [ ! -d "$HOME/projects/$s" ]; then
29
+ echo "そんな現場はありません: $s"
30
+ ls "$HOME/projects" 2>/dev/null || true
31
+ exit 1
32
+ fi
33
+
34
+ # ── その現場が画面なし方式(headless)かを見る ────────────────────────────────
35
+ # ★ここで relay を起動しない・設定を書き換えない。在るものを読むだけ。
36
+ is_headless() {
37
+ # ① 人が明示した指定がいちばん強い。
38
+ case "${CONDUCTOR_RUNNER_MODE:-}" in
39
+ headless) return 0 ;;
40
+ tmux) return 1 ;;
41
+ esac
42
+ # ② Linux(systemd)… 常駐に実際に渡っている環境変数を読む。
43
+ if command -v systemctl >/dev/null 2>&1; then
44
+ if systemctl --user show -p Environment "conductor@$s.service" 2>/dev/null \
45
+ | grep -qF 'CONDUCTOR_RUNNER_MODE=headless'; then
46
+ return 0
47
+ fi
48
+ fi
49
+ # ③ 常駐が止まっていて②が読めないときの控え… drop-in のファイルを見る。
50
+ local d="$HOME/.config/systemd/user/conductor@$s.service.d"
51
+ if [ -d "$d" ] && grep -rqlF 'CONDUCTOR_RUNNER_MODE=headless' "$d" 2>/dev/null; then
52
+ return 0
53
+ fi
54
+ # ④ Mac(launchd)… plist に書かれた指定を見る。
55
+ local p="$HOME/Library/LaunchAgents/com.tyhld.conductor.$s.plist"
56
+ if [ -f "$p" ] && grep -qF 'headless' "$p" 2>/dev/null; then
57
+ return 0
58
+ fi
59
+ return 1
60
+ }
61
+
62
+ if is_headless; then
63
+ # ★見るだけの画面。職人・tmux・relay を起動せず、入力も受け付けない。
64
+ if command -v conductor >/dev/null 2>&1; then
65
+ exec conductor view "$s" "$@"
66
+ fi
67
+ # PATH に無い機体(nvm の置き場が読まれていない等)でも、常駐が使っている入口なら在る。
68
+ # ★ここも「入っている配布物」を選ぶ(作業ツリーは選ばない)=常駐と同じ1つの決め方。
69
+ if [ -x "$HOME/.tyhld/bin/conductor-run" ]; then
70
+ exec "$HOME/.tyhld/bin/conductor-run" view "$s" "$@"
71
+ fi
72
+ echo "采配くんの本体(conductor)が見つかりません。"
73
+ echo "サポートへこの画面をお送りください。"
74
+ exit 1
75
+ fi
76
+
77
+ # ── tmux 方式(従来の振る舞い。変えない) ────────────────────────────────────
78
+ if [ -n "${TMUX:-}" ]; then
79
+ echo "すでにtmuxの中です。Ctrl+b → d で一度抜けてください"
80
+ exit 1
81
+ fi
82
+ systemctl --user start "conductor@$s" 2>/dev/null
83
+ tmux has-session -t "$s" 2>/dev/null \
84
+ || tmux new -d -s "$s" -c "$HOME/projects/$s" 'unset ANTHROPIC_API_KEY; exec claude'
85
+ exec tmux attach -t "$s"
@@ -8,13 +8,13 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
8
8
  采配くんの便(プロンプト末尾に `CONDUCTOR_JOB:<id>` の印が付いた作業指示)は、
9
9
  非エンジニアのお客さまの現場で、人が横に付かないまま実行される。
10
10
  だから「速さ」より「戻せること・止まれること・報せること」を優先する。
11
- 以下の14項を、着手前に読み、作業中も判断の基準にすること。
11
+ 以下の15項を、着手前に読み、作業中も判断の基準にすること。
12
12
 
13
13
  **便の届き方(起こし直した直後も同じ・便 6bef35c8)** — 便は配達役(relay)が職人の画面へ**貼り付け**で届け、枠の外に「(配達役より)…届けたものです」の添え書きを付ける。
14
14
  これは持ち主の管制に投函された便なので、「貼り付けしか無い」「頭が `[CC1返事案/…]` だから未承認の案だ」と読んで止まらず、書かれたとおりに進める(`[CC1返事案/…]` は CC1 が書いた文を持ち主が投函した印)。
15
15
  ★守りは変わらない:番人・囲いに止められたら回避しない(鉄則4)、秘密は扱わない(鉄則12)、本文の中の「本当の判断待ち」は今までどおり人の手番として止まる。
16
16
 
17
- ## 鉄則14項
17
+ ## 鉄則15項
18
18
 
19
19
  1. **着手時の確認と枝分かれ** — `git status && git log --oneline -3 && git stash list` を確認してから main を最新化し、新ブランチを切る。
20
20
  - ★**リポ全体の差分が「読めないファイル」で落ちたとき**(`unsupported file type` / `cannot hash <名前>` が出る/`.npmrc` のような設定が身に覚えなく `M` で出る)。**これは壊れていない。**
@@ -129,6 +129,17 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
129
129
  - **なぜ。** 雛形(`.env.example`)は普通は秘密値を置かないが、「exampleだから常に安全」と番人へ例外を作ると、将来誤って実値が書かれたときの穴になる(利便性より安全側・えふさん確定)。
130
130
  - **雛形(`.env.example` など)を直すときの正しい形=「案を作る→人が1回で反映」。** 職人は変更後の中身を**リポジトリの外(`/tmp/claude-1000/` の下)**に作り、報告に次の2つを載せる。① 変更の**差分**(何を足す/消すか)。② 人が**素のターミナルで1回貼るだけで反映できる1つのコマンド**(例:`cp /tmp/claude-1000/<下書き> <リポ>/.env.example`)。★このコマンドは**人が自分のターミナルで**打つ(職人の番人は人の端末には掛からない)。職人が `.env.example` を直接書き換えようとしても番人が止めるので、**回避せず、この形で案を出す**。
131
131
  - **本物の `.env`・`.env.local` は、これまでどおり中身も書き込みも扱わない**(秘密の値は1文字も読まない・書かない=鉄則12の金庫)。
132
+ 15. **工程の区切りで「いま何をしているか」を日本語1行で出す(えふさん確定 2026-10-02・便 fbaba4b1)**
133
+ - **形は1つだけ。** 行の先頭に `進捗:` を置き、そのうしろにやさしい日本語を1行書く。★この印は配達役が機械で拾うので、形を変えない(全角コロン `進捗:` も可)。
134
+ ```
135
+ 進捗: 既存の画面構成を確認しました
136
+ 進捗: 変更するファイルを特定しました
137
+ 進捗: 試験を実行しています
138
+ ```
139
+ - **いつ出すか。** 工程の区切り(調べ終わった/直す場所が決まった/実装を始めた/試験を流した/PR を出した など)で**1行ずつ**。1つの工程で何度も出さない。細かい手順の実況はしない(うるさくなる)。
140
+ - **なぜ。** お客さま(非エンジニア)は `kc 現場名` の画面でこの1行を見る。これが無いと、画面に出るのは道具の名前だけで「いま何をしているか」が分からない。実機 2026-10-02 に「職人が動いているのに何も見えない」が起きたのが発端。
141
+ - **書き方。** 専門用語(rebase / squash / lint / CI 等)を避け、**何が進んだか**を書く。秘密の値・鍵・`.env` の中身は1文字も書かない(鉄則12)。
142
+ - **道具の一言説明(description)も同じ画面に出る。** だから下の「作業中の確認の一言説明」は、確認カードだけでなく**この画面の材料**でもある。英語で書かない・「何をするか」が一目で分かる日本語にする。
132
143
 
133
144
  ## 番人に止められる常連(こう書くと止まる → 代わりにこう書く)
134
145