@tyhld/conductor 0.12.92 → 0.12.93

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/headless.js CHANGED
@@ -996,6 +996,41 @@ function closeLog(ws) {
996
996
  * 失敗の理由は lastSpawnError に入る。
997
997
  */
998
998
  export let lastSpawnError = '';
999
+ /** 職人の PATH で「古い node が先に来ない」ように見る道具の名前。 */
1000
+ const NODE_TOOLS = ['node', 'npm', 'npx'];
1001
+ /**
1002
+ * 職人(claude -p)の子プロセスへ渡す PATH を作る(便 24c9b6b6・e9276b86)。
1003
+ *
1004
+ * 【なぜ要るか(実物 2026-10-03・TYHLD-OTA 便 f770cd9d/ノートPC 便 4cf71dde)】
1005
+ * systemd の常駐はシェルの PATH を継承しないので、nvm の bin が職人の PATH に無い。
1006
+ * 以前は claude が nvm の bin に居たので、claude の bin を足すだけで npm・npx も一緒に見えていた。
1007
+ * Claude Code を公式の入れ方(~/.local/bin/claude)にそろえた後は、足されるのが ~/.local/bin だけになり、
1008
+ * 職人の bash で `npm: command not found`。node だけは /usr/local/bin/node(nvm への近道)で見えていた。
1009
+ * ★/usr/bin/node(v18)が別に在る機もあるので、PATH の順が崩れると古い node が動く。
1010
+ *
1011
+ * 【決め方】
1012
+ * - 中継を動かしている node(process.execPath=conductor-run が選んだ配布物の node)の bin を必ず足す。
1013
+ * =職人の node・npm・npx は、中継と同じ版になる(その bin に npm・npx が同居する入れ方=nvm 等)。
1014
+ * - claude の bin も足す(便 24c9b6b6)。ただし claude の bin に node・npm・npx のどれかが在るとき
1015
+ * (/usr/bin・nvm の別の版など)は、中継の node の bin をその前に置く=古い node が先に来ない。
1016
+ * claude の bin に node 類が無いとき(~/.local/bin などの公式の入れ方)は、claude の bin を先に置く
1017
+ * =職人の bash の `claude` は中継が選んだ claude のまま。
1018
+ * - 2つは既に PATH に在っても先頭へ寄せる(後ろに残っていると、先に在る古い node が勝つため)。重複させない。
1019
+ * - PATH が空でも動く。
1020
+ */
1021
+ export function buildWorkerPath(currentPath, claudeBin, nodeExecPath = process.execPath, hasTool = (dir, name) => existsSync(path.join(dir, name))) {
1022
+ const nodeDir = path.dirname(nodeExecPath);
1023
+ const claudeDir = path.dirname(claudeBin);
1024
+ let front;
1025
+ if (nodeDir === claudeDir)
1026
+ front = [nodeDir];
1027
+ else if (NODE_TOOLS.some((t) => hasTool(claudeDir, t)))
1028
+ front = [nodeDir, claudeDir];
1029
+ else
1030
+ front = [claudeDir, nodeDir];
1031
+ const rest = (currentPath ?? '').split(':').filter((d) => d !== '' && !front.includes(d));
1032
+ return [...front, ...rest].join(':');
1033
+ }
999
1034
  export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
1000
1035
  // 前の失敗の理由を持ち越さない(理由の無い失敗に古い理由が付かないように)
1001
1036
  lastSpawnError = '';
@@ -1025,15 +1060,9 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
1025
1060
  env.CONDUCTOR_SITE = cfg.site;
1026
1061
  env.CONDUCTOR_MACHINE = cfg.machine;
1027
1062
  env.CONDUCTOR_WORK_DIR = cfg.workDir;
1028
- // ★claude が見つかった bin ディレクトリを PATH の先頭に足す(便 24c9b6b6)。
1029
- // systemd 経由の起動では nvm の bin が PATH に無いため、子プロセスの bash から
1030
- // claude・node・npm・pnpm が command not found になる。resolveClaudeBin() が
1031
- // 信頼したディレクトリだけを足す。重複させない・PATH が空でも動く。
1032
- const claudeBinDir = path.dirname(claudeBin);
1033
- const currentPath = env.PATH ?? '';
1034
- if (!currentPath.split(':').includes(claudeBinDir)) {
1035
- env.PATH = currentPath ? `${claudeBinDir}:${currentPath}` : claudeBinDir;
1036
- }
1063
+ // ★職人の PATH の先頭に、中継を動かしている node の bin と claude の bin を足す
1064
+ // (便 24c9b6b6・e9276b86)。決め方は buildWorkerPath の説明を見ること。
1065
+ env.PATH = buildWorkerPath(env.PATH, claudeBin);
1037
1066
  // ★テスト用: CONDUCTOR_CLAUDE_CONFIG_DIR を設定すると、claude の設定フォルダを差し替える。
1038
1067
  // 本物のログインを壊さずに認証エラーを再現できる(空のフォルダを指す → not logged in)。
1039
1068
  // 本番では設定しない(claude は既定の ~/.claude を使う)。
@@ -22,6 +22,14 @@ export const EAR_REFRESH_FALLBACK_MS = 8 * 60 * 60 * 1000;
22
22
  /** 再接続の待ち(指数バックオフ)の初期値と上限。 */
23
23
  export const EAR_RETRY_BASE_MS = 5 * 1000;
24
24
  export const EAR_RETRY_MAX_MS = 5 * 60 * 1000;
25
+ /**
26
+ * 鍵が通らない(401・403)ときの聞き直しの待ち(指数バックオフ)の初期値と上限(便 e9276b86)。
27
+ * 【実物 2026-10-03 03:04〜03:24Z】鍵が通らない機から realtime-token が 2〜4秒おきに聞き直され、20分で 401 が189件。
28
+ * 鍵は待っても直らない(人が入れ直すまで同じ答え)ので、ふつうの切断(5秒から)より長く空ける。
29
+ * ★1分 → 2分 → 4分 …と広げ、30分で頭打ち。つながっているときの速さ(合図・取り直し)は変えない。
30
+ */
31
+ export const EAR_AUTH_RETRY_BASE_MS = 60 * 1000;
32
+ export const EAR_AUTH_RETRY_MAX_MS = 30 * 60 * 1000;
25
33
  /** 購読設定が無い(unsupported)ときに問い直す間隔(ms)。中央の設定投入に自然に追従するため。 */
26
34
  export const EAR_UNSUPPORTED_RETRY_MS = 30 * 60 * 1000;
27
35
  /**
@@ -108,6 +116,15 @@ export function computeRefreshDelayMs(expiresAt, nowMs) {
108
116
  return clamp(EAR_REFRESH_FALLBACK_MS);
109
117
  return clamp(at - nowMs - EAR_REFRESH_MARGIN_MS);
110
118
  }
119
+ /** 中央の答えが「鍵が通らない」か(401・403)。 */
120
+ export function isAuthRejected(r) {
121
+ return r.kind === 'error' && (r.status === 401 || r.status === 403);
122
+ }
123
+ /** 鍵が通らないときの待ち(ms)。続けて断られた回数に応じて 1分 → 2分 → 4分 …と広げ、30分で頭打ち。 */
124
+ export function computeAuthRetryDelayMs(failures) {
125
+ const n = failures < 0 ? 0 : failures;
126
+ return Math.min(EAR_AUTH_RETRY_MAX_MS, EAR_AUTH_RETRY_BASE_MS * 2 ** Math.min(n, 20));
127
+ }
111
128
  /** 再接続の待ち(ms)。試行回数に応じて 5s → 10s → 20s …と広げ、5分で頭打ちにする。 */
112
129
  export function computeRetryDelayMs(attempt) {
113
130
  const n = attempt < 0 ? 0 : attempt;
package/dist/realtime.js CHANGED
@@ -30,7 +30,7 @@
30
30
  * 読めなければ「耳なし=各relayのポーリングのみ」で静かに動き続ける。
31
31
  */
32
32
  import { describeTransport, resolveWebSocketTransport } from './websocket-transport.js';
33
- import { EAR_NO_SITE_RETRY_MS, EAR_NUDGE_MIN_GAP_MS, EAR_TOKEN_FETCH_TIMEOUT_MS, EAR_UNSUPPORTED_RETRY_MS, computeRefreshDelayMs, computeRetryDelayMs, parseRealtimeConnection, readSignalSite, } from './realtime-parse.js';
33
+ import { EAR_NO_SITE_RETRY_MS, EAR_NUDGE_MIN_GAP_MS, EAR_TOKEN_FETCH_TIMEOUT_MS, EAR_UNSUPPORTED_RETRY_MS, computeAuthRetryDelayMs, computeRefreshDelayMs, computeRetryDelayMs, isAuthRejected, parseRealtimeConnection, readSignalSite, } from './realtime-parse.js';
34
34
  // 読み取りと間合いの計算は src/realtime-parse.ts に分けてある(外部I/Oなし=単体テスト可能)。
35
35
  // 従来どおり `./realtime.js` からも使えるよう、ここで通しておく。
36
36
  export * from './realtime-parse.js';
@@ -60,7 +60,7 @@ export async function fetchRealtimeConnection(cfg, site) {
60
60
  signal: AbortSignal.timeout(EAR_TOKEN_FETCH_TIMEOUT_MS),
61
61
  });
62
62
  if (!res.ok)
63
- return { kind: 'error', detail: `HTTP ${res.status}` };
63
+ return { kind: 'error', detail: `HTTP ${res.status}`, status: res.status };
64
64
  const data = await res.json().catch(() => null);
65
65
  return parseRealtimeConnection(data);
66
66
  }
@@ -78,12 +78,17 @@ async function loadCreateClient() {
78
78
  return null;
79
79
  }
80
80
  }
81
+ const DEFAULT_IO = {
82
+ fetchConnection: fetchRealtimeConnection,
83
+ loadCreateClient,
84
+ resolveTransport: resolveWebSocketTransport,
85
+ };
81
86
  /**
82
87
  * 指示の着信合図を受け取る耳を1本張る(★このPCに1本だけ=耳プロセスからのみ呼ぶ)。
83
88
  *
84
89
  * 呼び出し側は各relayのポーリングを止めないこと——耳が死んでいる間はポーリングが肩代わりする。
85
90
  */
86
- export function startRealtimeEar(cfg, handlers) {
91
+ export function startRealtimeEar(cfg, handlers, io = DEFAULT_IO) {
87
92
  const { siteForToken, onSignal } = handlers;
88
93
  let stopped = false;
89
94
  let client = null;
@@ -96,6 +101,18 @@ export function startRealtimeEar(cfg, handlers) {
96
101
  let lastNudgeSite = null;
97
102
  /** 再接続の予約が二重に積まれないようにする(切断イベントは連続で飛んでくる)。 */
98
103
  let reconnectArmed = false;
104
+ /**
105
+ * 鍵が通らない(401・403)と続けて断られた回数(便 e9276b86)。★接続情報が取れたときだけ 0 に戻す
106
+ * (購読の SUBSCRIBED では戻さない=別の線の知らせで待ちが縮まない)。
107
+ */
108
+ let authFailures = 0;
109
+ /**
110
+ * いま張っている線の世代(便 e9276b86)。片付けるたびに1つ進める。
111
+ * ★片付けた古い線から遅れて届く CLOSED などの知らせは、世代が違うので無視する。
112
+ * (以前は、期限の手前の張り直しで古い線を片付けると、その CLOSED で2本目の張り直しが走り、
113
+ * 聞き直しが重なっていた。)
114
+ */
115
+ let generation = 0;
99
116
  const clearTimers = () => {
100
117
  if (retryTimer) {
101
118
  clearTimeout(retryTimer);
@@ -122,16 +139,28 @@ export function startRealtimeEar(cfg, handlers) {
122
139
  }
123
140
  channel = null;
124
141
  client = null;
142
+ generation += 1;
125
143
  };
126
- /** 一拍おいて張り直す。理由はログに残す(凝らない=最悪はポーリングが拾う)。 */
127
- const scheduleReconnect = (reason) => {
144
+ /**
145
+ * 一拍おいて張り直す。理由はログに残す(凝らない=最悪はポーリングが拾う)。
146
+ * authRejected=鍵が通らない(401・403)ときは、ふつうの切断より長く空ける(便 e9276b86)。
147
+ */
148
+ const scheduleReconnect = (reason, authRejected = false) => {
128
149
  if (stopped || reconnectArmed)
129
150
  return;
130
151
  reconnectArmed = true;
131
152
  teardown();
132
- const delay = computeRetryDelayMs(attempt);
133
- attempt += 1;
134
- log(`${reason} → ${Math.round(delay / 1000)}秒後に張り直します(この間は各relayの15秒ポーリングが肩代わり)`);
153
+ let delay;
154
+ if (authRejected) {
155
+ delay = computeAuthRetryDelayMs(authFailures);
156
+ authFailures += 1;
157
+ }
158
+ else {
159
+ delay = computeRetryDelayMs(attempt);
160
+ attempt += 1;
161
+ }
162
+ log(`${reason} → ${Math.round(delay / 1000)}秒後に張り直します(この間は各relayの15秒ポーリングが肩代わり)` +
163
+ (authRejected ? '。★鍵が通りません(~/.conductor.env の合言葉を確かめてください)' : ''));
135
164
  clearTimers();
136
165
  retryTimer = setTimeout(() => {
137
166
  reconnectArmed = false;
@@ -168,7 +197,7 @@ export function startRealtimeEar(cfg, handlers) {
168
197
  const connect = async () => {
169
198
  if (stopped)
170
199
  return;
171
- const createClient = await loadCreateClient();
200
+ const createClient = await io.loadCreateClient();
172
201
  if (createClient === null) {
173
202
  log('@supabase/supabase-js が読めないため耳は張りません(各relayの15秒ポーリングでこれまでどおり動きます)');
174
203
  return; // 依存は実行中に生えないので再試行しない。
@@ -176,7 +205,7 @@ export function startRealtimeEar(cfg, handlers) {
176
205
  // ★どの Node(18/20/22) でも耳が張れるようにする(実機 Node 20 の「native WebSocket not found」根治)。
177
206
  // 標準の WebSocket が無い機では同梱の ws を supabase-js の transport へ渡す。
178
207
  // どちらも用意できなければ耳は張らない——が、プロセスは落とさない(ポーリングが肩代わりする)。
179
- const transport = await resolveWebSocketTransport();
208
+ const transport = await io.resolveTransport();
180
209
  if (transport === null) {
181
210
  log('この機には標準のWebSocketが無く、同梱のWebSocket部品(ws)も読めないため耳は張りません' +
182
211
  '(npm install で解消します。それまでも各relayの15秒ポーリングでこれまでどおり動きます)');
@@ -189,7 +218,9 @@ export function startRealtimeEar(cfg, handlers) {
189
218
  retryTimer = setTimeout(() => void connect(), EAR_NO_SITE_RETRY_MS);
190
219
  return;
191
220
  }
192
- const result = await fetchRealtimeConnection(cfg, site);
221
+ const result = await io.fetchConnection(cfg, site);
222
+ if (stopped)
223
+ return;
193
224
  if (result.kind === 'unsupported') {
194
225
  log(`中央から購読設定が返らないため耳は張りません(${result.detail})。` +
195
226
  `各relayの15秒ポーリングで動き続け、${Math.round(EAR_UNSUPPORTED_RETRY_MS / 60000)}分後にもう一度問い合わせます`);
@@ -198,10 +229,12 @@ export function startRealtimeEar(cfg, handlers) {
198
229
  return;
199
230
  }
200
231
  if (result.kind === 'error') {
201
- scheduleReconnect(`接続情報を取得できません(${result.detail})`);
232
+ scheduleReconnect(`接続情報を取得できません(${result.detail})`, isAuthRejected(result));
202
233
  return;
203
234
  }
235
+ authFailures = 0;
204
236
  const conn = result.conn;
237
+ const myGeneration = generation;
205
238
  try {
206
239
  client = createClient(conn.supabaseUrl, conn.supabaseAnonKey, {
207
240
  auth: { persistSession: false, autoRefreshToken: false },
@@ -221,7 +254,8 @@ export function startRealtimeEar(cfg, handlers) {
221
254
  .channel(spec.channel)
222
255
  .on('postgres_changes', filterOpt, handleSignal)
223
256
  .subscribe((status, err) => {
224
- if (stopped)
257
+ // ★片付けた古い線の知らせは無視する(張り直しを重ねない・便 e9276b86)。
258
+ if (stopped || myGeneration !== generation)
225
259
  return;
226
260
  if (status === 'SUBSCRIBED') {
227
261
  attempt = 0;
package/dist/selfcheck.js CHANGED
@@ -27,7 +27,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
27
27
  import os from 'node:os';
28
28
  import path from 'node:path';
29
29
  import { fileURLToPath } from 'node:url';
30
- import { CLAUDE_MIN_VERSION, claudeUpdateHint, compareVersions, lastClaudePinStatus, resolveClaudeBin, } from './headless.js';
30
+ import { buildWorkerPath, CLAUDE_MIN_VERSION, claudeUpdateHint, compareVersions, lastClaudePinStatus, resolveClaudeBin, } from './headless.js';
31
31
  import { fetchControl } from './relay.js';
32
32
  import { readDrain } from './autoupdate-flags.js';
33
33
  // ─────────────────────────────────────────────────────────────────────────────
@@ -538,9 +538,7 @@ function probeEnv(d, claudeBin) {
538
538
  const env = { ...d.env };
539
539
  delete env.CLAUDECODE;
540
540
  delete env.ANTHROPIC_API_KEY;
541
- const binDir = path.dirname(claudeBin);
542
- if (!(env.PATH ?? '').split(':').includes(binDir))
543
- env.PATH = env.PATH ? `${binDir}:${env.PATH}` : binDir;
541
+ env.PATH = buildWorkerPath(env.PATH, claudeBin);
544
542
  return env;
545
543
  }
546
544
  /** ⑥ 小さな headless 試験を1回流す。 */
@@ -841,8 +839,31 @@ export async function runSelfcheck(d, trigger) {
841
839
  finally {
842
840
  // ★一時の作業場所は必ず消す(外に書けてしまった物も含めて)。
843
841
  rmSync(runDir, { recursive: true, force: true });
842
+ // ★道具(Claude Code)が作業場所ごとに /tmp/claude-<uid>/ の下へ作る記録も消す(便 e9276b86)。
843
+ // 作業場所は毎回名前が違うので、消さないと自己診断のたびに1つずつ溜まる(ノートPCで9つ)。
844
+ for (const dir of [runDir, path.join(runDir, 'work')]) {
845
+ const trace = claudeTraceDir(dir, d.claudeTmpRoot);
846
+ if (trace)
847
+ rmSync(trace, { recursive: true, force: true });
848
+ }
844
849
  }
845
850
  }
851
+ /**
852
+ * Claude Code が作業場所ごとに作る記録の置き場(★実物 2026-10-03・Claude Code 2.1.287)。
853
+ * `/tmp/claude-<uid>/` の下に、作業場所のパスの英数字以外を `-` に替えた名前で作る
854
+ * (例 /home/u/.local/state/conductor/selfcheck/run-…/work → -home-u--local-state-conductor-selfcheck-run-…-work)。
855
+ * ★消してよいのは自己診断の作業場所の分だけ。名前に selfcheck の run- が入らなければ null(他人の物に触らない)。
856
+ */
857
+ export function claudeTraceDir(workDir, root = defaultClaudeTmpRoot()) {
858
+ const name = workDir.replace(/[^a-zA-Z0-9]/g, '-');
859
+ if (!/-selfcheck-run-/.test(name))
860
+ return null;
861
+ return path.join(root, name);
862
+ }
863
+ function defaultClaudeTmpRoot() {
864
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
865
+ return path.join('/tmp', `claude-${uid}`);
866
+ }
846
867
  /** 画面に出す形(人が読む)。 */
847
868
  export function formatSelfcheck(r) {
848
869
  const lines = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.92",
3
+ "version": "0.12.93",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1290,6 +1290,49 @@ else
1290
1290
  cleanup_stale_units || warn "古いユニットの掃除に失敗しました(インストールは続行します)。"
1291
1291
  fi
1292
1292
 
1293
+ # --- 画面なし方式(headless)の機では、足す現場も画面なし方式で立てる(便 e9276b86) ---
1294
+ # 【なぜ要るか(実物 2026-10-03・TYHLD-GOTO・TYHLD-OTA)】
1295
+ # 1行インストーラ(install.sh)は最初の現場に drop-in(headless.conf)を置いて画面なし方式にする。
1296
+ # ところが2つ目の現場を `conductor setup 現場名` で足すと drop-in が無く、古い方式(tmux)で立った
1297
+ # (管制で「AI担当は休止中」。人が drop-in を手で置いて直した)。⑧の案内も tmux の3行を出していた。
1298
+ # 【決め方】次のどれかなら、この機は画面なし方式=この現場にも install.sh と同じ drop-in を置く。
1299
+ # ① CONDUCTOR_RUNNER_MODE=headless で呼ばれた(install.sh から)。★=tmux と明示されたら置かない。
1300
+ # ② この機のほかの現場(またはテンプレート全体)の drop-in に画面なし方式の指定が在る。
1301
+ # ★どれにも当たらない機(もとから tmux 方式の機)は、1つも変えない。Mac(launchd)も変えない。
1302
+ HEADLESS_LINE='Environment=CONDUCTOR_RUNNER_MODE=headless'
1303
+ HEADLESS_DROPIN_DIR="$HOME/.config/systemd/user/conductor@${SITE}.service.d"
1304
+ HEADLESS_DROPIN="$HEADLESS_DROPIN_DIR/headless.conf"
1305
+ machine_is_headless() {
1306
+ case "${CONDUCTOR_RUNNER_MODE:-}" in
1307
+ headless) return 0 ;;
1308
+ tmux) return 1 ;;
1309
+ esac
1310
+ local f
1311
+ for f in "$HOME"/.config/systemd/user/conductor@*.service.d/*.conf; do
1312
+ [[ -f "$f" ]] || continue
1313
+ grep -qxF "$HEADLESS_LINE" "$f" && return 0
1314
+ done
1315
+ return 1
1316
+ }
1317
+ HEADLESS_ADDED=0
1318
+ if [[ "$OS_KIND" != "mac" ]] && machine_is_headless; then
1319
+ if [[ -f "$HEADLESS_DROPIN" ]] && grep -qxF "$HEADLESS_LINE" "$HEADLESS_DROPIN"; then
1320
+ echo "[setup] 画面なし方式: この現場には設定済みです(${HEADLESS_DROPIN})"
1321
+ else
1322
+ mkdir -p "$HEADLESS_DROPIN_DIR"
1323
+ cat > "$HEADLESS_DROPIN" <<DROPIN
1324
+ # setup.sh が設定: この機は画面なし方式(headless)なので、この現場も同じ方式で動かす。
1325
+ # ★tmux に切り替えたいときはこのファイルを消して systemctl --user daemon-reload。
1326
+ [Service]
1327
+ ${HEADLESS_LINE}
1328
+ DROPIN
1329
+ HEADLESS_ADDED=1
1330
+ echo "[setup] 画面なし方式を設定しました(drop-in: ${HEADLESS_DROPIN})"
1331
+ fi
1332
+ # ⑧ の案内を画面なし方式に切り替える。
1333
+ CONDUCTOR_RUNNER_MODE=headless
1334
+ fi
1335
+
1293
1336
  # ★常駐の設置だけが OS で分かれる(ADR-019・案C)。ここより上も下も1本のまま。
1294
1337
  if [[ "$OS_KIND" == "mac" ]]; then
1295
1338
  SVC_INSTALL="$SCRIPT_DIR/launchd/install.sh"
@@ -1298,6 +1341,10 @@ else
1298
1341
  fi
1299
1342
  if [[ -f "$SVC_INSTALL" ]]; then
1300
1343
  bash "$SVC_INSTALL" "$SITE" "$PROJECT_DIR"
1344
+ # ★もう動いていた現場(setup のやり直し)は enable --now では読み直さないので、入れ直して効かせる。
1345
+ if [[ "$HEADLESS_ADDED" == "1" ]]; then
1346
+ systemctl --user restart "conductor@${SITE}.service" 2>/dev/null || true
1347
+ fi
1301
1348
  else
1302
1349
  warn "常駐の設置スクリプトが見つかりません: $SVC_INSTALL(スキップ)"
1303
1350
  echo "[setup] 手動で設定する場合は sales-template/${SVC_KIND}/ を参照してください。"
@@ -1452,12 +1499,13 @@ if [[ "${CONDUCTOR_RUNNER_MODE:-}" == "headless" ]]; then
1452
1499
  合言葉を入れ直す: rm ~/.conductor.env → そのあと setup.sh をもう一度実行
1453
1500
 
1454
1501
  ──────────────────────────────────────────────────────────
1455
- 職人の画面を手で開きたいとき(tmux 方式・補助)
1502
+ 職人の画面を手で開きたいとき(見るだけ・補助)
1456
1503
  ──────────────────────────────────────────────────────────
1457
1504
 
1458
- tmux new-session -d -s "$SITE" -c "$PROJECT_DIR"
1459
- tmux send-keys -t "$SITE" "unset ANTHROPIC_API_KEY && claude" C-m
1460
- tmux attach -t "$SITE"
1505
+ 画面なし方式では、職人の画面を手で立ち上げません(立ち上げると、便と同じ現場で2人目の職人が動きます)。
1506
+ 職人がいま何をしているかは、管制の画面か、次の1行で見られます(見るだけ・入力はできません):
1507
+
1508
+ kc ${SITE} (kc が無いときは conductor view ${SITE})
1461
1509
 
1462
1510
  GUIDE
1463
1511
  else
@@ -132,6 +132,10 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
132
132
  - **なぜ(置き場の話)。** 実機 2026-09-03: ai-saku の画面便(PR#258・PR#269)で、検品用の使い捨てページ `_layout-check.html` をリポジトリ直下に作り、`rm -rf` で消そうとして止められ、人が2回消した。**禁止の一覧と番人の制限は正しい(緩めない)。悪いのは置き場。**
133
133
  - **なぜ(後片付けの話を改めた理由)。** 旧い文言は「リポジトリ直下は番人が `rm` を止めるので自分で消さず報告」だった。実測(便 fb248f6e)では、`-f` を付けない `rm -r` と実在のパスの名指しなら、リポジトリ直下の自分の作業ファイルも通る。止まるのは `-f` 付きと変数・`*` 入りだけ。**止まらない形を「止まる」と書いていると、職人が自分の残骸を残したまま便を終える**ので、実物に合わせた(番人・禁止の一覧は1行も変えていない)。
134
134
  - 置き場を `/tmp/claude-1000/` にすれば、止められることも、残骸がコミットに混ざることも起きない(根治=鉄則10)。Playwright の検品ページも同じ(鉄則8-④)。
135
+ - **空振り確認(直しをわざと外して、試験が落ちるのを見る)も、使い捨ての複製の中だけで行う(えふさん確定 2026-10-03・便 4cf71dde の要判断②)。**
136
+ - 形: `git clone --no-hardlinks <リポの実在のパス> /tmp/claude-1000/karaburi-<便IDの頭8文字>` → その中で直しを外す → 試験を流す → 終わったら `rm -r /tmp/claude-1000/karaburi-<便IDの頭8文字>`(`-f` を付けない・名指し)。
137
+ - ★**作業ツリーの中で直しを外さない**(戻し忘れがそのまま PR に入る)。★**`git worktree` を使わない**:囲いが `.git/worktrees` を読み取り専用で重ねるので、`git worktree remove` が「Device or resource busy」で止まり、職人が片付けられない(便 4cf71dde で再現)。複製なら囲いにも番人にも止められずに片付く。
138
+ - 複製の中でも記録に入れるときは名指しで `git add <ファイル>`(鉄則1-④)。複製は GitHub へ push しない。
135
139
  14. **`.env` 類(`.env`・`.env.local`・`.env.example` などの雛形も含む)は職人が書き換えない(えふさん確定 2026-09-18・便 3d11f913)**
136
140
  - **番人は `.env` 本体も `.env.example` などの雛形も、書き込みを止める**(Write/Edit の設定 deny と、Bash の `echo`/`tee`/`sed -i`/`cp`/`mv` などを、経路を問わず同じ結果にそろえた)。読み込みは今までどおり(雛形は読める・本物は読めない)。
137
141
  - **なぜ。** 雛形(`.env.example`)は普通は秘密値を置かないが、「exampleだから常に安全」と番人へ例外を作ると、将来誤って実値が書かれたときの穴になる(利便性より安全側・えふさん確定)。