@tyhld/conductor 0.12.62 → 0.12.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/headless.js +148 -21
  2. package/package.json +1 -1
package/dist/headless.js CHANGED
@@ -42,33 +42,64 @@ const RESUME_PROMPT = '前回の作業の続きを行ってください。中断
42
42
  export const RUNS_DIR = path.join(os.homedir(), '.local', 'state', 'conductor', 'runs');
43
43
  /** セッションありと見なす最低実行時間(ms)。これより短いとセッションファイルが無い可能性が高い。 */
44
44
  export const SESSION_MIN_DURATION_MS = 30_000;
45
- /** 実行中のジョブを永続化するファイル。中継再起動後の拾い直しに使う。 */
46
- export const ACTIVE_JOB_PATH = path.join(os.homedir(), '.local', 'state', 'conductor', 'active-job.json');
45
+ /** 実行中のジョブを永続化するディレクトリ。中継再起動後の拾い直しに使う。 */
46
+ export const ACTIVE_JOB_DIR = path.join(os.homedir(), '.local', 'state', 'conductor');
47
+ /**
48
+ * 旧: PCで1つの共有パス(0.12.62 以前)。
49
+ * 現場ごとに分けたあとも、古い記録が残っている PC で誤って拾わないように参照する。
50
+ */
51
+ export const ACTIVE_JOB_PATH_LEGACY = path.join(ACTIVE_JOB_DIR, 'active-job.json');
52
+ /** 現場ごとの記録ファイルのパスを返す。 */
53
+ export function activeJobPath(site) {
54
+ return path.join(ACTIVE_JOB_DIR, `active-job-${site}.json`);
55
+ }
47
56
  /** 実行中のジョブをファイルに書く(中継が死んでも拾い直せるように)。 */
48
- export function saveActiveJob(record) {
57
+ export function saveActiveJob(site, record) {
49
58
  try {
50
- const dir = path.dirname(ACTIVE_JOB_PATH);
51
- mkdirSync(dir, { recursive: true });
52
- writeFileSync(ACTIVE_JOB_PATH, JSON.stringify(record) + '\n');
59
+ mkdirSync(ACTIVE_JOB_DIR, { recursive: true });
60
+ writeFileSync(activeJobPath(site), JSON.stringify(record) + '\n');
53
61
  }
54
62
  catch (err) {
55
63
  log(`WARN active-job の保存に失敗: ${err instanceof Error ? err.message : String(err)}`);
56
64
  }
57
65
  }
58
66
  /** 実行中のジョブ記録を消す(ジョブが完了・放棄したとき)。 */
59
- export function clearActiveJob() {
67
+ export function clearActiveJob(site) {
60
68
  try {
61
- if (existsSync(ACTIVE_JOB_PATH))
62
- unlinkSync(ACTIVE_JOB_PATH);
69
+ const p = activeJobPath(site);
70
+ if (existsSync(p))
71
+ unlinkSync(p);
63
72
  }
64
73
  catch { /* ignore */ }
65
74
  }
66
75
  /** 起動時に前回の実行中ジョブを読む。無ければ null。 */
67
- export function loadActiveJob() {
76
+ export function loadActiveJob(site) {
77
+ try {
78
+ const p = activeJobPath(site);
79
+ if (!existsSync(p))
80
+ return null;
81
+ const raw = readFileSync(p, 'utf8').trim();
82
+ if (!raw)
83
+ return null;
84
+ const rec = JSON.parse(raw);
85
+ if (!rec.commandId)
86
+ return null;
87
+ return rec;
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
93
+ /**
94
+ * 旧形式(PCで1つの共有 active-job.json)を読み、在れば消す。
95
+ * 4点がそろわない古い記録は再開に使わない。
96
+ */
97
+ export function loadAndClearLegacyActiveJob() {
68
98
  try {
69
- if (!existsSync(ACTIVE_JOB_PATH))
99
+ if (!existsSync(ACTIVE_JOB_PATH_LEGACY))
70
100
  return null;
71
- const raw = readFileSync(ACTIVE_JOB_PATH, 'utf8').trim();
101
+ const raw = readFileSync(ACTIVE_JOB_PATH_LEGACY, 'utf8').trim();
102
+ unlinkSync(ACTIVE_JOB_PATH_LEGACY); // 読んだら必ず消す
72
103
  if (!raw)
73
104
  return null;
74
105
  const rec = JSON.parse(raw);
@@ -80,6 +111,28 @@ export function loadActiveJob() {
80
111
  return null;
81
112
  }
82
113
  }
114
+ /**
115
+ * 手元の記録が自分(この中継)のものか4点照合する。
116
+ * 1点でも不一致なら理由を返す。すべて一致なら null。
117
+ */
118
+ export function validateActiveJobOwnership(rec, cfg) {
119
+ const mismatches = [];
120
+ if (rec.site !== undefined && rec.site !== cfg.site) {
121
+ mismatches.push(`site: 記録=${rec.site} 自分=${cfg.site}`);
122
+ }
123
+ if (rec.machine !== undefined && rec.machine !== cfg.machine) {
124
+ mismatches.push(`machine: 記録=${rec.machine} 自分=${cfg.machine}`);
125
+ }
126
+ if (rec.workDir !== undefined && rec.workDir !== cfg.workDir) {
127
+ mismatches.push(`workDir: 記録=${rec.workDir} 自分=${cfg.workDir}`);
128
+ }
129
+ // 4点がそろわない古い記録(0.12.62 以前)は site/machine/workDir が undefined。
130
+ // 照合できない = 安全に拾えない。
131
+ if (rec.site === undefined || rec.machine === undefined || rec.workDir === undefined) {
132
+ mismatches.push('4点の照合項目が不足(0.12.62 以前の古い記録)');
133
+ }
134
+ return mismatches.length > 0 ? mismatches.join(', ') : null;
135
+ }
83
136
  // ─────────────────────────────────────────────────────────────────────────────
84
137
  // claude の実行ファイルの探索
85
138
  //
@@ -215,8 +268,51 @@ export function streamEventToCommandEvent(parsed, attempt) {
215
268
  }
216
269
  return null;
217
270
  }
218
- /** 子プロセスの stderr から認証エラーを検出する。 */
271
+ /**
272
+ * stream-json の1行から認証エラーを検出する。
273
+ *
274
+ * 実物(2026-10-01 メインPC Fshima・ログイン切れ):
275
+ * assistant 行: error="authentication_failed", text="Not logged in · Please run /login"
276
+ * result 行: subtype="success", is_error=true, result="Not logged in · Please run /login"
277
+ * stderr: 空。exit code: 1。
278
+ *
279
+ * ★stderr には何も出ない。stdout の stream-json でだけ分かる。
280
+ */
281
+ export function detectStreamAuthError(parsed) {
282
+ // assistant 行の error フィールド(最も確実な判定)
283
+ if (parsed.type === 'assistant') {
284
+ const msg = parsed.message;
285
+ if (msg) {
286
+ // assistant 直下にも error がある場合がある(実物で確認)
287
+ }
288
+ if (parsed.error === 'authentication_failed')
289
+ return true;
290
+ }
291
+ // assistant.message.content[].text or parsed.error on assistant envelope
292
+ if (parsed.type === 'assistant' && typeof parsed.message === 'object' && parsed.message !== null) {
293
+ const msg = parsed.message;
294
+ if (msg.error === 'authentication_failed')
295
+ return true;
296
+ }
297
+ // result 行: is_error=true かつ result テキストにログイン切れの文言
298
+ if (parsed.type === 'result' && parsed.is_error === true) {
299
+ const result = String(parsed.result ?? '').toLowerCase();
300
+ if (result.includes('not logged in') ||
301
+ result.includes('please run /login') ||
302
+ result.includes('login required') ||
303
+ result.includes('session expired') ||
304
+ result.includes('token expired')) {
305
+ return true;
306
+ }
307
+ }
308
+ return false;
309
+ }
310
+ /** 子プロセスの stderr と stream-json の両方から認証エラーを検出する。 */
219
311
  export function isAuthError(job) {
312
+ // ★stream-json(stdout)で検出済み
313
+ if (job.authErrorDetected)
314
+ return true;
315
+ // stderr からも検出(従来の判定。API キーの認証エラーなど)
220
316
  const s = job.stderrBuf.toLowerCase();
221
317
  return (s.includes('401') ||
222
318
  s.includes('unauthorized') ||
@@ -334,6 +430,7 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
334
430
  lastFlushAt: Date.now(),
335
431
  lastStopCheckAt: Date.now(),
336
432
  stderrBuf: '',
433
+ authErrorDetected: false,
337
434
  stdoutLog: logs?.stdoutLog ?? null,
338
435
  stderrLog: logs?.stderrLog ?? null,
339
436
  durationMs: 0,
@@ -368,6 +465,10 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
368
465
  job.stdoutLog?.write(line + '\n');
369
466
  try {
370
467
  const parsed = JSON.parse(line);
468
+ // ★stream-json から認証エラーを検出(stderr に出ない実物に対応)
469
+ if (!job.authErrorDetected && detectStreamAuthError(parsed)) {
470
+ job.authErrorDetected = true;
471
+ }
371
472
  const event = streamEventToCommandEvent(parsed, attempt);
372
473
  if (event)
373
474
  job.eventBuffer.push(event);
@@ -479,7 +580,7 @@ async function checkRunningJob(cfg, state) {
479
580
  // ── 完了したプロセスの後始末 ──
480
581
  if (job.completed) {
481
582
  await flushEvents(cfg, job);
482
- clearActiveJob();
583
+ clearActiveJob(cfg.site);
483
584
  if (job.stopped) {
484
585
  // stop_requested で止めた
485
586
  await sendCommandEvents(cfg, job.commandId, [{
@@ -495,7 +596,10 @@ async function checkRunningJob(cfg, state) {
495
596
  }
496
597
  if (isAuthError(job)) {
497
598
  // 認証エラー → 再試行しない
498
- const authMsg = maskSecrets(`認証エラー (exit=${job.exitCode}): ${job.stderrBuf.slice(0, 300)}`).slice(0, EVENT_TEXT_MAX);
599
+ const detail = job.authErrorDetected
600
+ ? 'ログイン切れ(stream-json で検出)'
601
+ : job.stderrBuf.slice(0, 300);
602
+ const authMsg = maskSecrets(`認証エラー (exit=${job.exitCode}): ${detail}`).slice(0, EVENT_TEXT_MAX);
499
603
  await sendCommandEvents(cfg, job.commandId, [{
500
604
  type: 'auth_error',
501
605
  text: authMsg,
@@ -610,9 +714,32 @@ export async function restoreHeadlessOrphans(cfg, state) {
610
714
  if (state.job !== null)
611
715
  return; // 既にジョブ実行中
612
716
  // ── 経路1: 手元の記録から拾い直す ──
613
- const saved = loadActiveJob();
717
+ // ★現場ごとのファイルを先に読む。古い共有ファイルも消す(取り違え防止)。
718
+ let saved = loadActiveJob(cfg.site);
719
+ if (saved !== null) {
720
+ clearActiveJob(cfg.site); // 拾ったら消す(二重起動防止)
721
+ }
722
+ // 古い共有の記録(0.12.62 以前)が残っていれば読んで必ず消す
723
+ const legacy = loadAndClearLegacyActiveJob();
724
+ if (saved === null && legacy !== null) {
725
+ saved = legacy; // 新しい現場ごとの記録が無いときだけ候補にする
726
+ }
727
+ else if (legacy !== null) {
728
+ log(`site=${cfg.site} 古い共有の active-job.json を消しました(現場ごとの記録を優先)`);
729
+ }
614
730
  if (saved !== null) {
615
- clearActiveJob(); // 拾ったら消す(二重起動防止)
731
+ // ★4点照合: machine, site, commandId, workDir がすべて一致しなければ再開しない
732
+ const mismatch = validateActiveJobOwnership(saved, cfg);
733
+ if (mismatch !== null) {
734
+ log(`WARN site=${cfg.site} id=${saved.commandId} 手元の記録は別の中継のもの → 再開しない(不一致: ${mismatch})`);
735
+ await sendCommandEvents(cfg, saved.commandId, [{
736
+ type: 'error',
737
+ text: `手元の記録の4点照合で不一致 → 再開しない(${mismatch})`.slice(0, EVENT_TEXT_MAX),
738
+ at: new Date().toISOString(),
739
+ attempt: saved.attempt,
740
+ }]);
741
+ return;
742
+ }
616
743
  log(`site=${cfg.site} id=${saved.commandId} 手元の記録から孤児を検出(中継再起動後の拾い直し)`);
617
744
  const pick = { id: saved.commandId, body: saved.body, site: cfg.site, sentAt: saved.startedAt };
618
745
  const attempt = saved.attempt + 1;
@@ -633,7 +760,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
633
760
  return;
634
761
  }
635
762
  state.job = job;
636
- saveActiveJob({ commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now() });
763
+ saveActiveJob(cfg.site, { commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir });
637
764
  markDelivered(state.relay, pick.id);
638
765
  state.relay.lastHeartbeatAt = null;
639
766
  noteReadiness(state.relay, 'delivered', pick.id);
@@ -675,7 +802,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
675
802
  return;
676
803
  }
677
804
  state.job = job;
678
- saveActiveJob({ commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now() });
805
+ saveActiveJob(cfg.site, { commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir });
679
806
  markDelivered(state.relay, pick.id);
680
807
  state.relay.lastHeartbeatAt = null;
681
808
  noteReadiness(state.relay, 'delivered', pick.id);
@@ -768,7 +895,7 @@ export async function headlessTick(cfg, state, hooks) {
768
895
  return;
769
896
  }
770
897
  state.job = job;
771
- saveActiveJob({ commandId: retry.cmd.id, body: retry.cmd.body, sessionId, attempt: retry.attempt, startedAt: Date.now() });
898
+ 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 });
772
899
  rs.lastHeartbeatAt = null;
773
900
  noteReadiness(rs, 'delivered', retry.cmd.id);
774
901
  await sendCommandEvents(cfg, retry.cmd.id, [{
@@ -824,7 +951,7 @@ export async function headlessTick(cfg, state, hooks) {
824
951
  return;
825
952
  }
826
953
  state.job = job;
827
- saveActiveJob({ commandId: cmd.id, body: cmd.body, sessionId, attempt, startedAt: Date.now() });
954
+ saveActiveJob(cfg.site, { commandId: cmd.id, body: cmd.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir });
828
955
  markDelivered(rs, cmd.id);
829
956
  rs.lastHeartbeatAt = null;
830
957
  noteReadiness(rs, 'delivered', cmd.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.62",
3
+ "version": "0.12.64",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {