@tyhld/conductor 0.12.63 → 0.12.65

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 +101 -11
  2. package/package.json +1 -1
package/dist/headless.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * 同じ relayTick に分岐を詰め込むと壊れやすいので、独立したモジュールにする。
15
15
  * 共通の管制との通信(fetchControl, fetchCommand, patchStatus 等)は relay.ts から使う。
16
16
  */
17
- import { spawn } from 'node:child_process';
17
+ import { execFileSync, spawn } from 'node:child_process';
18
18
  import { createInterface } from 'node:readline';
19
19
  import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
20
20
  import path from 'node:path';
@@ -53,6 +53,23 @@ export const ACTIVE_JOB_PATH_LEGACY = path.join(ACTIVE_JOB_DIR, 'active-job.json
53
53
  export function activeJobPath(site) {
54
54
  return path.join(ACTIVE_JOB_DIR, `active-job-${site}.json`);
55
55
  }
56
+ /**
57
+ * 作業ディレクトリの git remote origin URL を返す。
58
+ * git リポジトリでない・origin が無い場合は null。
59
+ * ★パスの文字が同じでも別のリポなら止める(リポの同一性の照合に使う)。
60
+ */
61
+ export function resolveGitOrigin(workDir) {
62
+ try {
63
+ return execFileSync('git', ['-C', workDir, 'config', '--get', 'remote.origin.url'], {
64
+ encoding: 'utf8',
65
+ timeout: 5_000,
66
+ stdio: ['ignore', 'pipe', 'ignore'],
67
+ }).trim() || null;
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
56
73
  /** 実行中のジョブをファイルに書く(中継が死んでも拾い直せるように)。 */
57
74
  export function saveActiveJob(site, record) {
58
75
  try {
@@ -112,8 +129,12 @@ export function loadAndClearLegacyActiveJob() {
112
129
  }
113
130
  }
114
131
  /**
115
- * 手元の記録が自分(この中継)のものか4点照合する。
132
+ * 手元の記録が自分(この中継)のものか照合する。
133
+ * site, machine, workDir, gitOrigin を比べる。
116
134
  * 1点でも不一致なら理由を返す。すべて一致なら null。
135
+ *
136
+ * ★commandId は「この便が中央でまだ sent か」の確認(ネットワーク照合)で別に行う。
137
+ * ここではローカルの所有者照合のみ。
117
138
  */
118
139
  export function validateActiveJobOwnership(rec, cfg) {
119
140
  const mismatches = [];
@@ -126,10 +147,16 @@ export function validateActiveJobOwnership(rec, cfg) {
126
147
  if (rec.workDir !== undefined && rec.workDir !== cfg.workDir) {
127
148
  mismatches.push(`workDir: 記録=${rec.workDir} 自分=${cfg.workDir}`);
128
149
  }
129
- // 4点がそろわない古い記録(0.12.62 以前)は site/machine/workDir が undefined。
130
- // 照合できない = 安全に拾えない。
150
+ // ★git リポの同一性: パスが同じでも別のリポなら止める
151
+ if (rec.gitOrigin !== undefined) {
152
+ const currentOrigin = resolveGitOrigin(cfg.workDir);
153
+ if (currentOrigin !== null && rec.gitOrigin !== currentOrigin) {
154
+ mismatches.push(`gitOrigin: 記録=${rec.gitOrigin} 自分=${currentOrigin}`);
155
+ }
156
+ }
157
+ // 照合項目がそろわない古い記録は安全に拾えない。
131
158
  if (rec.site === undefined || rec.machine === undefined || rec.workDir === undefined) {
132
- mismatches.push('4点の照合項目が不足(0.12.62 以前の古い記録)');
159
+ mismatches.push('照合項目が不足(古い記録)');
133
160
  }
134
161
  return mismatches.length > 0 ? mismatches.join(', ') : null;
135
162
  }
@@ -268,8 +295,51 @@ export function streamEventToCommandEvent(parsed, attempt) {
268
295
  }
269
296
  return null;
270
297
  }
271
- /** 子プロセスの stderr から認証エラーを検出する。 */
298
+ /**
299
+ * stream-json の1行から認証エラーを検出する。
300
+ *
301
+ * 実物(2026-10-01 メインPC Fshima・ログイン切れ):
302
+ * assistant 行: error="authentication_failed", text="Not logged in · Please run /login"
303
+ * result 行: subtype="success", is_error=true, result="Not logged in · Please run /login"
304
+ * stderr: 空。exit code: 1。
305
+ *
306
+ * ★stderr には何も出ない。stdout の stream-json でだけ分かる。
307
+ */
308
+ export function detectStreamAuthError(parsed) {
309
+ // assistant 行の error フィールド(最も確実な判定)
310
+ if (parsed.type === 'assistant') {
311
+ const msg = parsed.message;
312
+ if (msg) {
313
+ // assistant 直下にも error がある場合がある(実物で確認)
314
+ }
315
+ if (parsed.error === 'authentication_failed')
316
+ return true;
317
+ }
318
+ // assistant.message.content[].text or parsed.error on assistant envelope
319
+ if (parsed.type === 'assistant' && typeof parsed.message === 'object' && parsed.message !== null) {
320
+ const msg = parsed.message;
321
+ if (msg.error === 'authentication_failed')
322
+ return true;
323
+ }
324
+ // result 行: is_error=true かつ result テキストにログイン切れの文言
325
+ if (parsed.type === 'result' && parsed.is_error === true) {
326
+ const result = String(parsed.result ?? '').toLowerCase();
327
+ if (result.includes('not logged in') ||
328
+ result.includes('please run /login') ||
329
+ result.includes('login required') ||
330
+ result.includes('session expired') ||
331
+ result.includes('token expired')) {
332
+ return true;
333
+ }
334
+ }
335
+ return false;
336
+ }
337
+ /** 子プロセスの stderr と stream-json の両方から認証エラーを検出する。 */
272
338
  export function isAuthError(job) {
339
+ // ★stream-json(stdout)で検出済み
340
+ if (job.authErrorDetected)
341
+ return true;
342
+ // stderr からも検出(従来の判定。API キーの認証エラーなど)
273
343
  const s = job.stderrBuf.toLowerCase();
274
344
  return (s.includes('401') ||
275
345
  s.includes('unauthorized') ||
@@ -387,6 +457,7 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
387
457
  lastFlushAt: Date.now(),
388
458
  lastStopCheckAt: Date.now(),
389
459
  stderrBuf: '',
460
+ authErrorDetected: false,
390
461
  stdoutLog: logs?.stdoutLog ?? null,
391
462
  stderrLog: logs?.stderrLog ?? null,
392
463
  durationMs: 0,
@@ -421,6 +492,10 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
421
492
  job.stdoutLog?.write(line + '\n');
422
493
  try {
423
494
  const parsed = JSON.parse(line);
495
+ // ★stream-json から認証エラーを検出(stderr に出ない実物に対応)
496
+ if (!job.authErrorDetected && detectStreamAuthError(parsed)) {
497
+ job.authErrorDetected = true;
498
+ }
424
499
  const event = streamEventToCommandEvent(parsed, attempt);
425
500
  if (event)
426
501
  job.eventBuffer.push(event);
@@ -548,7 +623,10 @@ async function checkRunningJob(cfg, state) {
548
623
  }
549
624
  if (isAuthError(job)) {
550
625
  // 認証エラー → 再試行しない
551
- const authMsg = maskSecrets(`認証エラー (exit=${job.exitCode}): ${job.stderrBuf.slice(0, 300)}`).slice(0, EVENT_TEXT_MAX);
626
+ const detail = job.authErrorDetected
627
+ ? 'ログイン切れ(stream-json で検出)'
628
+ : job.stderrBuf.slice(0, 300);
629
+ const authMsg = maskSecrets(`認証エラー (exit=${job.exitCode}): ${detail}`).slice(0, EVENT_TEXT_MAX);
552
630
  await sendCommandEvents(cfg, job.commandId, [{
553
631
  type: 'auth_error',
554
632
  text: authMsg,
@@ -689,6 +767,18 @@ export async function restoreHeadlessOrphans(cfg, state) {
689
767
  }]);
690
768
  return;
691
769
  }
770
+ // ★便IDの照合: 中央でまだ sent のまま残っている便か確かめる
771
+ const centralStatus = await fetchCommandStatus(cfg, saved.commandId);
772
+ if (centralStatus !== null && centralStatus !== 'sent') {
773
+ log(`WARN site=${cfg.site} id=${saved.commandId} 手元の記録の便は中央で ${centralStatus} → 再開しない`);
774
+ await sendCommandEvents(cfg, saved.commandId, [{
775
+ type: 'error',
776
+ text: `便IDの照合: 中央の状態が sent ではない(${centralStatus})→ 再開しない`.slice(0, EVENT_TEXT_MAX),
777
+ at: new Date().toISOString(),
778
+ attempt: saved.attempt,
779
+ }]);
780
+ return;
781
+ }
692
782
  log(`site=${cfg.site} id=${saved.commandId} 手元の記録から孤児を検出(中継再起動後の拾い直し)`);
693
783
  const pick = { id: saved.commandId, body: saved.body, site: cfg.site, sentAt: saved.startedAt };
694
784
  const attempt = saved.attempt + 1;
@@ -709,7 +799,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
709
799
  return;
710
800
  }
711
801
  state.job = job;
712
- saveActiveJob(cfg.site, { commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir });
802
+ saveActiveJob(cfg.site, { commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined });
713
803
  markDelivered(state.relay, pick.id);
714
804
  state.relay.lastHeartbeatAt = null;
715
805
  noteReadiness(state.relay, 'delivered', pick.id);
@@ -751,7 +841,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
751
841
  return;
752
842
  }
753
843
  state.job = job;
754
- saveActiveJob(cfg.site, { commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir });
844
+ saveActiveJob(cfg.site, { commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined });
755
845
  markDelivered(state.relay, pick.id);
756
846
  state.relay.lastHeartbeatAt = null;
757
847
  noteReadiness(state.relay, 'delivered', pick.id);
@@ -844,7 +934,7 @@ export async function headlessTick(cfg, state, hooks) {
844
934
  return;
845
935
  }
846
936
  state.job = job;
847
- 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 });
937
+ saveActiveJob(cfg.site, { commandId: retry.cmd.id, body: retry.cmd.body, sessionId, attempt: retry.attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined });
848
938
  rs.lastHeartbeatAt = null;
849
939
  noteReadiness(rs, 'delivered', retry.cmd.id);
850
940
  await sendCommandEvents(cfg, retry.cmd.id, [{
@@ -900,7 +990,7 @@ export async function headlessTick(cfg, state, hooks) {
900
990
  return;
901
991
  }
902
992
  state.job = job;
903
- saveActiveJob(cfg.site, { commandId: cmd.id, body: cmd.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir });
993
+ saveActiveJob(cfg.site, { commandId: cmd.id, body: cmd.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined });
904
994
  markDelivered(rs, cmd.id);
905
995
  rs.lastHeartbeatAt = null;
906
996
  noteReadiness(rs, 'delivered', cmd.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.63",
3
+ "version": "0.12.65",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {