@tyhld/conductor 0.12.53 → 0.12.54

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
@@ -13,7 +13,9 @@ import { execFileSync, spawnSync } from 'node:child_process';
13
13
  import { existsSync, readFileSync } from 'node:fs';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import os from 'node:os';
16
+ import path from 'node:path';
16
17
  import { RELAY_FETCH_TIMEOUT_MS, createRelayState, deliveryReport, parseAgentKind, reconnectDelayMs, relayTick, tmuxHasSession, pendingReportIds, sendPendingReportFiles, sweepProcessedReports, } from './relay.js';
18
+ import { createHeadlessState, headlessNextDelayMs, headlessTick, parseRunnerMode, } from './headless.js';
17
19
  import { createRelayRunner } from './relay-runner.js';
18
20
  import { installProcessGuards } from './process-guard.js';
19
21
  import { registerNudgeTarget, unregisterNudgeTarget, watchNudges, } from './nudge.js';
@@ -144,6 +146,9 @@ function loadConfig(args) {
144
146
  }
145
147
  promptIntervalSec = parsed;
146
148
  }
149
+ // ★配達方式(えふさん確定)。既定 'tmux'。'headless' で画面なし方式に切り替わる。
150
+ // 秘密ではないので ~/.conductor.env には入れない(systemd の drop-in か直接の export)。
151
+ const runnerMode = parseRunnerMode(process.env.CONDUCTOR_RUNNER_MODE);
147
152
  return {
148
153
  url: url.replace(/\/+$/, ''),
149
154
  token,
@@ -157,6 +162,7 @@ function loadConfig(args) {
157
162
  promptIntervalSec,
158
163
  agentKind,
159
164
  freshContext,
165
+ runnerMode,
160
166
  };
161
167
  }
162
168
  /** 1回分の報告を送る。送信結果(HTTPステータス)をログ表示する。 */
@@ -167,7 +173,8 @@ async function sendReport(cfg) {
167
173
  branch: detectBranch(),
168
174
  status: cfg.status,
169
175
  lastAction: cfg.lastAction,
170
- artisanReady: tmuxHasSession(cfg.site),
176
+ // headless 方式では tmux セッションは使わない。ジョブ実行中または空きで待機中なら ready。
177
+ artisanReady: cfg.runnerMode === 'headless' ? cfg.relayEnabled : tmuxHasSession(cfg.site),
171
178
  };
172
179
  // ★relay が動いている機だけ、2段目(いま配れるか)を足す。
173
180
  // 未配達でないときは秒数と指示idを載せない=管制で「遅れ」を数えない。
@@ -266,39 +273,60 @@ async function start(args) {
266
273
  // ポーリングの間隔はこれまでどおり(15秒/承認待ち3秒)。耳が死んでいる間の保険として残す。
267
274
  let relayRunner = null;
268
275
  let nudgeWatcher = null;
276
+ // ★headless 方式の状態参照(sendReport のアダプタ用)。tmux のときは null のまま。
277
+ let headlessStateRef = null;
269
278
  if (cfg.relayEnabled) {
270
- console.log(`[relay] 有効: site=${cfg.site} interval=${cfg.relayIntervalSec}s ` +
271
- `(承認待ちの間だけ ${cfg.promptIntervalSec}s に短縮)` +
272
- `(tmuxセッション "${cfg.site}" がアイドルのときだけ送信します)`);
273
- const relayState = createRelayState();
274
- // 生存報告に「いま配れるか」を載せるための参照(便 600d2a6a)。
275
- relayStateForReport = relayState;
276
- relayRunner = createRelayRunner({
277
- tick: () => relayTick(cfg, relayState, {
278
- // ★管制へつながらない状態から戻った瞬間に1回だけ呼ばれる。
279
- // 既にある口(現場の生存報告)を1回叩き、管制の一覧の札をすぐ正しく戻す。
280
- onCentralReconnect: () => {
281
- console.log('[relay] 管制へ現場の生存報告を1回送り直します(再接続の直後)');
282
- void sendReport(cfg);
279
+ if (cfg.runnerMode === 'headless') {
280
+ // ── headless 方式(画面なし・便ごとに claude -p を起動する) ──
281
+ const workDir = path.resolve(os.homedir(), 'projects', cfg.site);
282
+ console.log(`[headless] 有効: site=${cfg.site} mode=headless workDir=${workDir} ` +
283
+ `interval=${cfg.relayIntervalSec}s`);
284
+ const hState = createHeadlessState();
285
+ headlessStateRef = hState;
286
+ // 生存報告に「いま配れるか」を載せるため、内包する RelayState を参照する。
287
+ relayStateForReport = hState.relay;
288
+ const hCfg = { ...cfg, workDir };
289
+ relayRunner = createRelayRunner({
290
+ tick: () => headlessTick(hCfg, hState, {
291
+ onCentralReconnect: () => {
292
+ console.log('[headless] 管制へ現場の生存報告を1回送り直します(再接続の直後)');
293
+ void sendReport(cfg);
294
+ },
295
+ }),
296
+ nextDelayMs: () => headlessNextDelayMs(hState, cfg.relayIntervalSec),
297
+ onError: (err) => {
298
+ const msg = err instanceof Error ? err.message : String(err);
299
+ console.log(`[headless] site=${cfg.site} tick で例外: ${msg}(次回に再試行)`);
283
300
  },
284
- }),
285
- // ★管制へつながらない間だけ、再試行の間隔(5秒→10→20→40→60秒・上限後は60秒のまま永久に)。
286
- // つながっていれば従来どおり: 承認プロンプト待ちの間だけ短い間隔、それ以外は通常間隔
287
- // (★間隔は耳を付けても変えない=線が切れている間はこのポーリングがそのまま肩代わりする保険)。
288
- nextDelayMs: () => relayState.central.failures > 0
289
- ? reconnectDelayMs(relayState.central.failures)
290
- : (relayState.promptPending ? cfg.promptIntervalSec : cfg.relayIntervalSec) * 1000,
291
- onError: (err) => {
292
- // ループは絶対に止めない(1回の失敗で relay が死ぬと配達も承認も止まる)。
293
- const msg = err instanceof Error ? err.message : String(err);
294
- console.log(`[relay] site=${cfg.site} tick で例外: ${msg}(次回に再試行)`);
295
- },
296
- });
301
+ });
302
+ }
303
+ else {
304
+ // ── tmux 方式(従来どおり) ──
305
+ console.log(`[relay] 有効: site=${cfg.site} mode=tmux interval=${cfg.relayIntervalSec}s ` +
306
+ `(承認待ちの間だけ ${cfg.promptIntervalSec}s に短縮)` +
307
+ `(tmuxセッション "${cfg.site}" がアイドルのときだけ送信します)`);
308
+ const relayState = createRelayState();
309
+ // 生存報告に「いま配れるか」を載せるための参照(便 600d2a6a)。
310
+ relayStateForReport = relayState;
311
+ relayRunner = createRelayRunner({
312
+ tick: () => relayTick(cfg, relayState, {
313
+ onCentralReconnect: () => {
314
+ console.log('[relay] 管制へ現場の生存報告を1回送り直します(再接続の直後)');
315
+ void sendReport(cfg);
316
+ },
317
+ }),
318
+ nextDelayMs: () => relayState.central.failures > 0
319
+ ? reconnectDelayMs(relayState.central.failures)
320
+ : (relayState.promptPending ? cfg.promptIntervalSec : cfg.relayIntervalSec) * 1000,
321
+ onError: (err) => {
322
+ const msg = err instanceof Error ? err.message : String(err);
323
+ console.log(`[relay] site=${cfg.site} tick で例外: ${msg}(次回に再試行)`);
324
+ },
325
+ });
326
+ }
297
327
  void relayRunner.run(); // 起動直後に1回。以後は自分で次を積む。
298
328
  // ADR-066 便2c: 耳の常駐(conductor ear / この機に1本)からの呼び鈴を受ける。
299
- // ・自分の合図ファイル(空ファイル)を用意する=「この現場のrelayが動いている」の名簿登録も兼ねる。
300
- // ・呼び鈴が鳴ったらポーリング1周を前倒しするだけ。合図に中身は無く、指示は従来どおり取得APIから取る。
301
- // ・呼び鈴が来なくても・耳が落ちていても、上のポーリングがそのまま動く(耳は加速装置であって依存先ではない)。
329
+ // ★headless でも同じ呼び鈴を受ける(指示取得を前倒しする仕組みは方式を問わない)。
302
330
  const registered = registerNudgeTarget(cfg.site);
303
331
  if (registered !== null) {
304
332
  console.log(`[relay] 合図の受け口: ${registered}(耳からの「今すぐ1周」を受けます)`);
@@ -457,6 +485,9 @@ function printUsage() {
457
485
  ' CONDUCTOR_RELAY_INTERVAL_SEC relayポーリング間隔・秒(既定: 15)',
458
486
  ' CONDUCTOR_RELAY_PROMPT_INTERVAL_SEC 承認待ちの間だけ使う短い間隔・秒(既定: 3)',
459
487
  '',
488
+ '配達方式の切替(★PCごとに設定。relay を再起動すると切り替わる):',
489
+ ' CONDUCTOR_RUNNER_MODE tmux(既定)か headless。headless は便ごとに claude -p を起動する画面なし方式',
490
+ '',
460
491
  '管制につながらないとき(★relayは止まりません。待って再試行し、戻ったら自動で通常へ):',
461
492
  ' RELAY_FETCH_TIMEOUT_MS 管制への問い合わせ1回の上限・ミリ秒(既定: 20000)',
462
493
  ' RELAY_RECONNECT_BASE_MS 再試行の最初の待ち・ミリ秒(既定: 5000。以後 倍々に伸ばす)',
@@ -0,0 +1,570 @@
1
+ /**
2
+ * @tyhld/conductor — headless runner(画面なし方式)。
3
+ *
4
+ * tmux の代わりに、便ごとに `claude -p` を子プロセスで起動する。
5
+ *
6
+ * 【同時1指示】走る子プロセスは常に1つだけ。tmux 方式と同じ「1現場同時1指示」の不変条件を守る。
7
+ * 【入れ子防止】CLAUDECODE=1 は子プロセスの環境から外す(Claude Code が入れ子起動をブロックするため)。
8
+ * 【鍵の保護】ANTHROPIC_API_KEY も外す(今の tmux 起動と同じ。子プロセスは自分の鍵を使う)。
9
+ * 【秘密のマスク】進捗イベントを管制へ送る前に、鍵・トークン・パスワードをマスクする。
10
+ *
11
+ * 【なぜ切り出すか】headless 方式は tmux 方式とは配達・完了検出・状態管理が根本的に違う。
12
+ * tmux: 「常駐する職人の画面へ貼り付ける → 画面の DONE 行を監視する」
13
+ * headless: 「便ごとにプロセスを起動する → プロセスの終了を検出する」
14
+ * 同じ relayTick に分岐を詰め込むと壊れやすいので、独立したモジュールにする。
15
+ * 共通の管制との通信(fetchControl, fetchCommand, patchStatus 等)は relay.ts から使う。
16
+ */
17
+ import { spawn } from 'node:child_process';
18
+ import { createInterface } from 'node:readline';
19
+ import { RELAY_DELIVER_MAX, RELAY_FETCH_TIMEOUT_MS, RELAY_HEARTBEAT_INTERVAL_MS, RELAY_PENDING_TIMEOUT_MS, RELAY_RESTORE_ORPHANS_ENABLED, buildAnchor, buildSendText, canDeliver, completeWithReport, createRelayState, deliveryReport, fetchCommand, fetchCommandStatus, fetchControl, fetchOrphans, isTerminalCommandStatus, kindOf, markDelivered, noteCentralDown, noteCentralUp, noteReadiness, patchStatus, reconnectDelayMs, selectOrphanToRestore, sendHeartbeat, } from './relay.js';
20
+ const log = (msg) => {
21
+ console.log(`[headless] ${msg}`);
22
+ };
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+ // 定数
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+ /** 子プロセスへ SIGTERM を送ってから SIGKILL までの猶予(ms)。 */
27
+ export const HEADLESS_KILL_GRACE_MS = 10_000;
28
+ /** 1つの便を自動再開する最大試行回数(これを超えたら failed にして諦める)。 */
29
+ export const HEADLESS_MAX_ATTEMPTS = 3;
30
+ /** 進捗イベントのバッチ送信間隔(ms)。この間隔でバッファを管制へ流す。 */
31
+ export const HEADLESS_EVENT_FLUSH_MS = 2_000;
32
+ /** 実行中に stop_requested を確かめる間隔(ms)。 */
33
+ export const HEADLESS_STOP_CHECK_MS = 15_000;
34
+ /** イベントの text フィールドの上限(文字)。 */
35
+ export const EVENT_TEXT_MAX = 500;
36
+ /** resume 時に職人へ渡す続行指示。 */
37
+ const RESUME_PROMPT = '前回の作業の続きを行ってください。中断された箇所から再開してください。';
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+ // 秘密のマスク
40
+ // ─────────────────────────────────────────────────────────────────────────────
41
+ /** 秘密の値を検出するパターン。マッチした部分を *** に置換してからイベントを送る。 */
42
+ const SECRET_PATTERNS = [
43
+ /(?:CONDUCTOR_TOKEN|ANTHROPIC_API_KEY|API_KEY|SECRET_KEY|PASSWORD|DATABASE_URL|SUPABASE_SERVICE_ROLE_KEY)\s*[=:]\s*\S+/gi,
44
+ /Bearer\s+[A-Za-z0-9._~+/=-]{10,}/g,
45
+ /sk-ant-[A-Za-z0-9-]{20,}/g,
46
+ /sk-[A-Za-z0-9]{20,}/g,
47
+ ];
48
+ export function maskSecrets(text) {
49
+ let masked = text;
50
+ for (const re of SECRET_PATTERNS) {
51
+ // 毎回 lastIndex をリセット(gフラグ付き RegExp の再利用に必要)。
52
+ re.lastIndex = 0;
53
+ masked = masked.replace(re, (m) => {
54
+ const sepIdx = m.search(/[=:]\s*/);
55
+ if (sepIdx >= 0) {
56
+ const afterSep = m.indexOf(m[sepIdx]) + 1;
57
+ return m.slice(0, afterSep) + '***';
58
+ }
59
+ if (/^Bearer\s/i.test(m))
60
+ return 'Bearer ***';
61
+ return '***';
62
+ });
63
+ }
64
+ return masked;
65
+ }
66
+ // ─────────────────────────────────────────────────────────────────────────────
67
+ // ヘルパー
68
+ // ─────────────────────────────────────────────────────────────────────────────
69
+ /** commandId からセッション ID を作る(resume で再利用する決定的な値)。 */
70
+ export function sessionIdForCommand(commandId) {
71
+ return `conductor-${commandId}`;
72
+ }
73
+ /** stream-json の1行を CommandEvent に変換する。無関係な行は null。 */
74
+ export function streamEventToCommandEvent(parsed, attempt) {
75
+ const now = new Date().toISOString();
76
+ const type = parsed.type;
77
+ const subtype = parsed.subtype;
78
+ if (type === 'assistant' && subtype === 'text') {
79
+ const text = String(parsed.text ?? '').slice(0, EVENT_TEXT_MAX);
80
+ if (text.length === 0)
81
+ return null;
82
+ return { type: 'progress', text: maskSecrets(text), at: now, attempt };
83
+ }
84
+ if (type === 'assistant' && subtype === 'tool_use') {
85
+ const name = String(parsed.name ?? 'unknown');
86
+ return { type: 'tool', text: maskSecrets(`tool: ${name}`).slice(0, EVENT_TEXT_MAX), at: now, attempt };
87
+ }
88
+ if (type === 'result') {
89
+ if (subtype === 'error') {
90
+ const errText = String(parsed.error ?? '').slice(0, EVENT_TEXT_MAX);
91
+ return { type: 'error', text: maskSecrets(errText), at: now, attempt };
92
+ }
93
+ const resultText = String(parsed.result ?? '').slice(0, EVENT_TEXT_MAX);
94
+ return { type: 'result', text: maskSecrets(resultText), at: now, attempt };
95
+ }
96
+ return null;
97
+ }
98
+ /** 子プロセスの stderr から認証エラーを検出する。 */
99
+ export function isAuthError(job) {
100
+ const s = job.stderrBuf.toLowerCase();
101
+ return (s.includes('401') ||
102
+ s.includes('unauthorized') ||
103
+ s.includes('authentication failed') ||
104
+ s.includes('invalid api key') ||
105
+ s.includes('invalid_api_key'));
106
+ }
107
+ // ─────────────────────────────────────────────────────────────────────────────
108
+ // 子プロセスの起動と停止
109
+ // ─────────────────────────────────────────────────────────────────────────────
110
+ /**
111
+ * claude -p を子プロセスとして起動する。
112
+ *
113
+ * 【環境変数】
114
+ * CLAUDECODE を外す … 入れ子防止の解除。これが残っていると claude -p が起動しない。
115
+ * ANTHROPIC_API_KEY を外す … 今の tmux 起動と同じ。子プロセスは自分の鍵で認証する。
116
+ * CONDUCTOR_SITE, CONDUCTOR_MACHINE を渡す … フック(hooks)が管制と話すために使う。
117
+ *
118
+ * 【stdin】
119
+ * 初回: 指示本文(buildSendText で組み立てた1つのメッセージ)を書いて閉じる。
120
+ * resume: 続行指示を書いて閉じる。
121
+ *
122
+ * 【stdout】stream-json 形式。行ごとに JSON を出す。readline で1行ずつ読む。
123
+ * 【stderr】蓄積して認証エラーの検出に使う。
124
+ */
125
+ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
126
+ const env = { ...process.env };
127
+ delete env.CLAUDECODE;
128
+ delete env.ANTHROPIC_API_KEY;
129
+ env.CONDUCTOR_SITE = cfg.site;
130
+ env.CONDUCTOR_MACHINE = cfg.machine;
131
+ const args = [
132
+ '-p',
133
+ '--output-format', 'stream-json',
134
+ '--permission-mode', cfg.permissionMode ?? 'acceptEdits',
135
+ '--session-id', sessionId,
136
+ ];
137
+ if (isResume)
138
+ args.push('--resume');
139
+ const proc = spawn('claude', args, {
140
+ cwd: cfg.workDir,
141
+ env,
142
+ stdio: ['pipe', 'pipe', 'pipe'],
143
+ });
144
+ const job = {
145
+ commandId: cmd.id,
146
+ process: proc,
147
+ sessionId,
148
+ startedAt: Date.now(),
149
+ attempt,
150
+ completed: false,
151
+ exitCode: null,
152
+ exitSignal: null,
153
+ stopped: false,
154
+ eventBuffer: [],
155
+ lastFlushAt: Date.now(),
156
+ lastStopCheckAt: Date.now(),
157
+ stderrBuf: '',
158
+ };
159
+ // stdin: 指示本文を書いて閉じる
160
+ if (proc.stdin) {
161
+ if (isResume) {
162
+ proc.stdin.write(RESUME_PROMPT);
163
+ }
164
+ else {
165
+ const anchor = buildAnchor(cmd.id);
166
+ const text = buildSendText(cmd.body, cfg.site, anchor, kindOf(cfg));
167
+ proc.stdin.write(text);
168
+ }
169
+ proc.stdin.end();
170
+ }
171
+ // stdout: stream-json を行ごとに読み、CommandEvent に変換してバッファへ
172
+ if (proc.stdout) {
173
+ const rl = createInterface({ input: proc.stdout });
174
+ rl.on('line', (line) => {
175
+ try {
176
+ const parsed = JSON.parse(line);
177
+ const event = streamEventToCommandEvent(parsed, attempt);
178
+ if (event)
179
+ job.eventBuffer.push(event);
180
+ }
181
+ catch {
182
+ // JSON でないか壊れている行 — 無視
183
+ }
184
+ });
185
+ }
186
+ // stderr: 蓄積(認証エラー検出用。上限5000字で先頭を切り落とす)
187
+ if (proc.stderr) {
188
+ proc.stderr.on('data', (chunk) => {
189
+ job.stderrBuf += chunk.toString('utf8');
190
+ if (job.stderrBuf.length > 5000) {
191
+ job.stderrBuf = job.stderrBuf.slice(-3000);
192
+ }
193
+ });
194
+ }
195
+ // 終了検出
196
+ proc.on('exit', (code, signal) => {
197
+ job.completed = true;
198
+ job.exitCode = code;
199
+ job.exitSignal = signal?.toString() ?? null;
200
+ });
201
+ return job;
202
+ }
203
+ /**
204
+ * 子プロセスを停止する。SIGTERM → 猶予 → SIGKILL。
205
+ * プロセスが既に終わっていれば何もしない。
206
+ */
207
+ export async function stopJob(job) {
208
+ if (job.completed)
209
+ return;
210
+ const proc = job.process;
211
+ proc.kill('SIGTERM');
212
+ // 猶予の間に終了するのを待つ
213
+ const died = await Promise.race([
214
+ new Promise((resolve) => {
215
+ proc.once('exit', () => resolve(true));
216
+ }),
217
+ new Promise((resolve) => {
218
+ setTimeout(() => resolve(false), HEADLESS_KILL_GRACE_MS);
219
+ }),
220
+ ]);
221
+ if (!died && !job.completed) {
222
+ proc.kill('SIGKILL');
223
+ // SIGKILL は必ず効くので短く待つ
224
+ await new Promise((resolve) => {
225
+ const t = setTimeout(resolve, 1000);
226
+ proc.once('exit', () => {
227
+ clearTimeout(t);
228
+ resolve();
229
+ });
230
+ });
231
+ }
232
+ }
233
+ // ─────────────────────────────────────────────────────────────────────────────
234
+ // イベント送信
235
+ // ─────────────────────────────────────────────────────────────────────────────
236
+ /**
237
+ * 管制の events API へイベントを送る。
238
+ * POST /api/conductor/commands/{commandId}/events
239
+ */
240
+ export async function sendCommandEvents(cfg, commandId, events) {
241
+ if (events.length === 0)
242
+ return true;
243
+ try {
244
+ const res = await fetch(`${cfg.url}/api/conductor/commands/${commandId}/events`, {
245
+ method: 'POST',
246
+ headers: {
247
+ Authorization: `Bearer ${cfg.token}`,
248
+ 'Content-Type': 'application/json',
249
+ },
250
+ body: JSON.stringify({ events }),
251
+ signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS),
252
+ });
253
+ return res.ok;
254
+ }
255
+ catch {
256
+ return false;
257
+ }
258
+ }
259
+ /** バッファに溜まったイベントを管制へ送り、バッファを空にする。 */
260
+ export async function flushEvents(cfg, job) {
261
+ if (job.eventBuffer.length === 0)
262
+ return;
263
+ const events = job.eventBuffer.splice(0);
264
+ job.lastFlushAt = Date.now();
265
+ // 送信失敗は握り潰す(最善努力。進捗が欠けても作業は止まらない)。
266
+ await sendCommandEvents(cfg, job.commandId, events);
267
+ }
268
+ // ─────────────────────────────────────────────────────────────────────────────
269
+ // 実行中ジョブの監視
270
+ // ─────────────────────────────────────────────────────────────────────────────
271
+ /**
272
+ * 実行中のジョブを1tick分チェックする。
273
+ * 完了・停止・タイムアウト・生存通知・イベントフラッシュを処理する。
274
+ */
275
+ async function checkRunningJob(cfg, state) {
276
+ const job = state.job;
277
+ if (job === null)
278
+ return;
279
+ const rs = state.relay;
280
+ // ── 完了したプロセスの後始末 ──
281
+ if (job.completed) {
282
+ await flushEvents(cfg, job);
283
+ if (job.stopped) {
284
+ // stop_requested で止めた
285
+ await sendCommandEvents(cfg, job.commandId, [{
286
+ type: 'stopped',
287
+ text: `stop_requested により停止 (exit=${job.exitCode}, signal=${job.exitSignal})`,
288
+ at: new Date().toISOString(),
289
+ attempt: job.attempt,
290
+ }]);
291
+ state.job = null;
292
+ rs.lastHeartbeatAt = null;
293
+ log(`site=${cfg.site} id=${job.commandId} stop_requested で停止`);
294
+ return;
295
+ }
296
+ if (isAuthError(job)) {
297
+ // 認証エラー → 再試行しない
298
+ await sendCommandEvents(cfg, job.commandId, [{
299
+ type: 'auth_error',
300
+ text: maskSecrets(`認証エラー (exit=${job.exitCode}): ${job.stderrBuf.slice(0, 300)}`).slice(0, EVENT_TEXT_MAX),
301
+ at: new Date().toISOString(),
302
+ attempt: job.attempt,
303
+ }]);
304
+ // ★patchStatus は 'failed' を受け付けないので、放棄だけして中央の stalled/timeout に委ねる。
305
+ rs.abandoned.add(job.commandId);
306
+ state.job = null;
307
+ rs.lastHeartbeatAt = null;
308
+ log(`WARN site=${cfg.site} id=${job.commandId} 認証エラー → 再試行しない`);
309
+ return;
310
+ }
311
+ if (job.exitCode === 0) {
312
+ // 正常終了 → 完了を中央へ報告
313
+ const ok = await completeWithReport(cfg, job.commandId, log);
314
+ if (ok) {
315
+ log(`site=${cfg.site} id=${job.commandId} 完了(exit=0, attempt ${job.attempt})`);
316
+ }
317
+ else {
318
+ // 報告を書けなかった場合でも status=done だけは書く
319
+ await patchStatus(cfg, job.commandId, 'done');
320
+ log(`site=${cfg.site} id=${job.commandId} 完了(exit=0, 報告なし)`);
321
+ }
322
+ state.job = null;
323
+ rs.lastHeartbeatAt = null;
324
+ return;
325
+ }
326
+ // 異常終了 → 次の tick で自動再開を試みる
327
+ await sendCommandEvents(cfg, job.commandId, [{
328
+ type: 'error',
329
+ text: maskSecrets(`異常終了 (exit=${job.exitCode}, signal=${job.exitSignal}): ${job.stderrBuf.slice(0, 300)}`).slice(0, EVENT_TEXT_MAX),
330
+ at: new Date().toISOString(),
331
+ attempt: job.attempt,
332
+ }]);
333
+ const nextAttempt = (state.attempts.get(job.commandId) ?? job.attempt) + 1;
334
+ if (nextAttempt > HEADLESS_MAX_ATTEMPTS) {
335
+ log(`WARN site=${cfg.site} id=${job.commandId} 試行回数上限(${HEADLESS_MAX_ATTEMPTS}) → 放棄`);
336
+ rs.abandoned.add(job.commandId);
337
+ }
338
+ else {
339
+ log(`WARN site=${cfg.site} id=${job.commandId} 異常終了(exit=${job.exitCode}) → ` +
340
+ `次の tick で再開を試みます (attempt ${nextAttempt}/${HEADLESS_MAX_ATTEMPTS})`);
341
+ }
342
+ state.job = null;
343
+ rs.lastHeartbeatAt = null;
344
+ return;
345
+ }
346
+ // ── 実行中のプロセスの監視 ──
347
+ const now = Date.now();
348
+ // stop_requested の確認(間隔を置いて中央へ問い合わせる)
349
+ if (now - job.lastStopCheckAt >= HEADLESS_STOP_CHECK_MS) {
350
+ job.lastStopCheckAt = now;
351
+ const status = await fetchCommandStatus(cfg, job.commandId);
352
+ if (status === 'stop_requested' || isTerminalCommandStatus(status)) {
353
+ log(`site=${cfg.site} id=${job.commandId} ${status} → SIGTERM`);
354
+ job.stopped = true;
355
+ await stopJob(job);
356
+ return;
357
+ }
358
+ }
359
+ // タイムアウト
360
+ if (now - job.startedAt > RELAY_PENDING_TIMEOUT_MS) {
361
+ log(`WARN site=${cfg.site} id=${job.commandId} タイムアウト(${Math.round((now - job.startedAt) / 1000)}s) → SIGTERM`);
362
+ job.stopped = true;
363
+ await stopJob(job);
364
+ return;
365
+ }
366
+ // 生存ハートビート
367
+ const throttleOk = rs.lastHeartbeatAt === null || now - rs.lastHeartbeatAt >= RELAY_HEARTBEAT_INTERVAL_MS;
368
+ if (throttleOk) {
369
+ rs.lastHeartbeatAt = now;
370
+ await sendHeartbeat(cfg, job.commandId, true);
371
+ }
372
+ // イベントバッファのフラッシュ
373
+ if (now - job.lastFlushAt >= HEADLESS_EVENT_FLUSH_MS && job.eventBuffer.length > 0) {
374
+ await flushEvents(cfg, job);
375
+ }
376
+ // 可観測性
377
+ noteReadiness(rs, 'busy', job.commandId);
378
+ }
379
+ // ─────────────────────────────────────────────────────────────────────────────
380
+ // PC 再起動後の孤児復元
381
+ // ─────────────────────────────────────────────────────────────────────────────
382
+ /**
383
+ * sent孤児を1件取得し、--resume で再開する(起動時1回)。
384
+ *
385
+ * PC 再起動後、「やっています(sent)」のまま残った便を中央から見つけ、
386
+ * 決定的なセッション ID(conductor-{commandId})で resume する。
387
+ * Claude Code はセッションを ~/.claude/ に保存するので、同じセッション ID で
388
+ * 再開すれば前回の途中から続けられる。
389
+ *
390
+ * ★tmux 方式の restoreOrphans と同じ呼び出し経路(fetchOrphans → selectOrphanToRestore)
391
+ * を使う。headless では復元 = 即座に子プロセスを起動する(tmux のように pending に積んで待つ
392
+ * のではなく、直接 resume する)。
393
+ */
394
+ export async function restoreHeadlessOrphans(cfg, state) {
395
+ if (!RELAY_RESTORE_ORPHANS_ENABLED)
396
+ return;
397
+ if (state.job !== null)
398
+ return; // 既にジョブ実行中
399
+ const orphans = await fetchOrphans(cfg);
400
+ const pick = selectOrphanToRestore(orphans, null);
401
+ if (pick === null)
402
+ return;
403
+ const sessionId = sessionIdForCommand(pick.id);
404
+ const attempt = (state.attempts.get(pick.id) ?? 0) + 1;
405
+ state.attempts.set(pick.id, attempt);
406
+ if (attempt > HEADLESS_MAX_ATTEMPTS) {
407
+ state.relay.abandoned.add(pick.id);
408
+ log(`WARN site=${cfg.site} id=${pick.id} 孤児復元: 試行回数上限 → 放棄`);
409
+ return;
410
+ }
411
+ // ★孤児は resume で再開する(元の指示本文ではなく続行プロンプトを渡す)。
412
+ const cmd = { id: pick.id, body: pick.body };
413
+ const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, true);
414
+ state.job = job;
415
+ markDelivered(state.relay, pick.id);
416
+ state.relay.lastHeartbeatAt = null;
417
+ noteReadiness(state.relay, 'delivered', pick.id);
418
+ await sendCommandEvents(cfg, pick.id, [{
419
+ type: 'resumed',
420
+ text: `孤児復元: attempt ${attempt}/${HEADLESS_MAX_ATTEMPTS} (PC再起動後の自動再開)`,
421
+ at: new Date().toISOString(),
422
+ attempt,
423
+ }]);
424
+ log(`site=${cfg.site} id=${pick.id} ` +
425
+ `孤児を復元して再開しました (session=${sessionId}, attempt ${attempt}/${HEADLESS_MAX_ATTEMPTS})`);
426
+ }
427
+ // ─────────────────────────────────────────────────────────────────────────────
428
+ // headless tick(relayTick の headless 版)
429
+ // ─────────────────────────────────────────────────────────────────────────────
430
+ /**
431
+ * headless 方式の1tick。relayTick の代わりに createRelayRunner へ渡す。
432
+ *
433
+ * 【共通の管制通信】fetchControl, fetchCommand, patchStatus は relay.ts のものをそのまま使う。
434
+ * 【違い】配達は tmux ではなく子プロセスの起動。完了検出はプロセスの終了。
435
+ */
436
+ export async function headlessTick(cfg, state, hooks) {
437
+ const rs = state.relay;
438
+ if (rs.running)
439
+ return;
440
+ rs.running = true;
441
+ rs.promptPending = false;
442
+ try {
443
+ // ── 起動時1回の孤児復元(PC再起動後の拾い直し) ──
444
+ // cli.ts を触らず「起動時1回」を実現するため、最初の tick でだけ復元する。
445
+ // 失敗しても再試行しない(フラグを先に立てる=起動を妨げない/多重実行防止)。
446
+ if (!state.restored) {
447
+ state.restored = true;
448
+ await restoreHeadlessOrphans(cfg, state);
449
+ }
450
+ // ── 実行中のジョブの監視 ──
451
+ if (state.job !== null) {
452
+ await checkRunningJob(cfg, state);
453
+ return; // ジョブ実行中は新規取得しない
454
+ }
455
+ // ── 管制との線を確かめる ──
456
+ const ctl = await fetchControl(cfg);
457
+ if (!ctl.ok) {
458
+ log(noteCentralDown(rs, ctl.detail, Date.now()));
459
+ return;
460
+ }
461
+ const reconnected = noteCentralUp(rs, Date.now());
462
+ if (reconnected !== null) {
463
+ log(reconnected);
464
+ rs.lastHeartbeatAt = null;
465
+ hooks?.onCentralReconnect?.();
466
+ }
467
+ // ── 停止フラグ確認 ──
468
+ const haltedBySite = ctl.relayEnabled === false;
469
+ if (ctl.relayHalted || haltedBySite) {
470
+ if (rs.lastHalted !== true) {
471
+ log(haltedBySite
472
+ ? `この現場(${cfg.site})の relay送信トグルがOFF(停止)のため送信しません`
473
+ : 'relay停止中(緊急停止スイッチON)のため送信しません');
474
+ }
475
+ rs.lastHalted = true;
476
+ noteReadiness(rs, 'halted', null);
477
+ return;
478
+ }
479
+ if (rs.lastHalted === true) {
480
+ log('停止が解除されました。relayを再開します');
481
+ }
482
+ rs.lastHalted = false;
483
+ // ── 新規指示の取得 ──
484
+ const cmd = await fetchCommand(cfg);
485
+ if (!cmd) {
486
+ noteReadiness(rs, 'no-command', null);
487
+ return;
488
+ }
489
+ // ── 放棄ガード ──
490
+ if (rs.abandoned.has(cmd.id))
491
+ return;
492
+ // ── 配達回数上限 ──
493
+ if (!canDeliver(rs, cmd.id)) {
494
+ rs.abandoned.add(cmd.id);
495
+ log(`WARN site=${cfg.site} id=${cmd.id} 配達回数上限(${RELAY_DELIVER_MAX}) → 以後配達しない`);
496
+ return;
497
+ }
498
+ // ── 試行回数 ──
499
+ const attempt = (state.attempts.get(cmd.id) ?? 0) + 1;
500
+ state.attempts.set(cmd.id, attempt);
501
+ if (attempt > HEADLESS_MAX_ATTEMPTS) {
502
+ rs.abandoned.add(cmd.id);
503
+ log(`WARN site=${cfg.site} id=${cmd.id} 試行回数上限(${HEADLESS_MAX_ATTEMPTS}) → 放棄`);
504
+ return;
505
+ }
506
+ // ── claude -p を起動 ──
507
+ const sessionId = sessionIdForCommand(cmd.id);
508
+ const isResume = attempt > 1;
509
+ const job = spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume);
510
+ state.job = job;
511
+ markDelivered(rs, cmd.id);
512
+ rs.lastHeartbeatAt = null;
513
+ noteReadiness(rs, 'delivered', cmd.id);
514
+ // ★管制へ配達の印を書く
515
+ await patchStatus(cfg, cmd.id, 'sent');
516
+ if (isResume) {
517
+ await sendCommandEvents(cfg, cmd.id, [{
518
+ type: 'resumed',
519
+ text: `attempt ${attempt}/${HEADLESS_MAX_ATTEMPTS} (自動再開)`,
520
+ at: new Date().toISOString(),
521
+ attempt,
522
+ }]);
523
+ }
524
+ log(`site=${cfg.site} id=${cmd.id} ` +
525
+ `${isResume ? '再開' : '起動'}しました (session=${sessionId}, attempt ${attempt}/${HEADLESS_MAX_ATTEMPTS})`);
526
+ }
527
+ finally {
528
+ rs.running = false;
529
+ }
530
+ }
531
+ // ─────────────────────────────────────────────────────────────────────────────
532
+ // 状態の生成とアダプタ
533
+ // ─────────────────────────────────────────────────────────────────────────────
534
+ /** headless 用の初期状態を作る。内包する RelayState は createRelayState() で作る。 */
535
+ export function createHeadlessState() {
536
+ return {
537
+ relay: createRelayState(),
538
+ job: null,
539
+ attempts: new Map(),
540
+ restored: false,
541
+ };
542
+ }
543
+ /**
544
+ * headless 方式の配達状況を生存報告用に返す(cli.ts の sendReport が使う)。
545
+ * RelayState の deliveryReport をそのまま返す。
546
+ */
547
+ export function headlessDeliveryReport(state) {
548
+ return deliveryReport(state.relay);
549
+ }
550
+ /** headless 方式の次の tick までの待ち(ms)。cli.ts の適応スケジューラが使う。 */
551
+ export function headlessNextDelayMs(state, intervalSec) {
552
+ const rs = state.relay;
553
+ // 管制へつながらない間は再試行の間隔(指数バックオフ)
554
+ if (rs.central.failures > 0) {
555
+ return reconnectDelayMs(rs.central.failures);
556
+ }
557
+ // ジョブ実行中はイベントフラッシュ間隔で早く回す
558
+ if (state.job !== null) {
559
+ return Math.min(intervalSec * 1000, HEADLESS_EVENT_FLUSH_MS);
560
+ }
561
+ // 通常間隔
562
+ return intervalSec * 1000;
563
+ }
564
+ /** 環境変数から runner mode を読む。未設定・不明な値は 'tmux'(既定・後退させない)。 */
565
+ export function parseRunnerMode(raw) {
566
+ const v = (raw ?? '').trim().toLowerCase();
567
+ if (v === 'headless')
568
+ return 'headless';
569
+ return 'tmux';
570
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.53",
3
+ "version": "0.12.54",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {