@capstone-nomos/cli 0.1.8 → 0.1.10

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.
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  const MAX_TARGET = 300;
3
+ const MAX_SAY = 500;
3
4
  function clip(text) {
4
5
  const oneLine = text.split(/\r?\n/)[0].trim();
5
6
  return oneLine.length > MAX_TARGET ? `${oneLine.slice(0, MAX_TARGET - 1)}…` : oneLine;
@@ -26,6 +27,38 @@ function hideWorkspace(command, cwd) {
26
27
  }
27
28
  return out;
28
29
  }
30
+ function countLines(text) {
31
+ if (typeof text !== 'string')
32
+ return null;
33
+ const body = text.replace(/\r?\n$/, '');
34
+ return body === '' ? 0 : body.split(/\r?\n/).length;
35
+ }
36
+ // 수정·작성의 줄 수(내용은 보내지 않는다). Edit은 old/new, MultiEdit은 합, Write는 새 내용.
37
+ function lineCounts(tool, input) {
38
+ if (tool === 'Write') {
39
+ const added = countLines(input.content);
40
+ return added === null ? {} : { added };
41
+ }
42
+ const edits = tool === 'MultiEdit' && Array.isArray(input.edits) ? input.edits : [input];
43
+ let added = 0;
44
+ let removed = 0;
45
+ for (const e of edits) {
46
+ const a = countLines(e.new_string);
47
+ const r = countLines(e.old_string);
48
+ if (a === null || r === null)
49
+ return {};
50
+ added += a;
51
+ removed += r;
52
+ }
53
+ return { added, removed };
54
+ }
55
+ // 에이전트의 말 — 작업공간 경로를 지우고, 빈 줄을 줄이고, 500자에서 자른다.
56
+ export function toSay(text, cwd) {
57
+ const cleaned = hideWorkspace(text, cwd).replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
58
+ if (!cleaned)
59
+ return null;
60
+ return { kind: 'say', target: cleaned.length > MAX_SAY ? `${cleaned.slice(0, MAX_SAY - 1)}…` : cleaned };
61
+ }
29
62
  // 도구 하나 → 활동 한 줄. 룸에 보일 필요가 없는 것(NOMOS 도구 — 제출·노트는 서버 이벤트로 이미 보인다, 할 일 목록)은 null.
30
63
  export function toActivity(tool, input, cwd) {
31
64
  // ToolSearch는 Claude Code가 도구 정의를 불러오는 내부 동작이다(실측: 실행마다 첫 줄로 찍혔다).
@@ -65,31 +98,64 @@ export function toActivity(tool, input, cwd) {
65
98
  default:
66
99
  item = { kind: 'other', target: tool };
67
100
  }
68
- return item ? { kind: item.kind, target: clip(item.target) } : null;
101
+ if (!item)
102
+ return null;
103
+ const counts = item.kind === 'edit' || item.kind === 'write' ? lineCounts(tool, input) : {};
104
+ return { kind: item.kind, target: clip(item.target), ...counts };
69
105
  }
70
- // stream-json 한 줄 → 그 줄에 든 도구 호출들. 형식이 다르거나 깨진 줄은 조용히 건너뛴다(룸 표시가 실행을 막으면 안 된다).
71
- export function activityFromStreamLine(line, cwd) {
72
- const trimmed = line.trim();
73
- if (!trimmed.startsWith('{'))
74
- return [];
75
- let msg;
76
- try {
77
- msg = JSON.parse(trimmed);
78
- }
79
- catch {
80
- return [];
81
- }
82
- if (msg.type !== 'assistant' || !Array.isArray(msg.message?.content))
83
- return [];
84
- const items = [];
85
- for (const block of msg.message.content) {
86
- if (block.type !== 'tool_use' || typeof block.name !== 'string')
87
- continue;
88
- const item = toActivity(block.name, (block.input ?? {}), cwd);
89
- if (item)
90
- items.push(item);
91
- }
92
- return items;
106
+ // 실행 하나의 stream-json을 따라가며 룸에 보낼 줄을 낸다. 도구 줄은 **결과가 온 뒤** 성공 여부를 붙여 낸다(tool_use_id로 짝짓기).
107
+ // 결과를 못 받은 도구는 finish()에서 성공 여부 없이 낸다. 형식이 다르거나 깨진 줄은 조용히 건너뛴다(룸 표시가 실행을 막으면 안 된다).
108
+ export class ActivityTracker {
109
+ cwd;
110
+ pending = new Map();
111
+ constructor(cwd) {
112
+ this.cwd = cwd;
113
+ }
114
+ observe(line) {
115
+ const trimmed = line.trim();
116
+ if (!trimmed.startsWith('{'))
117
+ return [];
118
+ let msg;
119
+ try {
120
+ msg = JSON.parse(trimmed);
121
+ }
122
+ catch {
123
+ return [];
124
+ }
125
+ if (!Array.isArray(msg.message?.content))
126
+ return [];
127
+ const out = [];
128
+ for (const block of msg.message.content) {
129
+ if (msg.type === 'assistant' && block.type === 'text' && typeof block.text === 'string') {
130
+ const say = toSay(block.text, this.cwd);
131
+ if (say)
132
+ out.push(say);
133
+ }
134
+ else if (msg.type === 'assistant' && block.type === 'tool_use' && typeof block.name === 'string') {
135
+ const item = toActivity(block.name, (block.input ?? {}), this.cwd);
136
+ if (!item)
137
+ continue;
138
+ if (typeof block.id === 'string')
139
+ this.pending.set(block.id, item);
140
+ else
141
+ out.push(item);
142
+ }
143
+ else if (msg.type === 'user' && block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
144
+ const item = this.pending.get(block.tool_use_id);
145
+ if (!item)
146
+ continue;
147
+ this.pending.delete(block.tool_use_id);
148
+ out.push({ ...item, ok: block.is_error !== true });
149
+ }
150
+ }
151
+ return out;
152
+ }
153
+ // 실행이 끝났다 — 결과를 못 받은 도구도 낸다.
154
+ finish() {
155
+ const rest = [...this.pending.values()];
156
+ this.pending.clear();
157
+ return rest;
158
+ }
93
159
  }
94
160
  // 활동을 모아 2초마다 한 번에 보낸다. 보내기 실패는 기록만 하고 넘어간다 — 룸 표시가 태스크 실행을 멈추면 안 된다.
95
161
  export class ActivityReporter {
@@ -100,6 +166,8 @@ export class ActivityReporter {
100
166
  timer = null;
101
167
  sending = Promise.resolve();
102
168
  warned = false;
169
+ // 옛 서버(020 전)는 say·성공 여부·줄 수를 400으로 거부한다 — 한 번 거부되면 그 실행 동안은 예전 형식(도구·대상만)으로 낮춰 보낸다.
170
+ legacy = false;
103
171
  constructor(client, taskId, log, intervalMs = 2_000) {
104
172
  this.client = client;
105
173
  this.taskId = taskId;
@@ -115,7 +183,7 @@ export class ActivityReporter {
115
183
  while (this.queue.length > 0) {
116
184
  const batch = this.queue.splice(0, 50);
117
185
  try {
118
- await this.client.postActivity(this.taskId, batch);
186
+ await this.send(batch);
119
187
  }
120
188
  catch (err) {
121
189
  if (!this.warned) {
@@ -127,6 +195,24 @@ export class ActivityReporter {
127
195
  });
128
196
  return this.sending;
129
197
  }
198
+ async send(batch) {
199
+ if (this.legacy)
200
+ return this.sendLegacy(batch);
201
+ try {
202
+ await this.client.postActivity(this.taskId, batch);
203
+ }
204
+ catch (err) {
205
+ if (err.status !== 400)
206
+ throw err;
207
+ this.legacy = true;
208
+ await this.sendLegacy(batch);
209
+ }
210
+ }
211
+ async sendLegacy(batch) {
212
+ const items = batch.filter((i) => i.kind !== 'say').map((i) => ({ kind: i.kind, target: i.target }));
213
+ if (items.length > 0)
214
+ await this.client.postActivity(this.taskId, items);
215
+ }
130
216
  async close() {
131
217
  if (this.timer)
132
218
  clearInterval(this.timer);
@@ -25,7 +25,7 @@ import { ALLOWED_BASH_PREFIXES, buildMcpConfig } from '../bridge/claude-args.js'
25
25
  import { prepareDependencies } from './setup.js';
26
26
  import { credentialsPath, readCredentials, updateAccessToken, writeCredentials, } from '../bridge/credentials.js';
27
27
  import { NomosApiError, NomosClient } from '../bridge/nomos-client.js';
28
- import { ActivityReporter, activityFromStreamLine, RunObserver } from './activity.js';
28
+ import { ActivityReporter, ActivityTracker, RunObserver } from './activity.js';
29
29
  import { connect, DEFAULT_SERVER, serverCalls } from './connect.js';
30
30
  import { deviceLogin } from './device-login.js';
31
31
  import { cliVersion, mcpServerPath } from './paths.js';
@@ -128,6 +128,7 @@ async function handleTask(client, projectId, task) {
128
128
  });
129
129
  const reporter = runOpen ? new ActivityReporter(client, task.id, log) : null;
130
130
  const observer = new RunObserver(workspace.dir);
131
+ const tracker = new ActivityTracker(workspace.dir);
131
132
  // 의존성은 모델이 아니라 여기서 설치한다(설치 스크립트 끔). 실패해도 진행한다 — 프롬프트에 결과를 적는다.
132
133
  const setup = await prepareDependencies(workspace.dir, log);
133
134
  const before = headSha(workspace.dir);
@@ -140,11 +141,12 @@ async function handleTask(client, projectId, task) {
140
141
  questionRelay: briefing.questionRelay === true,
141
142
  onStdoutLine: (line) => {
142
143
  observer.observe(line);
143
- reporter?.push(activityFromStreamLine(line, workspace.dir));
144
+ reporter?.push(tracker.observe(line));
144
145
  },
145
146
  });
146
147
  const committed = headSha(workspace.dir) !== before;
147
148
  if (reporter) {
149
+ reporter.push(tracker.finish());
148
150
  await reporter.close();
149
151
  // 제출했는지는 서버가 태스크 상태로 판단해 룸에 남긴다("끝났지만 제출하지 않았다"가 대표에게 보인다).
150
152
  // 응답 없음(stalled)은 서버에 timeout으로 보고하고 사유 문장으로 구분한다 — 옛 서버는 outcome에 stalled를 모른다.
@@ -221,6 +223,7 @@ async function once() {
221
223
  }
222
224
  await handleTask(client, self.projectId, task);
223
225
  }
226
+ // 멈춘 이유를 돌려준다 — connect는 배정이 풀린 경우(해제·프로젝트 중단) 다시 배정을 기다린다.
224
227
  async function start() {
225
228
  const client = createClient();
226
229
  const self = await client.describeSelf();
@@ -258,6 +261,18 @@ async function start() {
258
261
  }
259
262
  }
260
263
  catch (err) {
264
+ // 대표나 본인이 웹에서 이 에이전트의 연결을 해제했다(서버 021) — 계속 물어도 같은 답이니 이유를 알리고 멈춘다.
265
+ if (err instanceof NomosApiError && err.code === 'AGENT_DISCONNECTED') {
266
+ log('이 에이전트는 연결이 해제됐다 — 다시 쓰려면 connect를 실행하라');
267
+ source.close();
268
+ return 'disconnected';
269
+ }
270
+ // 배정이 풀렸다(대표의 해제·프로젝트 중단, 서버 021). 같은 목록을 계속 물어도 거부된다.
271
+ if (err instanceof NomosApiError && err.code === 'NOT_PROJECT_MEMBER' && inFlight.size === 0) {
272
+ log('프로젝트 배정이 풀렸다(해제 또는 프로젝트 중단)');
273
+ source.close();
274
+ return 'unassigned';
275
+ }
261
276
  log(`태스크 목록 실패: ${err instanceof Error ? err.message : String(err)}`);
262
277
  }
263
278
  // 다른 역할이 이 역할에 물은 질문 — 질문이 생기면 서버가 { type: 'questions' }로 깨운다. 신호가 없으면 1분마다만 확인한다.
@@ -367,16 +382,21 @@ async function connectCommand(argv) {
367
382
  log(`${t.name}을 실행할 수 없다: ${t.detail}\n → ${t.hint}`);
368
383
  throw new Error('필요한 도구가 없어 시작하지 않는다');
369
384
  }
370
- await connect({
371
- server,
372
- agentName,
373
- readCredentials,
374
- login: () => loginTo(server, agentName, args.connectKey),
375
- ...serverCalls({ read: readCredentials, updateAccessToken }),
376
- sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
377
- log,
378
- });
379
- await start();
385
+ // 배정이 풀리면(해제·프로젝트 중단) 창을 닫지 않고 다시 배정을 기다린다. 연결 해제면 멈춘다.
386
+ for (;;) {
387
+ await connect({
388
+ server,
389
+ agentName,
390
+ readCredentials,
391
+ login: () => loginTo(server, agentName, args.connectKey),
392
+ ...serverCalls({ read: readCredentials, updateAccessToken }),
393
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
394
+ log,
395
+ });
396
+ if ((await start()) !== 'unassigned')
397
+ return;
398
+ log('다시 배정을 기다린다');
399
+ }
380
400
  }
381
401
  async function doctor(argv) {
382
402
  const { json } = parseArgs(argv);
@@ -458,8 +478,10 @@ async function main() {
458
478
  return refresh();
459
479
  if (command === 'once')
460
480
  return once();
461
- if (command === 'start')
462
- return start();
481
+ if (command === 'start') {
482
+ await start();
483
+ return;
484
+ }
463
485
  if (command === 'pm-worker')
464
486
  return pmWorker();
465
487
  if (command === 'clean') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capstone-nomos/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "NOMOS 에이전트 CLI — 노트북의 Claude Code를 NOMOS 서버에 연결하고 배정된 태스크를 실행한다",
5
5
  "type": "module",
6
6
  "bin": {