@capstone-nomos/cli 0.1.0 → 0.1.1

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.
@@ -84,6 +84,11 @@ export class NomosClient {
84
84
  return data.tasks;
85
85
  }
86
86
  // 프롬프트와 .claude/settings.json을 만드는 데 필요한 것 한 번에.
87
+ // 지금 가져갈 수 있는 태스크(서버가 역할·선행·시작 여부로 거른 것). 웹소켓 푸시와 같은 목록이다.
88
+ async listClaimableTasks() {
89
+ const data = (await this.request('GET', '/api/agents/me/tasks'));
90
+ return data.tasks;
91
+ }
87
92
  async getBriefing(taskId) {
88
93
  return (await this.request('GET', `/api/tasks/${taskId}/briefing`));
89
94
  }
@@ -93,6 +98,17 @@ export class NomosClient {
93
98
  async reportBranch(taskId, branchName) {
94
99
  await this.request('PATCH', `/api/tasks/${taskId}/branch`, { branchName });
95
100
  }
101
+ // 중계 모드 PM 작업(대표 노트북의 pm-worker만).
102
+ async nextPmJob() {
103
+ const data = (await this.request('GET', '/api/pm/jobs/next'));
104
+ return data.job;
105
+ }
106
+ async submitPmJobResult(jobId, result) {
107
+ await this.request('POST', `/api/pm/jobs/${jobId}/result`, result);
108
+ }
109
+ async failPmJob(jobId, message) {
110
+ await this.request('POST', `/api/pm/jobs/${jobId}/failure`, { message });
111
+ }
96
112
  async request(method, path, body) {
97
113
  const first = await this.send(method, path, body);
98
114
  if (!needsRefresh(first))
@@ -12,6 +12,7 @@
12
12
  // executor once READY 태스크 하나만 처리하고 종료 (개발·데모용)
13
13
  // executor start 10초 폴링
14
14
  // executor clean worktree 정리
15
+ // nomos pm-worker (대표 전용, 서버가 PM_PROVIDER=relay일 때) PM의 모델 호출을 이 노트북의 Claude Code로 대신 실행
15
16
  //
16
17
  // 서버를 옮길 때 baseUrl만 바꾸면 안 된다. 토큰은 발급한 서버의 서명 키와 그 DB의 agents 행에 묶여 있어
17
18
  // 다른 서버에서는 전부 401이다. 원격에 가입해 받은 연결 키로 login을 다시 해야 한다.
@@ -21,17 +22,20 @@ import path from 'node:path';
21
22
  import { createInterface } from 'node:readline/promises';
22
23
  import { buildMcpConfig } from '../bridge/claude-args.js';
23
24
  import { credentialsPath, readCredentials, updateAccessToken, writeCredentials, } from '../bridge/credentials.js';
24
- import { NomosClient } from '../bridge/nomos-client.js';
25
+ import { NomosApiError, NomosClient } from '../bridge/nomos-client.js';
25
26
  import { connect, DEFAULT_SERVER, serverCalls } from './connect.js';
26
27
  import { deviceLogin } from './device-login.js';
27
28
  import { cliVersion, mcpServerPath } from './paths.js';
28
29
  import { checkTools } from './preflight.js';
30
+ import { handleNextPmJob } from './pm-worker.js';
29
31
  import { buildTaskPrompt } from './prompt.js';
30
32
  import { runClaude } from './runner.js';
33
+ import { streamTaskSource } from './stream-source.js';
31
34
  import { pollingTaskSource } from './task-source.js';
32
35
  import { runLint, runSpecTests } from './verify.js';
33
36
  import { ensureRepo } from './repo-checkout.js';
34
37
  import { cleanWorkspaces, headSha, prepareWorkspace } from './workspace.js';
38
+ // 푸시가 오지 않아도 이 간격으로 한 번씩 다시 본다(끊겼을 때는 이 간격으로 폴링한다).
35
39
  const POLL_INTERVAL_MS = 10_000;
36
40
  function log(line) {
37
41
  process.stdout.write(`[nomos] ${line}\n`);
@@ -124,10 +128,10 @@ async function reportBridgeStages(client, taskId, workspaceDir, specTests) {
124
128
  async function once() {
125
129
  const client = createClient();
126
130
  const self = await client.describeSelf();
127
- const source = pollingTaskSource(client, self.projectId);
131
+ const source = pollingTaskSource(client);
128
132
  const [task] = await source.nextTasks(1);
129
133
  if (!task) {
130
- log('READY 태스크가 없다');
134
+ log('지금 가져갈 수 있는 태스크가 없다(프로젝트 시작 전이거나, 선행 태스크가 끝나지 않았거나, READY가 없다)');
131
135
  return;
132
136
  }
133
137
  await handleTask(client, self.projectId, task);
@@ -135,28 +139,42 @@ async function once() {
135
139
  async function start() {
136
140
  const client = createClient();
137
141
  const self = await client.describeSelf();
138
- const source = pollingTaskSource(client, self.projectId);
142
+ // 서버 푸시(웹소켓)로 받는다. 끊겨 있는 동안은 같은 목록을 HTTP로 읽는다.
143
+ const source = streamTaskSource({
144
+ baseUrl: readCredentials().baseUrl,
145
+ accessToken: () => readCredentials().accessToken,
146
+ refresh: async () => {
147
+ if ((await serverCalls({ read: readCredentials, updateAccessToken }).refresh()) === 'rejected') {
148
+ throw new Error('refresh token이 거부됐다 — connect를 다시 실행하라');
149
+ }
150
+ },
151
+ fallback: () => pollingTaskSource(client).nextTasks(Number.MAX_SAFE_INTEGER),
152
+ log,
153
+ });
139
154
  log(`시작 — 프로젝트 ${self.projectId}, 역할 ${self.teamRole}, 동시 실행 ${self.maxConcurrent} (${source.kind})`);
140
155
  const inFlight = new Set();
156
+ // 태스크 하나가 끝나면 자리가 난다 — 다음 푸시나 간격을 기다리지 않고 바로 다시 본다.
157
+ let finished = () => { };
141
158
  for (;;) {
142
159
  try {
143
160
  const capacity = self.maxConcurrent - inFlight.size;
144
161
  if (capacity > 0) {
145
- for (const task of await source.nextTasks(capacity)) {
146
- // 이미 처리 중인 태스크는 건너뛴다. 폴링은 같은 목록을 여러 번 준다.
147
- if (inFlight.has(task.id))
148
- continue;
162
+ // 처리 중인 것도 목록에 남아 있을 수 있으니(수령 전) 그만큼 더 받아 거른다.
163
+ for (const task of (await source.nextTasks(capacity + inFlight.size)).filter((t) => !inFlight.has(t.id)).slice(0, capacity)) {
149
164
  inFlight.add(task.id);
150
165
  void handleTask(client, self.projectId, task)
151
166
  .catch((err) => log(`태스크 ${task.id} 실패: ${err instanceof Error ? err.message : String(err)}`))
152
- .finally(() => inFlight.delete(task.id));
167
+ .finally(() => {
168
+ inFlight.delete(task.id);
169
+ finished();
170
+ });
153
171
  }
154
172
  }
155
173
  }
156
174
  catch (err) {
157
- log(`폴링 실패: ${err instanceof Error ? err.message : String(err)}`);
175
+ log(`태스크 목록 실패: ${err instanceof Error ? err.message : String(err)}`);
158
176
  }
159
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
177
+ await Promise.race([source.waitForChange(POLL_INTERVAL_MS), new Promise((resolve) => (finished = resolve))]);
160
178
  }
161
179
  }
162
180
  // 토큰이 헤더로 다니므로 원격은 https만 받는다. 로컬 개발 서버만 예외다.
@@ -311,6 +329,24 @@ async function refresh() {
311
329
  log(`재발급은 됐지만 아직 프로젝트에 배정되지 않은 것 같다: ${err instanceof Error ? err.message : String(err)}`);
312
330
  }
313
331
  }
332
+ const PM_POLL_INTERVAL_MS = 3_000;
333
+ async function pmWorker() {
334
+ const client = createClient();
335
+ log('pm-worker 시작 — 대표가 PM에 계획을 요청하면 여기서 claude가 돈다 (Ctrl+C로 종료)');
336
+ for (;;) {
337
+ try {
338
+ if (await handleNextPmJob(client, log))
339
+ continue;
340
+ }
341
+ catch (err) {
342
+ // 설정이 틀린 경우는 기다려도 풀리지 않는다 — 바로 멈춘다.
343
+ if (err instanceof NomosApiError && (err.code === 'PM_RELAY_DISABLED' || err.code === 'NOT_REPRESENTATIVE'))
344
+ throw err;
345
+ log(`폴링 실패: ${err instanceof Error ? err.message : String(err)}`);
346
+ }
347
+ await new Promise((resolve) => setTimeout(resolve, PM_POLL_INTERVAL_MS));
348
+ }
349
+ }
314
350
  async function main() {
315
351
  const command = process.argv[2];
316
352
  if (command === 'connect')
@@ -329,6 +365,8 @@ async function main() {
329
365
  return once();
330
366
  if (command === 'start')
331
367
  return start();
368
+ if (command === 'pm-worker')
369
+ return pmWorker();
332
370
  if (command === 'clean') {
333
371
  const { pruned, removed } = cleanWorkspaces();
334
372
  log(`worktree prune: ${pruned.join(', ') || '없음'} / 삭제: ${removed ?? '없음'}`);
@@ -340,6 +378,7 @@ async function main() {
340
378
  ' doctor 설치 상태 점검',
341
379
  ' login [주소] [--name 이름] [--connect-key] 로그인만',
342
380
  ' refresh | once | start | clean | --version',
381
+ ' pm-worker (대표 전용, 서버가 PM_PROVIDER=relay일 때) PM 모델 호출을 이 노트북의 Claude Code로',
343
382
  '',
344
383
  ].join('\n'));
345
384
  process.exitCode = 1;
@@ -0,0 +1,110 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { resolveClaudeCommand } from './runner.js';
6
+ // 지침·프롬프트는 길어서(이전 초안이 붙는 수정 요청) 명령줄로 넘기지 않는다 — Windows 명령줄은 32k자에서 잘린다.
7
+ // 지침은 파일, 프롬프트는 stdin. 도구는 전부 끄고(--tools ""), 사용자 MCP·세션 기록도 쓰지 않는다.
8
+ export function buildPmClaudeArgs(job, systemPromptFile) {
9
+ return [
10
+ '-p',
11
+ '--output-format',
12
+ 'json',
13
+ '--model',
14
+ job.request.model,
15
+ '--effort',
16
+ job.request.effort,
17
+ '--system-prompt-file',
18
+ systemPromptFile,
19
+ '--json-schema',
20
+ JSON.stringify(job.request.jsonSchema),
21
+ '--tools',
22
+ '',
23
+ '--strict-mcp-config',
24
+ '--no-session-persistence',
25
+ ];
26
+ }
27
+ // 출력 → 서버에 돌려줄 결과. 실패면 { error }.
28
+ export function toJobResult(out, requestedModel) {
29
+ if (out.is_error)
30
+ return { error: `claude reported an error (${out.subtype ?? 'unknown'}): ${(out.result ?? '').slice(0, 500)}` };
31
+ const text = out.structured_output !== undefined && out.structured_output !== null
32
+ ? JSON.stringify(out.structured_output)
33
+ : (out.result ?? '');
34
+ if (!text)
35
+ return { error: 'claude returned no output' };
36
+ // 실제로 응답한 모델: 출력이 가장 많은 모델(부수 호출에 작은 모델이 섞일 수 있다).
37
+ const served = Object.entries(out.modelUsage ?? {}).sort((a, b) => (b[1].outputTokens ?? 0) - (a[1].outputTokens ?? 0))[0]?.[0] ?? requestedModel;
38
+ // stop_reason은 그대로 넘긴다. Claude Code는 구조화 출력을 도구 호출로 내므로 정상 완료도 'tool_use'로 온다(실측) —
39
+ // 서버는 refusal·max_tokens만 따로 다루므로 그대로 둬도 흐름은 같고, 기록은 실제 값이 남는다.
40
+ return {
41
+ stopReason: out.stop_reason ?? null,
42
+ servedModel: served,
43
+ text,
44
+ usage: {
45
+ inputTokens: out.usage?.input_tokens ?? 0,
46
+ outputTokens: out.usage?.output_tokens ?? 0,
47
+ cacheWriteTokens: out.usage?.cache_creation_input_tokens ?? 0,
48
+ cacheReadTokens: out.usage?.cache_read_input_tokens ?? 0,
49
+ },
50
+ };
51
+ }
52
+ const spawnClaude = (args, stdin, cwd) => {
53
+ const { command, commandArgs } = resolveClaudeCommand(args);
54
+ return new Promise((resolve, reject) => {
55
+ // shell을 쓰지 않는다 — 인자가 명령으로 해석되지 않게(runner.ts와 같은 이유).
56
+ const child = spawn(command, commandArgs, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
57
+ let stdout = '';
58
+ let stderr = '';
59
+ child.stdout.on('data', (c) => (stdout += c.toString()));
60
+ child.stderr.on('data', (c) => (stderr += c.toString()));
61
+ child.once('error', reject);
62
+ child.once('close', (exitCode) => resolve({ exitCode, stdout, stderr }));
63
+ child.stdin.end(stdin);
64
+ });
65
+ };
66
+ export async function runPmJob(job, run = spawnClaude) {
67
+ // 빈 임시 폴더에서 돈다 — 레포·CLAUDE.md를 읽지 않게. 끝나면 지운다(지침 사본이 남지 않게).
68
+ const dir = mkdtempSync(path.join(os.tmpdir(), 'nomos-pm-'));
69
+ try {
70
+ const systemFile = path.join(dir, 'system.md');
71
+ writeFileSync(systemFile, job.request.system, 'utf8');
72
+ const { exitCode, stdout, stderr } = await run(buildPmClaudeArgs(job, systemFile), job.request.user, dir);
73
+ let out;
74
+ try {
75
+ out = JSON.parse(stdout);
76
+ }
77
+ catch {
78
+ return { error: `claude exited ${exitCode} without JSON output: ${(stderr || stdout).slice(0, 500)}` };
79
+ }
80
+ return toJobResult(out, job.request.model);
81
+ }
82
+ finally {
83
+ rmSync(dir, { recursive: true, force: true });
84
+ }
85
+ }
86
+ // 작업 하나를 가져와 처리한다. 처리했으면 true.
87
+ export async function handleNextPmJob(client, log, run = spawnClaude) {
88
+ const job = (await client.nextPmJob());
89
+ if (!job)
90
+ return false;
91
+ log(`PM 작업 ${job.id} (계획 ${job.planId}, ${job.purpose}) — claude 실행 중`);
92
+ const started = Date.now();
93
+ let result;
94
+ try {
95
+ result = await runPmJob(job, run);
96
+ }
97
+ catch (err) {
98
+ result = { error: err instanceof Error ? err.message : String(err) };
99
+ }
100
+ const sec = Math.round((Date.now() - started) / 1000);
101
+ if ('error' in result) {
102
+ log(`실패(${sec}s): ${result.error}`);
103
+ await client.failPmJob(job.id, result.error.slice(0, 2000));
104
+ }
105
+ else {
106
+ log(`완료(${sec}s, 출력 ${result.usage.outputTokens} 토큰) — 서버로 보냄`);
107
+ await client.submitPmJobResult(job.id, result);
108
+ }
109
+ return true;
110
+ }
@@ -0,0 +1,114 @@
1
+ export const AGENT_STREAM_PATH = '/api/agents/stream';
2
+ export function streamUrl(baseUrl) {
3
+ const url = new URL(AGENT_STREAM_PATH, baseUrl);
4
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
5
+ return url.toString();
6
+ }
7
+ export function streamTaskSource(deps) {
8
+ const WS = deps.WebSocketImpl ?? globalThis.WebSocket;
9
+ const backoff = deps.backoff ?? { initialMs: 1000, maxMs: 30_000 };
10
+ let latest = [];
11
+ let connected = false;
12
+ let closed = false;
13
+ let delay = backoff.initialMs;
14
+ let refreshedInARow = false;
15
+ let ws = null;
16
+ let reconnectTimer = null;
17
+ const waiters = new Set();
18
+ const wake = () => {
19
+ for (const w of waiters)
20
+ w();
21
+ waiters.clear();
22
+ };
23
+ const schedule = () => {
24
+ if (closed)
25
+ return;
26
+ reconnectTimer = setTimeout(connect, delay);
27
+ delay = Math.min(delay * 2, backoff.maxMs);
28
+ };
29
+ function connect() {
30
+ reconnectTimer = null;
31
+ if (closed)
32
+ return;
33
+ const socket = new WS(streamUrl(deps.baseUrl));
34
+ ws = socket;
35
+ socket.onopen = () => socket.send(JSON.stringify({ type: 'auth', token: deps.accessToken() }));
36
+ socket.onmessage = (ev) => {
37
+ let msg;
38
+ try {
39
+ msg = JSON.parse(String(ev.data));
40
+ }
41
+ catch {
42
+ return;
43
+ }
44
+ if (msg.type === 'ready') {
45
+ connected = true;
46
+ delay = backoff.initialMs;
47
+ refreshedInARow = false;
48
+ deps.log(`서버 푸시 연결됨 — 프로젝트 ${msg.projectId ?? '?'}`);
49
+ }
50
+ else if (msg.type === 'tasks') {
51
+ latest = msg.tasks ?? [];
52
+ wake();
53
+ }
54
+ else if (msg.type === 'error') {
55
+ deps.log(`서버 푸시 거부: ${msg.code ?? '?'} ${msg.message ?? ''}`.trim());
56
+ }
57
+ };
58
+ socket.onerror = () => { };
59
+ socket.onclose = (ev) => {
60
+ const wasConnected = connected;
61
+ connected = false;
62
+ ws = null;
63
+ if (closed)
64
+ return;
65
+ if (ev.code === 4403) {
66
+ deps.log('이 에이전트는 프로젝트 멤버가 아니다 — 푸시를 멈춘다(폴링으로 계속 확인한다)');
67
+ wake();
68
+ return;
69
+ }
70
+ if (ev.code === 4401 && !refreshedInARow) {
71
+ refreshedInARow = true;
72
+ deps
73
+ .refresh()
74
+ .then(() => connect())
75
+ .catch((err) => deps.log(`토큰 재발급 실패 — 푸시를 멈춘다: ${err instanceof Error ? err.message : String(err)}`));
76
+ return;
77
+ }
78
+ if (wasConnected)
79
+ deps.log(`서버 푸시 끊김(${ev.code}) — 다시 연결한다. 그동안은 폴링한다`);
80
+ wake();
81
+ schedule();
82
+ };
83
+ }
84
+ connect();
85
+ return {
86
+ kind: 'push',
87
+ get connected() {
88
+ return connected;
89
+ },
90
+ async nextTasks(limit) {
91
+ // 연결돼 있으면 받은 스냅샷, 아니면 같은 목록을 HTTP로.
92
+ const tasks = connected ? latest : await deps.fallback();
93
+ return tasks.slice(0, limit);
94
+ },
95
+ waitForChange(maxMs) {
96
+ return new Promise((resolve) => {
97
+ const done = () => {
98
+ clearTimeout(timer);
99
+ waiters.delete(done);
100
+ resolve();
101
+ };
102
+ const timer = setTimeout(done, maxMs);
103
+ waiters.add(done);
104
+ });
105
+ },
106
+ close() {
107
+ closed = true;
108
+ if (reconnectTimer)
109
+ clearTimeout(reconnectTimer);
110
+ ws?.close(1000, 'bye');
111
+ wake();
112
+ },
113
+ };
114
+ }
@@ -1,10 +1,12 @@
1
- // 서버가 에이전트 토큰을 보고 자기 역할로 필터해 준다 — 여기서 역할을 다시 거르지 않는다.
2
- export function pollingTaskSource(client, projectId) {
1
+ // 서버가 이 에이전트의 역할·선행·프로젝트 시작 여부로 거른 목록(GET /api/agents/me/tasks)을 읽는다 — 여기서 다시 거르지 않는다.
2
+ export function pollingTaskSource(client) {
3
3
  return {
4
4
  kind: 'polling',
5
5
  async nextTasks(limit) {
6
- const rows = await client.listTasks(projectId, { state: 'READY', limit });
7
- return rows;
6
+ const rows = await client.listClaimableTasks();
7
+ return rows.slice(0, limit);
8
8
  },
9
+ waitForChange: (maxMs) => new Promise((resolve) => setTimeout(resolve, maxMs)),
10
+ close: () => { },
9
11
  };
10
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capstone-nomos/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "NOMOS 에이전트 CLI — 노트북의 Claude Code를 NOMOS 서버에 연결하고 배정된 태스크를 실행한다",
5
5
  "type": "module",
6
6
  "bin": {