@tyhld/conductor 0.12.53 → 0.12.55

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/dist/relay.js CHANGED
@@ -1094,8 +1094,8 @@ export function fullCompletionGuide(site, commandId) {
1094
1094
  `---\n` +
1095
1095
  `この囲みの下に、いつもどおりの報告本文を書いてください(本文が空だと未完成と見なされます)。\n` +
1096
1096
  `その後の最終行に必ず ${marker} とだけ出力してください。\n\n` +
1097
- // ★案D(relay非依存の確実な完了通知): 完了したら必ず devlog MCP の conductor_complete を呼ぶ。
1098
- `そして完了したら必ず、devlog の MCP ツール conductor_complete を commandId="${commandId}" で1回だけ呼んでください` +
1097
+ // ★案D(relay非依存の確実な完了通知): 完了したら必ず ai-kana MCP の conductor_complete を呼ぶ。
1098
+ `そして完了したら必ず、ai-kana の MCP ツール conductor_complete を commandId="${commandId}" で1回だけ呼んでください` +
1099
1099
  `(relayに依存しない確実な完了通知です。report 引数に上記の報告本文を添えても構いません)。\n\n` +
1100
1100
  // ★成果引数の案内(devlog-tracker PR #100): conductor_complete に任意の成果を添えられる。
1101
1101
  // すべて任意・分からなければ省略可=渡さなくても従来どおり完了する。既存の完了検出
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.53",
3
+ "version": "0.12.55",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -613,9 +613,13 @@ else
613
613
 
614
614
  doing "Node ${NODE_MAJOR_WANTED} を入れます"
615
615
  nvm install "$NODE_MAJOR_WANTED" || die "Node ${NODE_MAJOR_WANTED} を入れられませんでした"
616
+ # ★nvm alias default が lts/* のように解決できない値だと、nvm use default が
617
+ # 終了コード3で黙って落ちる(実測 2026-09-28 FshimaNP)。入れたばかりの版を
618
+ # 名指しで使ったうえで、既定もその版に上書きする。
619
+ nvm use "$NODE_MAJOR_WANTED" >/dev/null \
620
+ || die "Node ${NODE_MAJOR_WANTED} に切り替えられませんでした。nvm use ${NODE_MAJOR_WANTED} を手動でお試しください。"
616
621
  nvm alias default "$NODE_MAJOR_WANTED" >/dev/null \
617
622
  || die 'Node の既定の版を決められませんでした'
618
- nvm use default >/dev/null || die 'Node に切り替えられませんでした'
619
623
  say " 用意できました: $(node --version)"
620
624
  record "Node: 入れた($(node --version))"
621
625
  fi
@@ -896,12 +900,84 @@ CONSOLE_URL=''
896
900
  CONSOLE_TOKEN=''
897
901
 
898
902
  if [[ -f "$ENV_FILE" ]]; then
899
- skip '接続情報はあります'
900
903
  # 権限が緩んでいたら締める(常駐側も起動時に直すが、ここでも揃えておく)。
901
904
  if [[ "$DRY_RUN" != "1" ]]; then
902
905
  chmod 600 "$ENV_FILE" 2>/dev/null || true
903
906
  fi
904
- record '接続情報: 済み'
907
+ # ★合言葉が管制に通るかを確かめる(実測 2026-09-28: 古い合言葉が 401 なのに
908
+ # 「接続情報はある」で飛ばされ、あとで「指示が届かない」だけが起きた)。
909
+ # 通らなければ入れ直しを聞く。確かめられないとき(ネット不調)は従来どおりスキップ。
910
+ _ENV_URL=''; _ENV_TOKEN=''
911
+ if [[ "$DRY_RUN" != "1" ]]; then
912
+ while IFS='=' read -r k v; do
913
+ case "$k" in
914
+ CONDUCTOR_URL) _ENV_URL="$v" ;;
915
+ CONDUCTOR_TOKEN) _ENV_TOKEN="$v" ;;
916
+ esac
917
+ done < "$ENV_FILE"
918
+ fi
919
+ if [[ -n "$_ENV_URL" && -n "$_ENV_TOKEN" ]]; then
920
+ _check="$(CONDUCTOR_URL="$_ENV_URL" CONDUCTOR_TOKEN="$_ENV_TOKEN" python3 <<'PYEOF' 2>/dev/null || echo unreachable
921
+ import os, urllib.error, urllib.request
922
+ PING_ID = '00000000-0000-4000-8000-000000000000'
923
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
924
+ def redirect_request(self, *a, **k):
925
+ return None
926
+ url = (os.environ.get('CONDUCTOR_URL') or '').rstrip('/')
927
+ token = os.environ.get('CONDUCTOR_TOKEN') or ''
928
+ req = urllib.request.Request(
929
+ f'{url}/api/conductor/hook-requests/{PING_ID}',
930
+ method='GET', headers={'Authorization': f'Bearer {token}'})
931
+ try:
932
+ with urllib.request.build_opener(_NoRedirect).open(req, timeout=10) as res:
933
+ code = getattr(res, 'status', 0) or 0
934
+ print('ok' if 200 <= code < 300 else 'bad-url')
935
+ except urllib.error.HTTPError as e:
936
+ if e.code == 404 or 200 <= e.code < 300:
937
+ print('ok')
938
+ elif e.code in (401, 403):
939
+ print('bad-token')
940
+ elif 300 <= e.code < 400:
941
+ print('bad-url')
942
+ else:
943
+ print('unreachable')
944
+ except Exception:
945
+ print('unreachable')
946
+ PYEOF
947
+ )"
948
+ unset _ENV_TOKEN
949
+ case "$_check" in
950
+ ok)
951
+ skip '接続情報はあります(管制への接続も確認しました)'
952
+ record '接続情報: 済み(接続確認OK)'
953
+ ;;
954
+ bad-token)
955
+ say ''
956
+ say ' ★いまの合言葉では管制に拒否されました(401)。'
957
+ say ' 合言葉が変わった可能性があります。入れ直しますか?'
958
+ reenter=''
959
+ ask_tty reenter ' 入れ直す (y/N)' ''
960
+ if [[ "$(lower "$reenter")" == "y" ]]; then
961
+ rm -f "$ENV_FILE"
962
+ say ' 古い接続情報を消しました。もう一度この1行を打ってください。'
963
+ say ' (続きから進みます)'
964
+ exit 1
965
+ else
966
+ note '★入れ直さずに進みます。指示が届かないときは rm ~/.conductor.env のあと、もう一度この1行を打ってください'
967
+ skip '接続情報はあります(★合言葉が古い可能性があります)'
968
+ record '接続情報: 済み(★合言葉が古い可能性あり)'
969
+ fi
970
+ ;;
971
+ *)
972
+ # ★ネット不調は止めない。従来どおりスキップ。
973
+ skip '接続情報はあります'
974
+ record '接続情報: 済み'
975
+ ;;
976
+ esac
977
+ else
978
+ skip '接続情報はあります'
979
+ record '接続情報: 済み'
980
+ fi
905
981
  note '★入れ直したいときは rm ~/.conductor.env のあと、もう一度この1行を打ってください'
906
982
  elif [[ "$DRY_RUN" == "1" ]]; then
907
983
  plan '管制のアドレスと合言葉をお聞きして、接続情報を作ります'
@@ -1040,6 +1116,35 @@ else
1040
1116
  record '設置: 実行した'
1041
1117
  fi
1042
1118
 
1119
+ # ═════════════════════════════════════════════════════════════════════════════
1120
+ # ⑬½ 画面なし方式(headless)を既定にする
1121
+ #
1122
+ # ★新しいPCは画面なし方式で設置する(えふさん確定)。既存のPCは切り替えない
1123
+ # (プログラムの既定は tmux のまま)。ここは install.sh(新規設置の入口)だけが
1124
+ # 通るので、既存PCには影響しない。
1125
+ # ★systemd の drop-in で環境変数を渡す。ユニット本体は変えない。
1126
+ # ═════════════════════════════════════════════════════════════════════════════
1127
+
1128
+ UNIT_DIR_INST="$HOME/.config/systemd/user"
1129
+ HEADLESS_DROPIN_DIR="$UNIT_DIR_INST/conductor@${SITE}.service.d"
1130
+ HEADLESS_DROPIN="$HEADLESS_DROPIN_DIR/headless.conf"
1131
+
1132
+ if [[ "$DRY_RUN" == "1" ]]; then
1133
+ plan '画面なし方式(headless)を設定します'
1134
+ else
1135
+ mkdir -p "$HEADLESS_DROPIN_DIR"
1136
+ cat > "$HEADLESS_DROPIN" <<'DROPIN'
1137
+ # install.sh が設定: 画面なし方式(headless)で動かす。
1138
+ # ★tmux に切り替えたいときはこのファイルを消して systemctl --user daemon-reload。
1139
+ [Service]
1140
+ Environment=CONDUCTOR_RUNNER_MODE=headless
1141
+ DROPIN
1142
+ systemctl --user daemon-reload 2>/dev/null || true
1143
+ systemctl --user restart "conductor@${SITE}.service" 2>/dev/null || true
1144
+ say " 画面なし方式を設定しました(drop-in: ${HEADLESS_DROPIN})"
1145
+ record '方式: 画面なし(headless)'
1146
+ fi
1147
+
1043
1148
  # ═════════════════════════════════════════════════════════════════════════════
1044
1149
  # ⑭ 締め(★自己点検の○×は、すぐ上に設置係が出しています)
1045
1150
  # ═════════════════════════════════════════════════════════════════════════════
@@ -1073,16 +1178,11 @@ ${BAR}
1073
1178
  ✗ が残っているときは、その行に書いてある直し方をお試しください。
1074
1179
 
1075
1180
  ${BAR}
1076
- 次にやること — 職人(Claude Code)を動かす
1181
+ これで準備は終わりです
1077
1182
  ${BAR}
1078
1183
 
1079
- 下の2行を、そのままコピーして貼ってください。
1080
-
1081
- source ~/.bashrc
1082
- kc ${SITE}
1083
-
1084
- (1行目は、いま用意した合言葉をこの画面に読み込みます。
1085
- 2行目で職人が開きます。★次からは「kc ${SITE}」の1行だけで開けます)
1184
+ 管制から便を出すと自動で動きます。
1185
+ 職人の画面を手で開く必要はありません。
1086
1186
 
1087
1187
  ${BAR}
1088
1188
  いちばん確実な「動いています」の確かめ方
@@ -1091,7 +1191,13 @@ ${BAR}
1091
1191
  管制の画面(采配くん)をブラウザで開いてください。
1092
1192
  そこに「${SITE}」が出ていれば、つながって動いています。
1093
1193
 
1094
- 出てこないときは、職人の画面を開いたまま1〜2分待って、開き直してください。
1194
+ 出てこないときは1〜2分待って、開き直してください。
1095
1195
  それでも出ないときは、この画面をそのままサポートへお送りください。
1096
1196
 
1197
+ ${BAR}
1198
+ 職人の画面を手で開きたいとき(tmux 方式・補助)
1199
+ ${BAR}
1200
+
1201
+ source ~/.bashrc && kc ${SITE}
1202
+
1097
1203
  GUIDE
@@ -108,6 +108,31 @@ MIN_CONDUCTOR_VERSION="0.12.3"
108
108
  fail() { echo; echo "[setup] エラー: $1" >&2; exit 1; }
109
109
  warn() { echo "[setup] warn: $1" >&2; }
110
110
 
111
+ # 入力元。curl | bash では /dev/tty から読む。テスト等で端末が無い場合は標準入力に落ちる。
112
+ # install.sh の CONDUCTOR_INSTALL_TTY と同じ差し替え口を使う。
113
+ _SETUP_TTY="${CONDUCTOR_INSTALL_TTY:-/dev/tty}"
114
+ # ★/dev/tty は「端末が無いプロセス」でもファイルとしては在る(-r が真を返す)。
115
+ # 実際に開けるかで判定する。開けなければ標準入力に落ちる。
116
+ _SETUP_TTY_OK=0
117
+ if exec 8<"$_SETUP_TTY" 2>/dev/null; then
118
+ exec 8<&-
119
+ _SETUP_TTY_OK=1
120
+ fi
121
+
122
+ # setup_read — /dev/tty があれば使い、無ければ標準入力から読む。
123
+ # 使い方: setup_read [-s] <変数名> <プロンプト>
124
+ setup_read() {
125
+ local silent='' var prompt
126
+ if [[ "${1:-}" == "-s" ]]; then silent='-s'; shift; fi
127
+ var="$1"; prompt="$2"
128
+ # shellcheck disable=SC2229 # $silent は動的な旗、$var は動的な変数名。意図どおり。
129
+ if [[ "$_SETUP_TTY_OK" == "1" ]]; then
130
+ IFS= read -r $silent -p "$prompt" "$var" <"$_SETUP_TTY" || printf -v "$var" ''
131
+ else
132
+ IFS= read -r $silent -p "$prompt" "$var" || printf -v "$var" ''
133
+ fi
134
+ }
135
+
111
136
  # version_lt <a> <b> — a が b より古いか。
112
137
  # ★`sort -V` に頼らない(Mac の sort には無い版がある=ADR-019 で Mac も対象)。
113
138
  # 数字3つ(major.minor.patch)だけを見る。読めない字は 0 とみなす(安全側=古い扱い)。
@@ -512,12 +537,12 @@ else
512
537
  echo "[setup] ★この2つは弊社からお渡しした値です。手元の案内をご覧ください。"
513
538
  echo
514
539
 
515
- read -r -p " ① 管制のアドレス(CONDUCTOR_URL・例: https://…): " input_url
540
+ setup_read input_url " ① 管制のアドレス(CONDUCTOR_URL・例: https://…): "
516
541
  [[ -z "$input_url" ]] && fail "管制のアドレスが空です。"
517
542
 
518
543
  # ★合言葉は画面に出さない(-s)。画面共有・録画に残さないため。
519
544
  # 何も出ないと固まったと思われるので、受け取ったことを必ず1行返す。
520
- read -r -s -p " ② 合言葉(CONDUCTOR_TOKEN・入力しても画面には出ません): " input_secret
545
+ setup_read -s input_secret " ② 合言葉(CONDUCTOR_TOKEN・入力しても画面には出ません): "
521
546
  echo
522
547
  [[ -z "$input_secret" ]] && fail "合言葉が空です。"
523
548
  echo "[setup] 受け取りました(${#input_secret} 文字)。画面には表示していません。"
@@ -821,7 +846,8 @@ fi
821
846
  if [[ -f "$SETTINGS_SRC" ]]; then
822
847
  if [[ -f "$SETTINGS_DST" ]]; then
823
848
  echo "[setup] $SETTINGS_DST は既に存在します。上書きしますか?"
824
- read -r -p " 上書き (y/N): " overwrite
849
+ setup_read overwrite " 上書き (y/N): "
850
+ # shellcheck disable=SC2154 # overwrite は setup_read で間接代入される
825
851
  if [[ "$(lower "$overwrite")" != "y" ]]; then
826
852
  echo "[setup] 現場の settings.json をスキップしました。"
827
853
  else
@@ -843,9 +869,10 @@ if [[ -f "$SETTINGS_SRC" ]]; then
843
869
  # Supabase 設定
844
870
  if [[ -f "$SETTINGS_DST" ]] && grep -q '<SUPABASE_PROJECT_ID>' "$SETTINGS_DST"; then
845
871
  echo
846
- read -r -p "[setup] Supabase を使いますか? (y/N): " use_supabase
872
+ setup_read use_supabase "[setup] Supabase を使いますか? (y/N): "
873
+ # shellcheck disable=SC2154 # use_supabase は setup_read で間接代入される
847
874
  if [[ "$(lower "$use_supabase")" == "y" ]]; then
848
- read -r -p " Supabase Project Reference ID: " supabase_id
875
+ setup_read supabase_id " Supabase Project Reference ID: "
849
876
  if [[ -n "$supabase_id" ]]; then
850
877
  sed_replace "s|<SUPABASE_PROJECT_ID>|${supabase_id}|g" "$SETTINGS_DST"
851
878
  echo "[setup] <SUPABASE_PROJECT_ID> → $supabase_id に置換"
@@ -885,7 +912,8 @@ if [[ "$PROFILE" == "proto" ]]; then
885
912
  write_env_example=1
886
913
  if [[ -f "$ENV_EXAMPLE_DST" ]]; then
887
914
  echo "[setup] $ENV_EXAMPLE_DST は既に存在します。上書きしますか?"
888
- read -r -p " 上書き (y/N): " overwrite_env
915
+ setup_read overwrite_env " 上書き (y/N): "
916
+ # shellcheck disable=SC2154 # overwrite_env は setup_read で間接代入される
889
917
  [[ "$(lower "$overwrite_env")" == "y" ]] || write_env_example=0
890
918
  fi
891
919
  if [[ "$write_env_example" == "1" ]]; then
@@ -156,7 +156,7 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
156
156
  ```
157
157
  この囲みの下に、いつもどおりの報告本文を書く(本文が空だと未完成と見なされる)。
158
158
  ③ **その後の最終行に、配達役の1行にある `DONE:<現場>` とだけ出力する**(完了の合図)。
159
- ④ **devlog の MCP ツール `conductor_complete` を、配達役の1行にある commandId で1回だけ呼ぶ**(relay に依存しない確実な完了通知)。report 引数に上の報告本文を添えてもよい。
159
+ ④ **ai-kana の MCP ツール `conductor_complete` を、配達役の1行にある commandId で1回だけ呼ぶ**(relay に依存しない確実な完了通知)。report 引数に上の報告本文を添えてもよい。
160
160
  - **nextWork(次の独立作業候補)は必ず書く。** 無ければ「なし」と、なぜ無いかの理由を書く。
161
161
  - 可能なら次も一緒に渡す(すべて任意・分かる範囲でよく、渡さなくても完了する):`branch=`作った作業ブランチ名(`git rev-parse --abbrev-ref HEAD`)、`baseBranch=`分岐元(通常 main)、`headSha=`最後のコミット(短縮可)、`pushed=`push したか(true/false)、`filesChanged=`変更したファイル数、`prTitle=`お客さま(非エンジニア)がそのまま読む題名(rebase/squash/lint 等の専門用語を避け、やさしい日本語で)、`prNumber=`既に PR がある場合のみ、`outcome=`pushed(変更して push した)/no_change(変更不要だった)/blocked(衝突・要判断・許可待ち等で完了できなかった)のどれか。
162
162
  - **blocked のときも必ず conductor_complete を呼び、report に理由を書く。**