@capstone-nomos/cli 0.1.15 → 0.1.17

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.
@@ -29,6 +29,7 @@ export class NomosClient {
29
29
  baseUrl;
30
30
  fetchImpl;
31
31
  onTokensChanged;
32
+ onApiError;
32
33
  current;
33
34
  // 진단용. 통합 테스트가 "정확히 한 번만 재발급했는가"를 확인한다.
34
35
  refreshCount = 0;
@@ -36,6 +37,7 @@ export class NomosClient {
36
37
  this.baseUrl = options.baseUrl.replace(/\/$/, '');
37
38
  this.fetchImpl = options.fetchImpl ?? fetch;
38
39
  this.onTokensChanged = options.onTokensChanged;
40
+ this.onApiError = options.onApiError;
39
41
  this.current = options.tokens;
40
42
  }
41
43
  get tokens() {
@@ -154,6 +156,22 @@ export class NomosClient {
154
156
  await this.request('POST', `/api/pm/jobs/${jobId}/failure`, { message });
155
157
  }
156
158
  async request(method, path, body) {
159
+ try {
160
+ return await this.requestOnce(method, path, body);
161
+ }
162
+ catch (err) {
163
+ if (err instanceof NomosApiError) {
164
+ try {
165
+ this.onApiError?.(err);
166
+ }
167
+ catch {
168
+ // 알림이 실패해도 원래 에러를 그대로 던진다.
169
+ }
170
+ }
171
+ throw err;
172
+ }
173
+ }
174
+ async requestOnce(method, path, body) {
157
175
  const first = await this.send(method, path, body);
158
176
  if (!needsRefresh(first))
159
177
  return unwrap(first);
@@ -26,6 +26,11 @@ export function isAlive(pid) {
26
26
  return err.code === 'EPERM';
27
27
  }
28
28
  }
29
+ // 이 프로세스가 백그라운드 connect 자신이면 daemon.json을 지운다 — 연결 해제로 스스로 끝날 때(cli.ts) status가 바로 "돌고 있지 않음"을 보이게.
30
+ export function clearDaemonIfSelf() {
31
+ if (readDaemon()?.pid === process.pid)
32
+ rmSync(daemonFile(), { force: true });
33
+ }
29
34
  // 살아 있는 백그라운드 connect. 죽었는데 파일만 남았으면 지우고 null.
30
35
  export function runningDaemon() {
31
36
  const info = readDaemon();
@@ -27,7 +27,8 @@ import { credentialsPath, readCredentials, updateAccessToken, writeCredentials,
27
27
  import { NomosApiError, NomosClient } from '../bridge/nomos-client.js';
28
28
  import { ActivityReporter, ActivityTracker, RunObserver } from './activity.js';
29
29
  import { accountLine, connect, DEFAULT_SERVER, serverCalls } from './connect.js';
30
- import { runningDaemon, startDaemon, stopDaemon, tailLog } from './background.js';
30
+ import { clearDaemonIfSelf, runningDaemon, startDaemon, stopDaemon, tailLog } from './background.js';
31
+ import { agentStop, DISCONNECTED_EXIT_CODE, DISCONNECTED_MESSAGE } from './stop.js';
31
32
  import { deviceLogin } from './device-login.js';
32
33
  import { cliVersion, mcpServerPath } from './paths.js';
33
34
  import { checkTools } from './preflight.js';
@@ -46,6 +47,24 @@ const POLL_INTERVAL_MS = 10_000;
46
47
  function log(line) {
47
48
  process.stdout.write(`[nomos] ${line}\n`);
48
49
  }
50
+ // 돌고 있는 일(태스크 실행·상담·계약 초안) — 연결 해제로 멈출 때 다 끝나기를 기다린 뒤 프로세스를 끝낸다.
51
+ const running = new Set();
52
+ function track(work) {
53
+ const p = work().finally(() => running.delete(p));
54
+ running.add(p);
55
+ return p;
56
+ }
57
+ // 연결 해제로 멈출 때, 이미 끝내라고 한(트리째 종료) 실행들이 정리되기를 잠시 기다린다. 오래 걸려도 프로세스는 끝낸다.
58
+ async function settleRunning(timeoutMs = 15_000) {
59
+ await Promise.race([Promise.allSettled([...running]), new Promise((r) => setTimeout(r, timeoutMs).unref())]);
60
+ }
61
+ // 연결 해제로 끝난다 — 0이 아닌 코드. 백그라운드(--background)로 돌던 것이면 daemon.json도 지워 status가 "돌고 있지 않음"을 보이게 한다.
62
+ // 남은 타이머(폴링 대기 등)가 프로세스를 붙잡지 않게 바로 끝낸다.
63
+ function exitDisconnected() {
64
+ log(DISCONNECTED_MESSAGE);
65
+ clearDaemonIfSelf();
66
+ process.exit(DISCONNECTED_EXIT_CODE);
67
+ }
49
68
  function createClient() {
50
69
  const credentials = readCredentials();
51
70
  if (!credentials) {
@@ -56,6 +75,11 @@ function createClient() {
56
75
  tokens: { accessToken: credentials.accessToken, refreshToken: credentials.refreshToken },
57
76
  // 재발급 결과를 파일에 되돌려 쓴다. 없으면 다음 실행이 또 401로 시작한다.
58
77
  onTokensChanged: (tokens) => updateAccessToken(tokens.accessToken),
78
+ // 어느 요청에서든 AGENT_DISCONNECTED를 받으면 실행 중인 Claude를 모두 멈춘다(stop.ts).
79
+ onApiError: (err) => {
80
+ if (!agentStop.stopped && agentStop.noteError(err))
81
+ log(DISCONNECTED_MESSAGE);
82
+ },
59
83
  });
60
84
  }
61
85
  // 지금 돌고 있는 태스크의 작업공간 — 상담 실행이 진행 중인 작업(커밋 안 된 파일 포함)을 읽으려고 둔다.
@@ -72,7 +96,7 @@ async function consultPending(client, role) {
72
96
  if (!job)
73
97
  return;
74
98
  consulting.add(job.id);
75
- void (async () => {
99
+ track(async () => {
76
100
  try {
77
101
  const active = [...activeWorkspaces.values()].find((w) => repos.some((r) => r.fullName === w.repo));
78
102
  const repo = active ? { fullName: active.repo } : repos[0];
@@ -80,18 +104,20 @@ async function consultPending(client, role) {
80
104
  throw new Error('이 역할이 소유한 레포가 프로젝트에 없다');
81
105
  const dir = active ? active.dir : ensureRepo({ fullName: repo.fullName, cloneUrl: repos[0].cloneUrl }, { log }).path;
82
106
  log(`상담 실행: ${job.askerRole}의 질문 ${job.questions.length}개 — ${repo.fullName}${active ? ' (진행 중인 작업공간)' : ''}`);
83
- const draft = await runConsult({ dir, role, repoName: repo.fullName, question: job });
107
+ const draft = await runConsult({ dir, role, repoName: repo.fullName, question: job, signal: agentStop.signal });
84
108
  const result = await client.submitDraft(job.id, draft);
85
109
  log(result.status === 'agent_answered' ? ' 초안 올림 — 전부 코드에 정해져 있어 바로 답이 됐다(사람 확인 대기)' : ' 초안 올림 — 정해지지 않은 결정이 있어 담당자에게 넘어갔다');
86
110
  }
87
111
  catch (err) {
88
112
  consultGaveUp.add(job.id);
89
- log(` 상담 실패 — 담당자가 직접 답한다: ${err instanceof Error ? err.message : String(err)}`);
113
+ // 연결 해제로 멈췄으면 사유는 이미 남겼다(그 뒤의 보고는 401이라 보내지 않는다).
114
+ if (!agentStop.stopped)
115
+ log(` 상담 실패 — 담당자가 직접 답한다: ${err instanceof Error ? err.message : String(err)}`);
90
116
  }
91
117
  finally {
92
118
  consulting.delete(job.id);
93
119
  }
94
- })();
120
+ });
95
121
  }
96
122
  // 계약 작성(023) — BACKEND 노트북만. 기능 명세에 API가 필요하면 BE 레포를 읽기만 해서 계약 초안을 올린다(본인 구독).
97
123
  // 한 번에 한 묶음씩. 첫 회차가 둘 이상이면 한 번의 실행으로 함께 쓰고(레포를 한 번만 읽는다 — contract.ts), 레포 최신화도 묶음당 한 번이다.
@@ -107,7 +133,7 @@ async function contractPending(client) {
107
133
  if (jobs.length === 0)
108
134
  return;
109
135
  drafting = true;
110
- void (async () => {
136
+ track(async () => {
111
137
  try {
112
138
  const active = [...activeWorkspaces.values()].find((w) => repos.some((r) => r.fullName === w.repo));
113
139
  const repo = active ? repos.find((r) => r.fullName === active.repo) : repos[0];
@@ -124,7 +150,7 @@ async function contractPending(client) {
124
150
  if (firsts.length >= 2) {
125
151
  log(`계약 묶음 작성: ${firsts.map((j) => j.featureKey).join(', ')} (${firsts.length}개, 1회차) — ${repo.fullName}`);
126
152
  try {
127
- batched = await runContractBatch({ dir, repoName: repo.fullName, jobs: firsts, approved });
153
+ batched = await runContractBatch({ dir, repoName: repo.fullName, jobs: firsts, approved, signal: agentStop.signal });
128
154
  const missing = firsts.filter((j) => !batched.has(j.featureKey)).map((j) => j.featureKey);
129
155
  if (missing.length > 0)
130
156
  log(` 묶음에서 빠진 기능 ${missing.join(', ')} — 하나씩 다시 쓴다`);
@@ -134,11 +160,13 @@ async function contractPending(client) {
134
160
  }
135
161
  }
136
162
  for (const job of jobs) {
163
+ if (agentStop.stopped)
164
+ break;
137
165
  try {
138
166
  const ready = batched.get(job.featureKey);
139
167
  if (!ready)
140
168
  log(`계약 작성: ${job.featureKey} ${job.specTitle} (${job.attempt}회차) — ${repo.fullName}`);
141
- let draft = ready ?? (await runContractDraft({ dir, repoName: repo.fullName, job, approved }));
169
+ let draft = ready ?? (await runContractDraft({ dir, repoName: repo.fullName, job, approved, signal: agentStop.signal }));
142
170
  try {
143
171
  await client.submitContractDraft(job.id, { attempt: job.attempt, ...draft });
144
172
  }
@@ -147,7 +175,7 @@ async function contractPending(client) {
147
175
  throw err;
148
176
  const problems = Array.isArray(err.details) ? err.details.map(String) : [err.message];
149
177
  log(` ${job.featureKey} 형식 위반 ${problems.length}개 — 한 번 고쳐 쓴다`);
150
- draft = await runContractDraft({ dir, repoName: repo.fullName, job, approved, problems });
178
+ draft = await runContractDraft({ dir, repoName: repo.fullName, job, approved, problems, signal: agentStop.signal });
151
179
  await client.submitContractDraft(job.id, { attempt: job.attempt, ...draft });
152
180
  }
153
181
  log(` ${job.featureKey} 초안 올림 — BE 담당이 웹에서 확정하거나 수정을 요청한다`);
@@ -160,10 +188,13 @@ async function contractPending(client) {
160
188
  finally {
161
189
  drafting = false;
162
190
  }
163
- })();
191
+ });
164
192
  }
165
193
  async function reportContractFailure(client, job, err) {
166
194
  contractGaveUp.add(`${job.id}:${job.attempt}`);
195
+ // 연결 해제로 멈췄다 — 실패 보고는 401이라 보내지 않는다. 서버에서는 BE 담당이 다시 요청할 수 있다.
196
+ if (agentStop.stopped)
197
+ return;
167
198
  // 그 사이 수정 요청이 와서 회차가 바뀌었다 — 새 회차는 다음 확인에서 다시 쓴다.
168
199
  if (err instanceof NomosApiError && err.code === 'CONTRACT_STALE') {
169
200
  log(` ${job.featureKey} — 그 사이 계약이 바뀌었다, 이 회차는 버린다`);
@@ -213,6 +244,9 @@ async function handleTask(client, projectId, task) {
213
244
  const tracker = new ActivityTracker(workspace.dir);
214
245
  // 의존성은 모델이 아니라 여기서 설치한다(설치 스크립트 끔). 실패해도 진행한다 — 프롬프트에 결과를 적는다.
215
246
  const setup = await prepareDependencies(workspace.dir, log);
247
+ // 준비하는 사이 연결이 해제됐으면 Claude를 띄우지 않는다.
248
+ if (agentStop.stopped)
249
+ return;
216
250
  const before = headSha(workspace.dir);
217
251
  const result = await runClaude({
218
252
  workspaceDir: workspace.dir,
@@ -225,7 +259,15 @@ async function handleTask(client, projectId, task) {
225
259
  observer.observe(line);
226
260
  reporter?.push(tracker.observe(line));
227
261
  },
262
+ signal: agentStop.signal,
228
263
  });
264
+ // 연결이 해제돼 멈췄다 — 서버가 열린 실행을 이미 닫았고(outcome disconnected) 종료 보고는 401이라 보내지 않는다.
265
+ // 작업공간은 지우지 않는다(남기는 규칙 그대로).
266
+ if (result.outcome === 'aborted') {
267
+ await reporter?.close().catch(() => { });
268
+ log(`태스크 ${task.id} 실행을 멈췄다(연결 해제). 작업공간은 남긴다: ${workspace.dir}`);
269
+ return;
270
+ }
229
271
  const committed = headSha(workspace.dir) !== before;
230
272
  if (reporter) {
231
273
  reporter.push(tracker.finish());
@@ -320,19 +362,29 @@ async function start() {
320
362
  },
321
363
  fallback: () => pollingTaskSource(client).nextTasks(Number.MAX_SAFE_INTEGER),
322
364
  log,
365
+ // 서버가 연결 해제로 스트림을 닫았다(4401 AGENT_DISCONNECTED) — 실행 중인 Claude를 멈춘다.
366
+ onAgentDisconnected: () => agentStop.stop(),
323
367
  });
324
368
  log(`시작 — 프로젝트 ${self.projectId}, 역할 ${self.teamRole}, 동시 실행 ${self.maxConcurrent} (${source.kind})`);
325
369
  const inFlight = new Set();
326
370
  // 태스크 하나가 끝나면 자리가 난다 — 다음 푸시나 간격을 기다리지 않고 바로 다시 본다.
327
371
  let finished = () => { };
372
+ // 연결 해제 — 실행 중인 것은 이미 트리째 끝내라고 했다(stop.ts). 정리를 기다린 뒤 멈춘다.
373
+ const stopDisconnected = async () => {
374
+ source.close();
375
+ await settleRunning();
376
+ return 'disconnected';
377
+ };
328
378
  for (;;) {
379
+ if (agentStop.stopped)
380
+ return stopDisconnected();
329
381
  try {
330
382
  const capacity = self.maxConcurrent - inFlight.size;
331
383
  if (capacity > 0) {
332
384
  // 처리 중인 것도 목록에 남아 있을 수 있으니(수령 전) 그만큼 더 받아 거른다.
333
385
  for (const task of (await source.nextTasks(capacity + inFlight.size)).filter((t) => !inFlight.has(t.id)).slice(0, capacity)) {
334
386
  inFlight.add(task.id);
335
- void handleTask(client, self.projectId, task)
387
+ void track(() => handleTask(client, self.projectId, task))
336
388
  .catch((err) => log(`태스크 ${task.id} 실패: ${err instanceof Error ? err.message : String(err)}`))
337
389
  .finally(() => {
338
390
  inFlight.delete(task.id);
@@ -345,9 +397,8 @@ async function start() {
345
397
  catch (err) {
346
398
  // 대표나 본인이 웹에서 이 에이전트의 연결을 해제했다(서버 021) — 계속 물어도 같은 답이니 이유를 알리고 멈춘다.
347
399
  if (err instanceof NomosApiError && err.code === 'AGENT_DISCONNECTED') {
348
- log('이 에이전트는 연결이 해제됐다 — 다시 쓰려면 connect를 실행하라');
349
- source.close();
350
- return 'disconnected';
400
+ agentStop.stop();
401
+ return stopDisconnected();
351
402
  }
352
403
  // 배정이 풀렸다(대표의 해제·프로젝트 중단, 서버 021). 같은 목록을 계속 물어도 거부된다.
353
404
  if (err instanceof NomosApiError && err.code === 'NOT_PROJECT_MEMBER' && inFlight.size === 0) {
@@ -376,7 +427,7 @@ async function start() {
376
427
  if (!(err instanceof NomosApiError && err.status === 404))
377
428
  log(`계약 작업 목록 실패: ${err instanceof Error ? err.message : String(err)}`);
378
429
  }
379
- await Promise.race([source.waitForChange(POLL_INTERVAL_MS), new Promise((resolve) => (finished = resolve))]);
430
+ await Promise.race([source.waitForChange(POLL_INTERVAL_MS), new Promise((resolve) => (finished = resolve)), agentStop.whenStopped()]);
380
431
  }
381
432
  }
382
433
  // 토큰이 헤더로 다니므로 원격은 https만 받는다. 로컬 개발 서버만 예외다.
@@ -522,8 +573,9 @@ async function connectCommand(argv) {
522
573
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
523
574
  log,
524
575
  });
525
- if ((await start()) !== 'unassigned')
526
- return;
576
+ const reason = await start();
577
+ if (reason === 'disconnected')
578
+ exitDisconnected();
527
579
  log('다시 배정을 기다린다');
528
580
  }
529
581
  }
@@ -626,7 +678,8 @@ async function main() {
626
678
  if (command === 'once')
627
679
  return once();
628
680
  if (command === 'start') {
629
- await start();
681
+ if ((await start()) === 'disconnected')
682
+ exitDisconnected();
630
683
  return;
631
684
  }
632
685
  if (command === 'pm-worker')
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import path from 'node:path';
5
+ import { killOnAbort } from './stop.js';
5
6
  import { claudeEnv, resolveClaudeCommand } from './runner.js';
6
7
  // 상담 응답의 형식(구조화 출력). 모든 객체는 additionalProperties: false + 전 필드 required.
7
8
  export const CONSULT_JSON_SCHEMA = {
@@ -63,6 +64,7 @@ export function consultArgs(prompt, emptyMcpPath) {
63
64
  return readOnlyClaudeArgs(prompt, emptyMcpPath, CONSULT_JSON_SCHEMA);
64
65
  }
65
66
  // 읽기 전용 Claude를 한 번 돌려 구조화 출력을 돌려준다(없으면 result 문자열).
67
+ // signal이 멈추면(연결 해제 — stop.ts) claude를 트리째 끝내고 그 사유로 실패한다.
66
68
  export async function runReadOnlyClaude(input) {
67
69
  const scratch = path.join(tmpdir(), 'nomos-consult');
68
70
  mkdirSync(scratch, { recursive: true });
@@ -76,14 +78,20 @@ export async function runReadOnlyClaude(input) {
76
78
  child.kill();
77
79
  reject(new Error(`${input.label} 시간 초과`));
78
80
  }, input.timeoutMs);
81
+ const unwatch = killOnAbort(child, input.signal);
79
82
  child.stdout.on('data', (d) => (stdout += d));
80
83
  child.once('error', (err) => {
81
84
  clearTimeout(timer);
85
+ unwatch();
82
86
  reject(err);
83
87
  });
84
88
  child.on('close', () => {
85
89
  clearTimeout(timer);
86
- resolve(stdout);
90
+ unwatch();
91
+ if (input.signal?.aborted)
92
+ reject(input.signal.reason instanceof Error ? input.signal.reason : new Error(`${input.label} 중단`));
93
+ else
94
+ resolve(stdout);
87
95
  });
88
96
  child.stdin.end(input.prompt);
89
97
  });
@@ -124,6 +132,7 @@ export async function runConsult(input) {
124
132
  schema: CONSULT_JSON_SCHEMA,
125
133
  timeoutMs: input.timeoutMs ?? 5 * 60_000,
126
134
  label: '상담 실행',
135
+ ...(input.signal ? { signal: input.signal } : {}),
127
136
  });
128
137
  return parseConsultDraft(output, input.question.questions.map((q) => q.question));
129
138
  }
@@ -80,6 +80,7 @@ export async function runContractDraft(input) {
80
80
  // 계약은 레포를 넓게 읽는다(라우트·에러 형식·인증) — 상담(5분)보다 길게 둔다.
81
81
  timeoutMs: input.timeoutMs ?? 10 * 60_000,
82
82
  label: '계약 작성',
83
+ ...(input.signal ? { signal: input.signal } : {}),
83
84
  });
84
85
  return parseContractDraft(output);
85
86
  }
@@ -170,6 +171,7 @@ export async function runContractBatch(input) {
170
171
  // 레포는 한 번 읽지만 쓸 양은 기능 수만큼 는다. 기본 10분 + 기능당 2분, 최대 30분.
171
172
  timeoutMs: input.timeoutMs ?? Math.min(30, 10 + 2 * input.jobs.length) * 60_000,
172
173
  label: '계약 묶음 작성',
174
+ ...(input.signal ? { signal: input.signal } : {}),
173
175
  });
174
176
  return parseContractBatch(output, input.jobs);
175
177
  }
@@ -29,7 +29,7 @@ export function buildTaskPrompt(briefing, branch, environment) {
29
29
  // 직전 제출이 반려됐다 — 같은 태스크 브랜치에 이전 작업이 남아 있으니 이어서 고친다.
30
30
  if (briefing.lastRejection) {
31
31
  sections.push([
32
- '# 직전 제출이 대표에게 반려됐다',
32
+ `# 직전 제출이 ${briefing.lastRejection.rejectedBy === 'role_owner' ? '역할 담당(검토자)' : '대표'}에게 반려됐다`,
33
33
  `사유: ${briefing.lastRejection.reason}`,
34
34
  `반려된 커밋: ${briefing.lastRejection.commitSha ?? '(알 수 없음)'} — 이 브랜치에 이전 작업이 남아 있다. 처음부터 다시 만들지 말고 사유에 맞게 고친 뒤 다시 제출한다.`,
35
35
  ].join('\n'));
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { existsSync, writeFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { buildClaudeArgs } from '../bridge/claude-args.js';
5
+ import { killOnAbort } from './stop.js';
5
6
  export const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
6
7
  // 모델 출력(stream-json)이 이만큼 없으면 응답이 끊긴 것으로 보고 끊는다. 30분을 다 기다리지 않고, 사유도 "오래 일함"과 구분된다.
7
8
  // 실측: 도구 결과를 받은 뒤 다음 모델 응답이 오지 않은 채 20분 넘게 멈춘 실행이 있었다(같은 조건 재실행은 정상).
@@ -111,14 +112,20 @@ export async function runClaude(input) {
111
112
  child.kill();
112
113
  }
113
114
  }, Math.min(30_000, Math.max(50, Math.floor(idleMs / 4))));
115
+ const unwatch = killOnAbort(child, input.signal);
114
116
  child.on('close', (code) => {
115
117
  clearTimeout(timer);
116
118
  clearInterval(idleCheck);
117
- writeFileSync(logPath, stalled ? `${chunks.join('')}
118
- [executor] ${Math.round(idleMs / 60_000)}분 동안 모델 출력이 없어 실행을 끊었다
119
- ` : chunks.join(''));
119
+ unwatch();
120
+ const aborted = input.signal?.aborted === true;
121
+ const note = aborted
122
+ ? '\n[executor] 에이전트 연결이 해제돼 실행을 멈췄다\n'
123
+ : stalled
124
+ ? `\n[executor] ${Math.round(idleMs / 60_000)}분 동안 모델 출력이 없어 실행을 끊었다\n`
125
+ : '';
126
+ writeFileSync(logPath, `${chunks.join('')}${note}`);
120
127
  resolve({
121
- outcome: stalled ? 'stalled' : timedOut ? 'timeout' : code === 0 ? 'completed' : 'failed',
128
+ outcome: aborted ? 'aborted' : stalled ? 'stalled' : timedOut ? 'timeout' : code === 0 ? 'completed' : 'failed',
122
129
  exitCode: code,
123
130
  durationMs: Date.now() - startedAt,
124
131
  logPath,
@@ -127,6 +134,7 @@ export async function runClaude(input) {
127
134
  child.on('error', (err) => {
128
135
  clearTimeout(timer);
129
136
  clearInterval(idleCheck);
137
+ unwatch();
130
138
  writeFileSync(logPath, `${chunks.join('')}\n[executor] spawn 실패: ${err.message}\n`);
131
139
  resolve({ outcome: 'failed', exitCode: null, durationMs: Date.now() - startedAt, logPath });
132
140
  });
@@ -0,0 +1,66 @@
1
+ import { spawn } from 'node:child_process';
2
+ // 이 에이전트의 연결이 해제됐다(서버 021 — 웹에서 본인·대표가 해제, 조직 탈퇴·해산). 알게 되는 곳이 여러 군데라 한 곳에 모은다:
3
+ // 폴링·브리핑·활동 보고 같은 HTTP 응답(NomosClient의 onApiError), 태스크 스트림의 4401 AGENT_DISCONNECTED 종료.
4
+ // 알게 되면 실행 중인 Claude(태스크 실행·상담·계약 초안)를 모두 멈춘다 — 해제 뒤에는 무엇을 해도 서버가 받지 않고(401),
5
+ // 실행이 끝까지 가면 룸에 "생각 중"만 남았다. 서버는 해제하면서 열린 실행을 이미 닫는다(outcome disconnected) — 그래서 종료 보고도 보내지 않는다.
6
+ export const AGENT_DISCONNECTED = 'AGENT_DISCONNECTED';
7
+ export const DISCONNECTED_MESSAGE = '이 에이전트의 연결이 해제돼 실행 중인 작업을 멈췄다 — 다시 쓰려면 connect를 실행하라';
8
+ // connect·start가 연결 해제로 끝날 때의 종료 코드(0이 아니다 — 스크립트·백그라운드 감시가 정상 종료와 가를 수 있게).
9
+ export const DISCONNECTED_EXIT_CODE = 3;
10
+ export function isAgentDisconnected(err) {
11
+ return typeof err === 'object' && err !== null && err.code === AGENT_DISCONNECTED;
12
+ }
13
+ // 프로세스 전체에서 하나 — 연결 해제는 에이전트 단위라 실행마다 따로 둘 이유가 없다.
14
+ export class AgentStop {
15
+ controller = new AbortController();
16
+ get signal() {
17
+ return this.controller.signal;
18
+ }
19
+ get stopped() {
20
+ return this.controller.signal.aborted;
21
+ }
22
+ // 에러가 연결 해제면 멈춘다. 멈췄으면 true(이미 멈춘 경우도).
23
+ noteError(err) {
24
+ if (isAgentDisconnected(err))
25
+ this.stop();
26
+ return this.stopped;
27
+ }
28
+ stop() {
29
+ if (!this.controller.signal.aborted)
30
+ this.controller.abort(new Error(DISCONNECTED_MESSAGE));
31
+ }
32
+ // 멈출 때 풀리는 약속 — 대기(waitForChange)와 경주시킨다.
33
+ whenStopped() {
34
+ if (this.stopped)
35
+ return Promise.resolve();
36
+ return new Promise((resolve) => this.controller.signal.addEventListener('abort', () => resolve(), { once: true }));
37
+ }
38
+ }
39
+ export const agentStop = new AgentStop();
40
+ // 자식 프로세스를 트리째 끝낸다. claude는 MCP 서버·Bash 도구(시험 실행 등)를 자식으로 띄운다.
41
+ // - Windows: claude를 node로 cli-wrapper를 거쳐 띄우므로(runner.ts의 resolveClaudeCommand) 그 node만 죽이면 실제 claude가 남는다 —
42
+ // taskkill /T(트리) /F로 끝낸다. taskkill을 못 띄우면 직접 kill로 내려간다.
43
+ // - 그 밖: SIGTERM을 claude에 보낸다. MCP 서버는 stdin(파이프)이 닫히면 함께 끝난다. 프로세스 그룹째(detached + kill(-pid)) 죽이지 않는 이유:
44
+ // detached로 띄우면 터미널의 Ctrl+C가 claude에 닿지 않아, connect를 끄면 claude가 고아로 남는다.
45
+ export function killProcessTree(child) {
46
+ if (child.exitCode !== null || child.signalCode !== null)
47
+ return;
48
+ if (process.platform === 'win32' && child.pid !== undefined) {
49
+ const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
50
+ killer.once('error', () => child.kill());
51
+ return;
52
+ }
53
+ child.kill('SIGTERM');
54
+ }
55
+ // signal이 멈추면 자식을 트리째 끝낸다. 돌려주는 함수로 구독을 푼다(자식이 먼저 끝나면 부른다).
56
+ export function killOnAbort(child, signal) {
57
+ if (!signal)
58
+ return () => { };
59
+ const onAbort = () => killProcessTree(child);
60
+ if (signal.aborted) {
61
+ onAbort();
62
+ return () => { };
63
+ }
64
+ signal.addEventListener('abort', onAbort, { once: true });
65
+ return () => signal.removeEventListener('abort', onAbort);
66
+ }
@@ -89,6 +89,12 @@ export function streamTaskSource(deps) {
89
89
  wake();
90
90
  return;
91
91
  }
92
+ if (ev.code === 4401 && ev.reason === 'AGENT_DISCONNECTED') {
93
+ deps.log('서버가 이 에이전트의 연결 해제로 푸시를 닫았다');
94
+ deps.onAgentDisconnected?.();
95
+ wake();
96
+ return;
97
+ }
92
98
  if (ev.code === 4401 && !refreshedInARow) {
93
99
  refreshedInARow = true;
94
100
  deps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capstone-nomos/cli",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "NOMOS 에이전트 CLI — 노트북의 Claude Code를 NOMOS 서버에 연결하고 배정된 태스크를 실행한다",
5
5
  "type": "module",
6
6
  "bin": {