deel-local-cli 1.17.5 → 1.17.7

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.
package/README.ko.md CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  [![Node.js CI](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/test.yml?branch=main&logo=github&logoColor=white&label=Node.js%20CI)](https://github.com/jysvai/deel-local-cli/actions/workflows/test.yml)
22
22
  [![CodeQL](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/codeql.yml?branch=main&logo=github&logoColor=white&label=CodeQL)](https://github.com/jysvai/deel-local-cli/actions/workflows/codeql.yml)
23
- [![tests](https://img.shields.io/badge/tests-7%2C991%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/ko/develop.md)
23
+ [![tests](https://img.shields.io/badge/tests-8%2C032%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/ko/develop.md)
24
24
 
25
25
  [![dependencies](https://img.shields.io/badge/dependencies-0-1a7f37)](https://www.npmjs.com/package/deel-local-cli?activeTab=dependencies)
26
26
  [![ESM](https://img.shields.io/badge/ESM-Node%2020%2B-5FA04E?logo=javascript&logoColor=white)](package.json)
@@ -53,6 +53,39 @@
53
53
 
54
54
  ---
55
55
 
56
+ ### 그래서 뭐가 다른데 — 나란히 세워 재 봤습니다
57
+
58
+ 같은 프로젝트, 같은 일, 같은 모델(AWS Bedrock · Claude Opus 5). 양쪽 다 공동
59
+ 편집기의 **데이터 손실 결함을 찾아 고치게** 했습니다.
60
+
61
+ | | Claude CLI | deel |
62
+ |---|---|---|
63
+ | 관측 비용 | $7.90 | **$6.57** · -16.8% |
64
+ | 소요 시간 | 38분 00초 | **36분 59초** · -2.7% |
65
+ | 종합 상대지수 | 97.9% | 98.5% |
66
+ | 곁들인 것 | `claude-token-saver` (별도 npm) | **없음 — 자체 기능만** |
67
+
68
+ 한 줄로 줄이면: **품질은 사실상 동급인데, 절감 패키지를 안 붙이고도 더 쌌습니다.**
69
+
70
+ <picture>
71
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jysvai/deel-local-cli/main/docs/assets/benchmark-dark.svg">
72
+ <img alt="Claude CLI 와 deel 벤치마크 — 항목별 상대지수" src="https://raw.githubusercontent.com/jysvai/deel-local-cli/main/docs/assets/benchmark-light.svg" width="900">
73
+ </picture>
74
+
75
+ 두 실행은 같은 버그를 두 번 찾은 게 아닙니다. **서로 다른 실패 경로**를 하나씩
76
+ 없앴습니다 — 한쪽은 한글 조합 중 기준값이 오염되는 길, 한쪽은 `await` 사이에
77
+ 입력이 덮이는 경쟁 상태(TOCTOU). 둘 다 고치기 전에 재현했고, 최소 수정 뒤
78
+ 회귀 검증까지 갔습니다.
79
+
80
+ > **이 자료로 말할 수 없는 것도 적어 둡니다.** 표본이 2회입니다. Claude 쪽에는
81
+ > 외부 절감 패키지가 붙어 있어서 **엔진 자체의 효율 차이라고 단정할 수 없습니다.**
82
+ > 그리고 **전체 회귀 범위는 Claude 가 넓었습니다** — 우리가 진 항목이고, 그것이
83
+ > 1.17.7 에서 `Verify` 를 고치게 만들었습니다(확인 방법을 하나가 아니라 전부 찾습니다).
84
+
85
+ → [전체 기록과 방법론](docs/ko/benchmark.md) · [원본 PDF](pdf/cli_benchmark_anonymized_v4.pdf)
86
+
87
+ ---
88
+
56
89
  ## 목차
57
90
 
58
91
  - [왜 만들었나](#왜-만들었나)
@@ -268,7 +301,7 @@ deel --offline
268
301
  무엇이 어디로 갈 수 있는지는 켤 때 화면 맨 위에 늘 적혀 있습니다.
269
302
 
270
303
  ```
271
- deel 1.17.5 ⌂ 이 안
304
+ deel 1.17.7 ⌂ 이 안
272
305
  보냄 이 컴퓨터 안 127.0.0.1:11434 ← 여기 말고는 어디로도 안 갑니다
273
306
  ```
274
307
 
@@ -1185,7 +1218,7 @@ deel stats # 이 폴더에서 실제로 무엇을 했나 (.de
1185
1218
  ## 개발
1186
1219
 
1187
1220
  ```bash
1188
- npm test 전체 검증 (7,991항목 — 몇몇은 터미널에 따라 갈립니다)
1221
+ npm test 전체 검증 (8,032항목 — 몇몇은 터미널에 따라 갈립니다)
1189
1222
  npm run coverage 검사가 소스의 어디를 밟았는지
1190
1223
  npm run verify 반입·통신 검증만
1191
1224
  npm run bench 편집 성공률 측정
@@ -1254,6 +1287,8 @@ zip 은 진짜 `unzip` 으로, tar 는 진짜 `tar` 가 만든 것을 읽혀 교
1254
1287
 
1255
1288
  | 판 | 무엇이 바뀌었나 |
1256
1289
  |---|---|
1290
+ | [1.17.7](docs/ko/releases/1.17.md#1177) | 나란히 세워 재 봤고, 진 항목을 고쳤습니다 |
1291
+ | [1.17.6](docs/ko/releases/1.17.md#1176) | 살아 있다는 신호만 오고 내용이 안 오면, 여태 영원히 기다렸습니다 |
1257
1292
  | [1.17.5](docs/ko/releases/1.17.md#1175) | 한 턴 안에서 단계가 일을 따라갑니다 — 그리고 읽기 도구 넷이 여태 줄을 서 있었습니다 |
1258
1293
  | [1.17.4](docs/ko/releases/1.17.md#1174) | 같은 요청을 한 번 더 보내던 자리들 — 그리고 배운 것이 스스로를 봉인하던 자리 |
1259
1294
  | [1.17.3](docs/ko/releases/1.17.md#1173) | 「같은 대화다」 를 적는 표준 이름이 없습니다 — 아는 이름을 다 답니다 |
package/README.md CHANGED
@@ -20,7 +20,7 @@ Vendor APIs connect too — **only when you say so**
20
20
 
21
21
  [![Node.js CI](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/test.yml?branch=main&logo=github&logoColor=white&label=Node.js%20CI)](https://github.com/jysvai/deel-local-cli/actions/workflows/test.yml)
22
22
  [![CodeQL](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/codeql.yml?branch=main&logo=github&logoColor=white&label=CodeQL)](https://github.com/jysvai/deel-local-cli/actions/workflows/codeql.yml)
23
- [![tests](https://img.shields.io/badge/tests-7%2C991%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/en/develop.md)
23
+ [![tests](https://img.shields.io/badge/tests-8%2C032%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/en/develop.md)
24
24
 
25
25
  [![dependencies](https://img.shields.io/badge/dependencies-0-1a7f37)](https://www.npmjs.com/package/deel-local-cli?activeTab=dependencies)
26
26
  [![ESM](https://img.shields.io/badge/ESM-Node%2020%2B-5FA04E?logo=javascript&logoColor=white)](package.json)
@@ -60,6 +60,41 @@ summary are what deel actually printed.
60
60
 
61
61
  ---
62
62
 
63
+ ### So what is actually different — we put them side by side
64
+
65
+ Same project, same job, same model (Claude Opus 5 via AWS Bedrock). Both were asked to
66
+ **find and fix a data-loss defect** in a collaborative editor.
67
+
68
+ | | Claude CLI | deel |
69
+ |---|---|---|
70
+ | Observed cost | $7.90 | **$6.57** · -16.8% |
71
+ | Wall time | 38m 00s | **36m 59s** · -2.7% |
72
+ | Composite index | 97.9% | 98.5% |
73
+ | Extras | `claude-token-saver` (separate npm) | **none — built-in only** |
74
+
75
+ In one line: **quality came out effectively equal, and it cost less without bolting on
76
+ a savings package.**
77
+
78
+ <picture>
79
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jysvai/deel-local-cli/main/docs/assets/benchmark-dark.svg">
80
+ <img alt="Claude CLI and deel benchmark — relative index by dimension" src="https://raw.githubusercontent.com/jysvai/deel-local-cli/main/docs/assets/benchmark-light.svg" width="900">
81
+ </picture>
82
+
83
+ The two runs did not find the same bug twice. Each removed **a different failure
84
+ path** — one where the base value is corrupted mid-IME-composition, one a TOCTOU race
85
+ where input is overwritten across an `await`. Both reproduced the defect before fixing
86
+ it, and carried the minimal fix through regression verification.
87
+
88
+ > **What this data cannot say, stated plainly.** The sample is two runs. The Claude
89
+ > side had an external savings package attached, so **the gap cannot be attributed to
90
+ > engine efficiency.** And **Claude covered more regression scope** — that is the row
91
+ > we lost, and it is what made 1.17.7 fix `Verify` to find every way a project checks
92
+ > itself, not just one.
93
+
94
+ → [Full record and methodology](docs/en/benchmark.md) · [source PDF](pdf/cli_benchmark_anonymized_v4.pdf)
95
+
96
+ ---
97
+
63
98
  ## 60 seconds
64
99
 
65
100
  ```bash
@@ -339,7 +374,7 @@ deel --offline
339
374
  The destination is printed at the top of every session:
340
375
 
341
376
  ```
342
- deel 1.17.5 ⌂ inside
377
+ deel 1.17.7 ⌂ inside
343
378
  Sends to this machine 127.0.0.1:11434 ← nowhere else
344
379
  ```
345
380
 
@@ -1258,7 +1293,7 @@ Stored in `~/.deel/config.json`. A `.deel/config.json` in the project folder tak
1258
1293
  ## Development
1259
1294
 
1260
1295
  ```bash
1261
- npm test Full suite (7,991 checks; a few are TTY-dependent)
1296
+ npm test Full suite (8,032 checks; a few are TTY-dependent)
1262
1297
  npm run coverage Which lines the tests actually execute
1263
1298
  npm run verify Import + network checks only
1264
1299
  npm run bench Edit success rate
@@ -1313,6 +1348,8 @@ so one run tells you everything.
1313
1348
 
1314
1349
  | Version | What changed |
1315
1350
  |---|---|
1351
+ | [1.17.7](docs/en/releases/1.17.md#1177) | We measured it side by side, and fixed the row we lost |
1352
+ | [1.17.6](docs/en/releases/1.17.md#1176) | Only keep-alive and no content used to mean waiting forever |
1316
1353
  | [1.17.5](docs/en/releases/1.17.md#1175) | The phase now follows the work inside a turn — and four read tools had been queueing up |
1317
1354
  | [1.17.4](docs/en/releases/1.17.md#1174) | The places that sent the same request twice — and where what it learned sealed itself in |
1318
1355
  | [1.17.3](docs/en/releases/1.17.md#1173) | No standard name for "same conversation" — it sends every name it knows |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deel-local-cli",
3
- "version": "1.17.5",
3
+ "version": "1.17.7",
4
4
  "description": "A coding-agent CLI that runs on local models and private gateways. Zero dependencies, Node 20+.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/acp/serve.js CHANGED
@@ -50,7 +50,7 @@ import { allowEndpoint, setOffline } from '../safety/network.js';
50
50
  import { 지금모드, 바깥인가, 나갈수있나 } from '../safety/runmode.js';
51
51
  import { 주소가리기 } from '../safety/secrets.js';
52
52
  import { probeCtx, 기본값 as CTX_DEFAULT } from '../backend/ctxsize.js';
53
- import { 잠잠기본 } from '../backend/http.js';
53
+ import { 잠잠기본, 무소식기본 } from '../backend/http.js';
54
54
  import { 인증서설정 } from '../backend/clientcert.js';
55
55
  import { 다붙이기 } from '../backend/mcp.js';
56
56
  import { 배움 } from '../agent/evolve.js';
@@ -229,6 +229,8 @@ export async function acp(opts = {}) {
229
229
  * 모았다가 한 번에 주는 사내 게이트웨이면 이 값을 올린다.
230
230
  */
231
231
  잠잠: prof.잠잠 ?? prof.streamIdleMs ?? 잠잠기본,
232
+ // 바이트는 오는데 내용이 안 올 때의 전체 상한 (backend/http.js 의 무소식기본).
233
+ 무소식: prof.무소식 ?? prof.streamNoNewsMs ?? 무소식기본,
232
234
  // 게이트웨이가 우리 인증서를 요구하면 (mTLS, backend/clientcert.js).
233
235
  // 파일 경로만 싣는다 — 알맹이는 요청 직전에 읽는다.
234
236
  인증서: 인증서설정(prof),
@@ -34,7 +34,7 @@
34
34
  */
35
35
  import { load, activeProfile, resolveKey } from '../config.js';
36
36
  import { isLocalHost, isOffline } from '../safety/network.js';
37
- import { 잠잠기본 } from '../backend/http.js';
37
+ import { 잠잠기본, 무소식기본 } from '../backend/http.js';
38
38
  import { 인증서설정 } from '../backend/clientcert.js';
39
39
 
40
40
  // 컨텍스트를 못 알아냈을 때 쓰는 값. repl.js 와 같은 값을 봐야 한다.
@@ -59,6 +59,8 @@ export function 연결만들기(prof, { ctx = null, maxTokens = null } = {}) {
59
59
  maxTokens: maxTokens ?? prof.maxTokens ?? null,
60
60
  streaming: prof.streaming ?? false,
61
61
  잠잠: prof.잠잠 ?? prof.streamIdleMs ?? 잠잠기본,
62
+ // 바이트는 오는데 내용이 안 올 때의 전체 상한 (backend/http.js 의 무소식기본).
63
+ 무소식: prof.무소식 ?? prof.streamNoNewsMs ?? 무소식기본,
62
64
  인증서: 인증서설정(prof),
63
65
  tools: prof.tools ?? false,
64
66
  json: prof.json ?? false,
@@ -2,7 +2,10 @@
2
2
  // 진단(probe)과 에이전트 루프가 같은 함수를 쓴다.
3
3
  import { mkdirSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
- import { req, headersFor, serverMessage, Aborted, 잠잠기본, 초로 } from './http.js';
5
+ import {
6
+ req, headersFor, serverMessage, Aborted, 잠잠기본, 초로,
7
+ 무소식기본, 무소식알림기본, 소식없음오류,
8
+ } from './http.js';
6
9
  import { 할당량기억, 미리기다릴까, 마지막할당량, 할당량자리 } from './quota.js';
7
10
  import { 열쇠 as 열쇠받아오기, 쓸수있나 } from '../safety/authcmd.js';
8
11
  import { 말 } from '../i18n/index.js';
@@ -1223,6 +1226,32 @@ export async function* chatStream(conn, opts) {
1223
1226
  const dec = new TextDecoder();
1224
1227
  let buf = '';
1225
1228
 
1229
+ /*
1230
+ * ── 바이트 말고 **소식**을 잰다 (backend/http.js 의 무소식기본) ─────────
1231
+ *
1232
+ * 잠잠 시계는 조각이 오면 되감긴다. 그런데 keep-alive(`: ping`)와 빈 delta
1233
+ * 는 조각이면서 내용이 없다 — 되감기기만 하고 화면에는 아무것도 안 남는다.
1234
+ * 그 둘만 오는 동안은 천장이 사라진다.
1235
+ *
1236
+ * 여기서 세는 것은 **acc 가 자랐나** 다. 이벤트 수가 아니다 — 도구 인자는
1237
+ * 글자로 쪼개져 와서 acc._raw 에만 쌓이고 이벤트를 안 낸다(mergeDeltaCalls).
1238
+ * 이벤트만 세면 도구 호출을 길게 쓰는 중인 정상 흐름을 우리가 끊는다.
1239
+ */
1240
+ const 자란만큼 = () =>
1241
+ acc.content.length
1242
+ + acc.thinking.length
1243
+ + (acc.거절글?.length ?? 0)
1244
+ + (acc._raw?.reduce(
1245
+ (n, c) => n + 1 + (c?.name?.length ?? 0) + (c?.args?.length ?? 0), 0) ?? 0)
1246
+ + (acc.usage?.잰것 ? 1 : 0)
1247
+ + (acc.stopped ? 1 : 0);
1248
+
1249
+ const 무소식 = conn.무소식 ?? 무소식기본;
1250
+ const 무소식알림 = Math.min(conn.무소식알림 ?? 무소식알림기본, 무소식 > 0 ? 무소식 / 2 : Infinity);
1251
+ let 마지막소식 = Date.now();
1252
+ let 본만큼 = 자란만큼();
1253
+ let 알렸나 = false;
1254
+
1226
1255
  /*
1227
1256
  * 한 줄을 읽는 규칙. 루프 안에서도, **끝난 뒤에도** 같은 것을 쓴다.
1228
1257
  *
@@ -1292,6 +1321,51 @@ export async function* chatStream(conn, opts) {
1292
1321
  yield* 한줄(꼬리);
1293
1322
  break;
1294
1323
  }
1324
+
1325
+ /*
1326
+ * 이번 조각으로 acc 가 자랐나. 자랐으면 시계를 되감고, 안 자랐으면 얼마나
1327
+ * 오래 그랬는지 본다.
1328
+ *
1329
+ * 알림이 먼저고 끊기는 나중이다. 순서가 중요하다 — 끊기부터 만들면
1330
+ * 「원래 이런 게이트웨이」 를 우리가 죽이면서 그 사실을 아무도 못 본다.
1331
+ */
1332
+ const 이번만큼 = 자란만큼();
1333
+ if (이번만큼 !== 본만큼) {
1334
+ 본만큼 = 이번만큼;
1335
+ 마지막소식 = Date.now();
1336
+ 알렸나 = false;
1337
+ } else {
1338
+ const 흐른 = Date.now() - 마지막소식;
1339
+ if (무소식 > 0 && 흐른 >= 무소식) {
1340
+ /*
1341
+ * 끊을 때는 **소켓도 같이 닫는다.**
1342
+ *
1343
+ * 안 닫으면 게이트웨이는 우리가 간 줄도 모르고 핑을 계속 보낸다. 그
1344
+ * 연결이 프로그램이 끝날 때까지 남아서 겪을 때마다 하나씩 쌓인다.
1345
+ * 잠잠 시계는 제 손으로 소켓을 끊고 있다(http.js 의 끊기) — 여기만
1346
+ * 안 하면 같은 일을 반쪽만 하는 것이다. 검사에서 프로세스가 안 끝나서
1347
+ * 잡혔다.
1348
+ */
1349
+ try { await reader.cancel(); } catch { /* 이미 닫혔으면 그만 */ }
1350
+ /*
1351
+ * 여기까지 받은 글은 **버리지 않는다.** 세 문단을 받아 놓고 그 뒤로
1352
+ * keep-alive 만 오는 자리가 있다 — 통째로 던지면 사람은 아무것도 못
1353
+ * 보고 같은 것을 다시 시킨다. 잠잠 시계가 하는 것과 같은 규칙이다.
1354
+ *
1355
+ * 한 글자도 못 받았으면 그건 그냥 실패다. 빈 답을 답이라고 부르지 않는다.
1356
+ */
1357
+ if (acc.content || acc.thinking || acc.toolCalls.length) {
1358
+ acc.stopped = 흐름멎음;
1359
+ acc.멎은초 = 초로(흐른);
1360
+ break;
1361
+ }
1362
+ throw 소식없음오류(무소식);
1363
+ }
1364
+ if (!알렸나 && 흐른 >= 무소식알림) {
1365
+ 알렸나 = true;
1366
+ yield { type: '소식없음', 초: 초로(흐른), 상한초: 무소식 > 0 ? 초로(무소식) : null };
1367
+ }
1368
+ }
1295
1369
  }
1296
1370
  /*
1297
1371
  * ── 끝을 안 알려 주고 끊긴 것은 '끝난 것' 이 아니다 ──────────────────────
@@ -232,6 +232,57 @@ export function 멎음오류(잠잠) {
232
232
  );
233
233
  }
234
234
 
235
+ /*
236
+ * ── 위 시계가 못 잡는 자리 ──────────────────────────────────────────────
237
+ *
238
+ * 잠잠 시계는 **바이트**를 잰다. 조각이 하나 오면 되감긴다(위 잠잠감시).
239
+ * 그런데 SSE 에는 내용이 없는 조각이 있다 —
240
+ *
241
+ * `: ping` 규격이 정한 주석 줄. keep-alive.
242
+ * `data: {"choices":[{"delta":{}}]}` 빈 delta. 살아 있다는 말만 한다.
243
+ *
244
+ * 둘 다 **바이트**다. 그래서 잠잠 시계를 되감는다. 그리고 둘 다 화면에
245
+ * 아무것도 안 남긴다 — 앞엣것은 JSON 이 아니라 파싱에서 버려지고, 뒤엣것은
246
+ * absorb 가 낼 이벤트가 없다.
247
+ *
248
+ * 머리말까지 재던 시계(timeout)는 머리말이 오는 순간 **꺼진다**(위 노드로·
249
+ * 직접흘려의 시계끄기). 그러면 남는 천장이 하나도 없다.
250
+ *
251
+ * 실제로 그렇게 됐다. 게이트웨이 뒤에서 15분 넘게, 오류도 없이, 스피너만
252
+ * 돌았다. 이벤트 루프는 멀쩡했다 — 기다릴 이유가 없어지는 순간이 영영 안
253
+ * 왔을 뿐이다.
254
+ *
255
+ * 그래서 시계를 하나 더 둔다. 바이트가 아니라 **소식**을 잰다. 흔히
256
+ * Logical Idle Timeout 이라 부르고, 그 위에 전체 상한(Total Timeout)을
257
+ * 같이 씌우는 것이 보통이다. 여기서는 둘을 한 쌍으로 둔다.
258
+ */
259
+
260
+ /**
261
+ * 소식 없이 얼마나 두고 볼까 — **알리는** 자리.
262
+ *
263
+ * 끊지 않는다. 한 줄 적을 뿐이다. 추론을 감추는 게이트웨이는 원래 이 모양으로
264
+ * 정상 동작한다 — 상위 모델이 생각하는 동안 keep-alive 만 흘린다. 그걸
265
+ * 끊어 버리면 지금 잘 도는 연결을 우리가 죽이는 것이다.
266
+ *
267
+ * 90초로 잡는다. 사람이 「멈춘 건가」 를 의심하기 시작하는 자리다.
268
+ */
269
+ export const 무소식알림기본 = 90000;
270
+
271
+ /**
272
+ * 그래도 이건 넘기지 않는다 — **전체 상한**.
273
+ *
274
+ * 무한은 어떤 경우에도 맞는 답이 아니다. 10분은 어떤 모델의 첫 글자보다도
275
+ * 한참 뒤다. 0 으로 두면 이 상한을 안 쓴다(프로필의 `무소식`).
276
+ */
277
+ export const 무소식기본 = 600000;
278
+
279
+ export function 소식없음오류(무소식) {
280
+ return Object.assign(
281
+ new Error(`${초로(무소식)}초 동안 살아 있다는 신호만 오고 내용이 안 왔습니다`),
282
+ { name: 'NoNewsError', code: 'NONEWS', 무소식 },
283
+ );
284
+ }
285
+
235
286
  /**
236
287
  * 밀리초를 사람에게 보여 줄 초로.
237
288
  *
package/src/commands.js CHANGED
@@ -30,7 +30,7 @@ import { 말, 말 as 옮긴말, 언어, 언어들, 언어정하기, 언어고르
30
30
  import { 프로필찾기, 쓸수있나, 연결만들기, 알릴말, 목록보기 } from './agent/models.js';
31
31
  import { allowTemporarily } from './safety/network.js';
32
32
  import { chat, 규격이름, 더할머리 } from './backend/adapter.js';
33
- import { 잠잠기본 } from './backend/http.js';
33
+ import { 잠잠기본, 무소식기본 } from './backend/http.js';
34
34
  import { 인증서설정, 인증서말 } from './backend/clientcert.js';
35
35
  import { 알림채움 } from './backend/retry.js';
36
36
  import { 프록시고르기, 프록시설정 } from './backend/proxy.js';
@@ -2944,6 +2944,8 @@ function 연결적용(session, p, ctx = null) {
2944
2944
  streaming: p.streaming, tools: p.tools, json: p.json, think: p.think,
2945
2945
  // 프로필마다 다를 수 있다 — 사내 게이트웨이는 잠잠 상한이 크고 로컬은 작다.
2946
2946
  잠잠: p.잠잠 ?? p.streamIdleMs ?? 잠잠기본,
2947
+ // 바이트는 오는데 내용이 안 올 때의 전체 상한 (backend/http.js 의 무소식기본).
2948
+ 무소식: p.무소식 ?? p.streamNoNewsMs ?? 무소식기본,
2947
2949
  // 프로필마다 인증서가 다르다. 안 갈아 끼우면 옛 프로필의 신원으로 붙는다.
2948
2950
  인증서: 인증서설정(p),
2949
2951
  /*
package/src/i18n/en.js CHANGED
@@ -478,6 +478,8 @@ export const en = {
478
478
  'ev.phaseWhyEdit': 'Started changing files',
479
479
  'ev.phaseWhyTodo': 'The plan is written down',
480
480
  'ev.phaseTools': 'More tools from here ({도구}) — the prefix gets written once more',
481
+ 'ev.noNews': 'Only keep-alive for {초}s, no content yet — the gateway may still be waiting on the upstream model',
482
+ 'ev.noNewsCap': 'Giving up at {상한초}s. If this is genuinely slow, raise streamNoNewsMs in the profile',
481
483
  'ev.cutoff': 'The server stopped the answer without saying it had finished — it may have been cut mid-way',
482
484
  'ev.stalled': 'Nothing arrived for {n}s, so the connection was dropped — this is what came through',
483
485
  'ev.nudgeMissed': 'It was about to finish leaving {n} of the things you asked for undone — pushed back once',
package/src/i18n/ja.js CHANGED
@@ -436,6 +436,8 @@ export const ja = {
436
436
  'ev.phaseWhyEdit': 'ファイルを変更し始めました',
437
437
  'ev.phaseWhyTodo': 'やることを書き終えました',
438
438
  'ev.phaseTools': 'ツールが増えました ({도구}) — 先頭を一度書き直します',
439
+ 'ev.noNews': '{초}秒間生存信号だけで内容が来ていません — ゲートウェイが上位モデルを待っている可能性があります',
440
+ 'ev.noNewsCap': '{상한초}秒で打ち切ります。本当に遅い場合は設定の streamNoNewsMs を上げてください',
439
441
  'ev.cutoff': 'サーバーが終わったとも言わずに答えを止めました — 途中で切れた可能性があります',
440
442
  'ev.stalled': '{n}秒間なにも届かなかったので切りました — 届いたぶんだけ表示します',
441
443
  'ev.nudgeMissed': '頼んだこと {n} 件を残したまま終えようとしたので、一度押し戻しました',
package/src/i18n/ko.js CHANGED
@@ -509,6 +509,8 @@ export const ko = {
509
509
  'ev.phaseWhyEdit': '파일을 바꾸기 시작했습니다',
510
510
  'ev.phaseWhyTodo': '할 일을 다 적었습니다',
511
511
  'ev.phaseTools': '도구가 늘었습니다 ({도구}) — 앞머리를 한 번 다시 씁니다',
512
+ 'ev.noNews': '{초}초간 살아 있다는 신호만 오고 내용은 안 왔습니다 — 게이트웨이가 윗모델을 기다리는 중일 수 있습니다',
513
+ 'ev.noNewsCap': '{상한초}초까지 기다리고 끊습니다. 오래 걸리는 게 맞으면 설정의 무소식 을 올리세요',
512
514
  'ev.cutoff': '서버가 끝났다는 말 없이 답을 멈췄습니다 — 중간에서 끊겼을 수 있습니다',
513
515
  'ev.stalled': '{n}초 동안 아무것도 안 와서 끊었습니다 — 받은 데까지만 보여 드립니다',
514
516
  'ev.nudgeMissed': '시킨 것 {n}가지를 빼놓고 끝내려고 해서 한 번 되밀었습니다',
package/src/i18n/zh.js CHANGED
@@ -436,6 +436,8 @@ export const zh = {
436
436
  'ev.phaseWhyEdit': '开始修改文件了',
437
437
  'ev.phaseWhyTodo': '要做的事已经列完了',
438
438
  'ev.phaseTools': '工具变多了 ({도구}) — 前缀要重写一次',
439
+ 'ev.noNews': '{초} 秒内只有保活信号,没有内容 — 网关可能还在等上游模型',
440
+ 'ev.noNewsCap': '到 {상한초} 秒就断开。如果确实慢,请调高配置中的 streamNoNewsMs',
439
441
  'ev.cutoff': '服务端没说结束就停下了 — 回答可能是中途断的',
440
442
  'ev.stalled': '{n} 秒没有任何数据,已断开 — 只显示已收到的部分',
441
443
  'ev.nudgeMissed': '还剩你交代的 {n} 件事没做就要收尾,已经推回去一次',
package/src/oneshot.js CHANGED
@@ -35,7 +35,7 @@ import { allowEndpoint, setOffline } from './safety/network.js';
35
35
  import { 지금모드, 바깥인가, 나갈수있나 } from './safety/runmode.js';
36
36
  import { 주소가리기 } from './safety/secrets.js';
37
37
  import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
38
- import { 잠잠기본 } from './backend/http.js';
38
+ import { 잠잠기본, 무소식기본 } from './backend/http.js';
39
39
  import { 인증서설정 } from './backend/clientcert.js';
40
40
  import { route } from './agent/route.js';
41
41
  import { get as getWork, 보일이름 } from './agent/modes.js';
@@ -274,6 +274,8 @@ export async function runOnce(opts = {}) {
274
274
  * 모았다가 한 번에 주는 사내 게이트웨이면 이 값을 올린다.
275
275
  */
276
276
  잠잠: prof.잠잠 ?? prof.streamIdleMs ?? 잠잠기본,
277
+ // 바이트는 오는데 내용이 안 올 때의 전체 상한 (backend/http.js 의 무소식기본).
278
+ 무소식: prof.무소식 ?? prof.streamNoNewsMs ?? 무소식기본,
277
279
  // 게이트웨이가 우리 인증서를 요구하면 (mTLS, backend/clientcert.js).
278
280
  // 파일 경로만 싣는다 — 알맹이는 요청 직전에 읽는다.
279
281
  인증서: 인증서설정(prof),
@@ -627,6 +629,12 @@ export async function runOnce(opts = {}) {
627
629
  }))}`);
628
630
  break;
629
631
 
632
+ // 바이트만 오고 내용이 안 오는 자리. 배치 기록에 남아야 「그날 밤 왜
633
+ // 12분이 걸렸나」 를 나중에 읽을 수 있다.
634
+ case '소식없음':
635
+ 곁(` ${c.yellow('⧗')} ${c.gray(옮긴말('ev.noNews', { 초: ev.초 }))}`);
636
+ break;
637
+
630
638
  case 'folded':
631
639
  곁(` ${c.cyan('◲')} ${c.gray(`오래된 도구 결과 ${ev.접은것}개를 접었습니다 (${ev.아낀토큰.toLocaleString()} 토큰을 비움)`)}`);
632
640
  break;
package/src/repl.js CHANGED
@@ -48,7 +48,7 @@ import { 접을까 as 붙임접을까, 표만들기 as 붙임표, 펼치기 as
48
48
  import { 고른것풀기, 계획답풀기 } from './ui/pick.js';
49
49
  import { 이력지킴이 } from './ui/histline.js';
50
50
  import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
51
- import { 잠잠기본 } from './backend/http.js';
51
+ import { 잠잠기본, 무소식기본 } from './backend/http.js';
52
52
  import { 인증서설정 } from './backend/clientcert.js';
53
53
  import { renderDiff, shortStat } from './ui/diff.js';
54
54
  import { expand as expandMentions } from './agent/mention.js';
@@ -249,6 +249,8 @@ export async function chatLoop(opts = {}) {
249
249
  * 모았다가 한 번에 주는 사내 게이트웨이면 이 값을 올린다.
250
250
  */
251
251
  잠잠: prof.잠잠 ?? prof.streamIdleMs ?? 잠잠기본,
252
+ // 바이트는 오는데 내용이 안 올 때의 전체 상한 (backend/http.js 의 무소식기본).
253
+ 무소식: prof.무소식 ?? prof.streamNoNewsMs ?? 무소식기본,
252
254
  // 게이트웨이가 우리 인증서를 요구하면 (mTLS, backend/clientcert.js).
253
255
  // 파일 경로만 싣는다 — 알맹이는 요청 직전에 읽는다.
254
256
  인증서: 인증서설정(prof),
@@ -2162,6 +2164,23 @@ export async function chatLoop(opts = {}) {
2162
2164
  break;
2163
2165
  }
2164
2166
 
2167
+ /*
2168
+ * 바이트는 오는데 **내용이 안 온다** (backend/adapter.js).
2169
+ *
2170
+ * 여기가 없으면 화면에는 스피너만 돈다. 실제로 15분 넘게 그랬고,
2171
+ * 사람은 프로그램이 멎은 줄 알았다. 멎은 게 아니라 기다릴 이유가
2172
+ * 없어지는 순간이 안 온 것이라, 그걸 그대로 적어 준다.
2173
+ *
2174
+ * 끊지 않는다는 것도 같이 말한다 — 추론을 감추는 게이트웨이는 원래
2175
+ * 이 모양으로 정상 동작하므로, 사람이 기다릴지 ESC 할지 고르게 한다.
2176
+ */
2177
+ case '소식없음':
2178
+ say(` ${c.yellow('⧗')} ${c.gray(옮긴말('ev.noNews', { 초: ev.초 }))}`);
2179
+ if (ev.상한초) {
2180
+ say(` ${c.gray(옮긴말('ev.noNewsCap', { 상한초: ev.상한초 }))}`);
2181
+ }
2182
+ break;
2183
+
2165
2184
  case 'trimmed':
2166
2185
  say(` ${c.gray(`(컨텍스트가 차서 오래된 대화 ${ev.dropped}개를 줄였습니다)`)}`);
2167
2186
  break;
@@ -30,9 +30,10 @@
30
30
  */
31
31
  import { existsSync, statSync, readFileSync } from 'node:fs';
32
32
  import { execFile } from 'node:child_process';
33
- import { extname, dirname, join, resolve } from 'node:path';
33
+ import { extname, dirname, resolve } from 'node:path';
34
34
  import { walk, SKIP_DIRS, 내부살림 } from './fsutil.js';
35
35
  import { 건너뜀말 } from './ignore.js';
36
+ import { 확인법들 } from './확인법.js';
36
37
  import { decode, looksBinary } from './encoding.js';
37
38
  import { checkCommand } from '../safety/guard.js';
38
39
  import { 말 } from '../i18n/index.js';
@@ -315,10 +316,24 @@ export const VERIFY_TOOL = {
315
316
  * 검사가 걸려 있기 때문이다. 이 도구가 몰래 돌리면 strict 모드에서
316
317
  * "물어보고 실행한다" 는 약속이 이 자리에서만 깨진다.
317
318
  */
318
- const 있는명령 = 프로젝트확인법(뿌리);
319
- if (있는명령) {
319
+ const 확인들 = 확인법들(뿌리);
320
+ if (확인들.length) {
320
321
  줄.push('');
321
- 줄.push(`이 프로젝트에는 ${있는명령} 가 있습니다. 진짜로 도는지 보려면 Bash 로 돌려라.`);
322
+ if (확인들.length === 1) {
323
+ 줄.push(`이 프로젝트에는 ${확인들[0].명령} 가 있습니다. 진짜로 도는지 보려면 Bash 로 돌려라.`);
324
+ } else {
325
+ /*
326
+ * **하나만 알려 주면 하나만 돈다.**
327
+ *
328
+ * 벤치마크에서 진 자리가 여기다 — 검사가 셋인 프로젝트에서 우리는
329
+ * 하나만 알려 줬고, 그래서 하나만 돌았고, 회귀 범위가 좁았다.
330
+ * 고른 하나가 아니라 **찾은 전부**를 준다. 무엇을 돌릴지는 모델이
331
+ * 정하되, 있는 줄도 몰라서 못 도는 일은 없어야 한다.
332
+ */
333
+ 줄.push(`이 프로젝트의 확인 방법은 ${확인들.length}가지입니다. 고친 곳과 닿는 것은 **전부** Bash 로 돌려라 —`);
334
+ for (const { 명령, 어디서 } of 확인들) 줄.push(` ${명령} (${어디서})`);
335
+ 줄.push('하나만 돌리고 "회귀 검증했다" 고 말하지 마라. 안 돌린 것은 안 돌렸다고 적어라.');
336
+ }
322
337
  }
323
338
 
324
339
  const 다됐나 = !탈난것.length;
@@ -339,20 +354,4 @@ export const VERIFY_TOOL = {
339
354
  },
340
355
  };
341
356
 
342
- /**
343
- * 이 프로젝트가 스스로 정해 둔 확인 방법.
344
- *
345
- * package.json 에 test 가 있으면 그것이 답이다. 없는 것을 지어내지는 않는다 —
346
- * 없으면 null 이고, 그러면 파일별 확인만 한 것이 전부다.
347
- * 돌리지는 않는다. 알려 주기만 한다 (위 머리말 참고).
348
- */
349
- function 프로젝트확인법(뿌리) {
350
- const pkg = join(뿌리, 'package.json');
351
- if (!existsSync(pkg)) return null;
352
- try {
353
- const j = JSON.parse(readFileSync(pkg, 'utf8'));
354
- if (j.scripts?.test) return 'npm test';
355
- if (j.scripts?.build) return 'npm run build';
356
- } catch { /* 망가진 package.json 은 아래 파일 확인에서 잡힌다 */ }
357
- return null;
358
- }
357
+ // 확인 방법 찾기는 tools/확인법.js 로 옮겼다 — 찾을 자리가 늘어 파일이 무거워졌다.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * 이 프로젝트가 스스로 정해 둔 **확인 방법들**을 찾는다.
3
+ *
4
+ * ── 왜 따로 나왔나 ─────────────────────────────────────────────────────
5
+ *
6
+ * 벤치마크에서 진 자리다(`docs/ko/benchmark.md`).
7
+ *
8
+ * 같은 프로젝트에 같은 일을 시킨 비교에서, 품질은 거의 동급이었는데 **전체
9
+ * 회귀 범위**만 우리가 낮았다(85% 대 100%). 까닭은 단순했다 — 그 프로젝트의
10
+ * 검사는 `scripts/` 밑의 독립 실행 파일 셋이었는데, 우리는 `package.json` 의
11
+ * `test` 하나만 보고 있었다. 못 본 검사는 안 돌고, 안 돈 검사는 회귀를 못 잡는다.
12
+ *
13
+ * 고칠 자리가 「더 열심히 하라」 가 아니라 **「무엇이 있는지 알려 주기」** 였다.
14
+ * 시킴말에 "확인해라" 는 이미 있었다. 확인할 **길**의 목록이 없었을 뿐이다.
15
+ *
16
+ * ── 여기서도 안 돌린다 ──────────────────────────────────────────────────
17
+ *
18
+ * 찾기만 하고 **실행은 안 한다.** 검사 스크립트는 무슨 짓이든 할 수 있어서,
19
+ * 그걸 돌리는 길은 Bash 하나여야 한다 — 승인 관문과 안전 검사가 거기에만
20
+ * 걸려 있다. 이 파일이 몰래 돌리면 strict 모드의 약속이 여기서만 깨진다.
21
+ *
22
+ * ── 없는 것은 지어내지 않는다 ───────────────────────────────────────────
23
+ *
24
+ * 있을 법한 이름을 추측해서 채우지 않는다. **파일이나 스크립트로 실재하는
25
+ * 것만** 담는다. 없는 명령을 알려 주면 모델이 그걸 부르고, 실패를 받고, 또
26
+ * 부른다 — 걸음만 태우고 사람은 왜 헤매는지 모른다.
27
+ */
28
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
29
+ import { join } from 'node:path';
30
+
31
+ /** 한 판에 몇 개까지 알려 줄까. 스무 개를 늘어놓으면 그건 목록이 아니라 소음이다. */
32
+ export const 최대 = 8;
33
+
34
+ /*
35
+ * package.json 의 스크립트 가운데 **확인에 해당하는 것**.
36
+ *
37
+ * `test` 만 보던 것을 넓힌다. 요즘 저장소는 확인을 여러 칸에 나눠 둔다 —
38
+ * 형 검사와 린트와 e2e 가 따로 있고, 그 중 하나만 돌리면 나머지는 안 돈다.
39
+ *
40
+ * `build` 는 확인이 아니지만 **깨지면 아무것도 안 되는 것**이라 맨 뒤에 붙인다.
41
+ */
42
+ const 확인스크립트 = /^(test|tests|check|checks|lint|typecheck|types?|verify|qa|e2e|spec|coverage)(:|$)/i;
43
+ const 뒤에붙일것 = /^build(:|$)/i;
44
+
45
+ /*
46
+ * 독립 실행 검사 파일이 사는 자리와 이름.
47
+ *
48
+ * `package.json` 에 안 걸리는 검사가 여기 산다. 벤치마크에서 놓친 것이 정확히
49
+ * 이 모양이었다 — `scripts/qa-collaboration.js` 처럼 npm 스크립트로 등록되지
50
+ * 않은 채 `node` 로 직접 도는 파일들.
51
+ */
52
+ const 볼폴더 = ['scripts', 'script', 'test', 'tests', 'qa', 'tools', 'bin'];
53
+ const 검사파일 = /^(qa|test|tests|check|verify|e2e|spec|bench)[-_.].+\.(m?js|cjs|ts|py|sh)$/i;
54
+ const 부르는법 = { '.js': 'node', '.mjs': 'node', '.cjs': 'node', '.ts': 'node', '.py': 'python', '.sh': 'sh' };
55
+
56
+ /*
57
+ * 다른 생태계의 관례.
58
+ *
59
+ * 자리(표식 파일)가 실재할 때만 담는다. Makefile 은 `test:` 칸이 진짜로
60
+ * 있을 때만 — 없는데 `make test` 를 알려 주면 그건 지어낸 것이다.
61
+ */
62
+ function 생태계(뿌리, 담기) {
63
+ const 있나 = (f) => existsSync(join(뿌리, f));
64
+ if (있나('Cargo.toml')) 담기('cargo test', 'Cargo.toml');
65
+ if (있나('go.mod')) 담기('go test ./...', 'go.mod');
66
+ if (있나('pytest.ini') || 있나('tox.ini')) 담기('pytest', 'pytest 설정');
67
+ else if ((있나('pyproject.toml') || 있나('setup.cfg')) && (있나('tests') || 있나('test'))) 담기('pytest', 'pyproject + tests/');
68
+ if (있나('go.work')) 담기('go test ./...', 'go.work');
69
+ for (const 이름 of ['Makefile', 'makefile']) {
70
+ if (!있나(이름)) continue;
71
+ try {
72
+ // 진짜로 `test:` 칸이 있을 때만. 있을 법하다고 넣지 않는다.
73
+ if (/^\.?test\s*:/m.test(readFileSync(join(뿌리, 이름), 'utf8'))) 담기('make test', 이름);
74
+ } catch { /* 못 읽으면 없는 것으로 둔다 */ }
75
+ break;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * 확인 방법들을 찾아 돌려준다.
81
+ *
82
+ * @param {string} 뿌리 프로젝트 뿌리 경로
83
+ * @returns {{명령: string, 어디서: string}[]} 실재하는 것만. 없으면 빈 배열.
84
+ */
85
+ export function 확인법들(뿌리) {
86
+ const 나온것 = [];
87
+ const 본명령 = new Set();
88
+ const 담기 = (명령, 어디서) => {
89
+ if (본명령.has(명령) || 나온것.length >= 최대) return;
90
+ 본명령.add(명령);
91
+ 나온것.push({ 명령, 어디서 });
92
+ };
93
+ const 나중에 = [];
94
+
95
+ const pkg = join(뿌리, 'package.json');
96
+ if (existsSync(pkg)) {
97
+ try {
98
+ const j = JSON.parse(readFileSync(pkg, 'utf8'));
99
+ for (const 이름 of Object.keys(j.scripts ?? {})) {
100
+ // `npm test` 는 관례가 있고 나머지는 `npm run` 을 붙여야 돈다.
101
+ const 명령 = 이름 === 'test' ? 'npm test' : `npm run ${이름}`;
102
+ if (확인스크립트.test(이름)) 담기(명령, 'package.json');
103
+ else if (뒤에붙일것.test(이름)) 나중에.push([명령, 'package.json']);
104
+ }
105
+ } catch { /* 망가진 package.json 은 파일별 확인에서 잡힌다 */ }
106
+ }
107
+
108
+ for (const 폴더 of 볼폴더) {
109
+ const 곳 = join(뿌리, 폴더);
110
+ let 목록;
111
+ try {
112
+ if (!statSync(곳).isDirectory()) continue;
113
+ 목록 = readdirSync(곳);
114
+ } catch { continue; }
115
+ for (const 이름 of 목록.sort()) {
116
+ if (!검사파일.test(이름)) continue;
117
+ const 끝 = 이름.slice(이름.lastIndexOf('.')).toLowerCase();
118
+ const 앞 = 부르는법[끝];
119
+ if (!앞) continue;
120
+ 담기(`${앞} ${폴더}/${이름}`, `${폴더}/`);
121
+ }
122
+ }
123
+
124
+ 생태계(뿌리, 담기);
125
+ for (const [명령, 어디서] of 나중에) 담기(명령, 어디서);
126
+ return 나온것;
127
+ }