deel-local-cli 1.20.10 → 1.20.12

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/src/config.js CHANGED
@@ -126,12 +126,25 @@ export function load() {
126
126
  * 없고, 금지를 더할 수는 있어도 사용자가 적어 둔 금지를 지우지는 못한다.
127
127
  * 정책 파일 한 줄로 안전장치가 헐거워지는 길을 안 낸다.
128
128
  */
129
+ /*
130
+ * 정책이 덮기 **전**의 값. 저장할 때 되돌려 놓으려고 들고 있는다.
131
+ *
132
+ * Symbol 로 단 까닭: JSON.stringify 가 Symbol 칸을 안 적는다. 보통 칸으로
133
+ * 두면 이 흔적 자체가 설정 파일에 적혀 나간다.
134
+ */
135
+ const 정책흔적 = Symbol('정책이덮은것');
136
+
129
137
  function 정책덮기(cfg) {
130
138
  const 정책 = 정책읽기().값;
131
139
  if (!정책 || typeof 정책 !== 'object') return cfg;
132
140
 
141
+ const 덮기전 = { 주소: new Map(), offline: cfg.offline, deny: cfg.permissions?.deny };
142
+
133
143
  if (typeof 정책.baseUrl === 'string' && 정책.baseUrl.trim()) {
134
- for (const 프로필 of cfg.profiles ?? []) 프로필.baseUrl = 정책.baseUrl.trim();
144
+ for (const 프로필 of cfg.profiles ?? []) {
145
+ 덮기전.주소.set(프로필, 프로필.baseUrl);
146
+ 프로필.baseUrl = 정책.baseUrl.trim();
147
+ }
135
148
  cfg.정책주소 = 정책.baseUrl.trim();
136
149
  }
137
150
  if (정책.offline === true) cfg.offline = true; // 끄지는 못한다 — 켜기만
@@ -140,9 +153,41 @@ function 정책덮기(cfg) {
140
153
  const 있던것 = Array.isArray(cfg.permissions.deny) ? cfg.permissions.deny : [];
141
154
  cfg.permissions.deny = [...new Set([...있던것, ...정책.permissions.deny])];
142
155
  }
156
+ cfg[정책흔적] = 덮기전;
143
157
  return cfg;
144
158
  }
145
159
 
160
+ /*
161
+ * 저장하기 전에 **정책이 얹은 것을 도로 벗긴다.**
162
+ *
163
+ * 정책은 덮는 것이지 사람 설정을 고쳐 쓰는 것이 아니다. 그런데 load() 가
164
+ * 얹은 값을 그대로 저장하는 자리가 넷이었다(setup · /model · scan · 설정남기기).
165
+ * 그러면 사람이 적어 둔 주소는 사라지고 정책 주소가 제 파일에 박힌다.
166
+ * 관리자가 나중에 정책을 걷어도 그 값은 남고, 그때 `deel config explain` 은
167
+ * 「이 PC 설정 = …」 이라며 **사람 본인을 범인으로 가리킨다.**
168
+ */
169
+ function 정책벗기기(cfg) {
170
+ const 덮기전 = cfg?.[정책흔적];
171
+ if (!덮기전) return cfg;
172
+ const 사본 = { ...cfg };
173
+ delete 사본.정책주소;
174
+ 사본.profiles = (cfg.profiles ?? []).map((p) => {
175
+ if (!덮기전.주소.has(p)) return p;
176
+ const q = { ...p };
177
+ const 원래 = 덮기전.주소.get(p);
178
+ if (원래 === undefined) delete q.baseUrl; else q.baseUrl = 원래;
179
+ return q;
180
+ });
181
+ if (덮기전.offline === undefined) delete 사본.offline; else 사본.offline = 덮기전.offline;
182
+ if (cfg.permissions) {
183
+ 사본.permissions = { ...cfg.permissions };
184
+ if (덮기전.deny === undefined) delete 사본.permissions.deny;
185
+ else 사본.permissions.deny = 덮기전.deny;
186
+ if (!Object.keys(사본.permissions).length) delete 사본.permissions;
187
+ }
188
+ return 사본;
189
+ }
190
+
146
191
  function 한장읽기(p) {
147
192
  try { return JSON.parse(readFileSync(p, 'utf8')); }
148
193
  catch (err) { throw new Error(`설정 파일을 읽지 못했습니다: ${p}\n ${err.message}`); }
@@ -162,13 +207,36 @@ function 한장읽기(p) {
162
207
  function 겹치기(집, 방) {
163
208
  const cfg = { ...집, ...방 };
164
209
 
165
- // 프로필은 이름으로 맞춘다. 통째로 갈아치우면 이 PC 프로필이 사라진다.
166
- const 이름표 = new Map((집.profiles ?? []).map((x) => [x?.name, x]));
210
+ /*
211
+ * 프로필은 **id 로** 맞춘다. 통째로 갈아치우면 이 PC 프로필이 사라진다.
212
+ *
213
+ * 여기만 `name` 으로 맞췄다. 나머지는 전부 id 로 돈다 — activeProfile 도,
214
+ * upsert 도, 열쇠 푸는 자리도. 그래서 세 가지가 조용히 어긋났다:
215
+ *
216
+ * · id 만 적은 저장소 프로필은 `continue` 로 **통째로 버려졌다.**
217
+ * · 이름을 새로 붙이면 id 가 같은 프로필이 **두 개**가 되고 집 것이 이겼다.
218
+ * · id 없이 이름만 적으면(문서의 예시 모양이 그렇다) 그 프로필은
219
+ * 영영 active 가 못 된다.
220
+ *
221
+ * 셋 다 화면에 한마디도 안 나왔다. 그리고 `deel config explain` 은 저장소
222
+ * 값이 이겼다고 그려 줬다 — 실제로 모델에 붙는 값은 집 것인데.
223
+ *
224
+ * 옛 저장소 설정이 이름으로만 적어 둔 경우가 있어 이름도 받아 준다.
225
+ */
226
+ const 집것 = 집.profiles ?? [];
227
+ const 자리표 = new Map(집것.map((x, i) => [x?.id ?? `#${i}`, i]));
228
+ const 이름자리 = new Map(집것.map((x, i) => [x?.name, i]).filter(([k]) => k));
229
+ const 모음 = [...집것];
230
+ const 이름없는것 = [];
167
231
  for (const p of 방.profiles ?? []) {
168
- if (!p?.name) continue;
169
- 이름표.set(p.name, { ...(이름표.get(p.name) ?? {}), ...p });
232
+ const 자리 = p?.id != null && 자리표.has(p.id) ? 자리표.get(p.id)
233
+ : (p?.name != null && 이름자리.has(p.name) ? 이름자리.get(p.name) : null);
234
+ if (자리 != null) { 모음[자리] = { ...모음[자리], ...p }; continue; }
235
+ if (p?.id == null && p?.name == null) { 이름없는것.push(p); continue; }
236
+ 모음.push({ ...p });
170
237
  }
171
- cfg.profiles = [...이름표.values()];
238
+ cfg.profiles = 모음;
239
+ if (이름없는것.length) 버린프로필 = 이름없는것.length;
172
240
 
173
241
  // 금지는 합친다. 어느 쪽이 적었든 금지는 금지다.
174
242
  const 금지 = [...(집.permissions?.deny ?? []), ...(방.permissions?.deny ?? [])];
@@ -183,6 +251,8 @@ function 겹치기(집, 방) {
183
251
  }
184
252
 
185
253
  let 신뢰소식 = null; // 프로젝트 설정에 대해 할 말. 한 번 읽으면 지워진다.
254
+ // id 도 name 도 없어서 못 붙인 저장소 프로필 수. 조용히 버리면 안 된다.
255
+ let 버린프로필 = 0;
186
256
 
187
257
  /**
188
258
  * 프로젝트 설정을 안 읽었거나 일부를 걷어냈으면 그것을 알려 준다.
@@ -217,8 +287,15 @@ function 읽기() {
217
287
  }
218
288
 
219
289
  const { 값: 방, 걸러낸것 } = 프로젝트거르기(한장읽기(방파일));
220
- if (걸러낸것.length) 신뢰소식 = { 갈래: '걸러냄', 자리: 방파일, 걸러낸것 };
221
- return 겹치기(집, 방);
290
+ 버린프로필 = 0;
291
+ const 겹친것 = 겹치기(집, 방);
292
+ const 다걸러낸것 = [
293
+ ...걸러낸것,
294
+ // id 도 name 도 없으면 어느 프로필을 고치라는 것인지 알 수 없다. 버리되 말한다.
295
+ ...(버린프로필 ? [{ 칸: 'profiles', 왜: `id 도 name 도 없는 것 ${버린프로필}개는 못 붙였습니다` }] : []),
296
+ ];
297
+ if (다걸러낸것.length) 신뢰소식 = { 갈래: '걸러냄', 자리: 방파일, 걸러낸것: 다걸러낸것 };
298
+ return 겹친것;
222
299
  }
223
300
 
224
301
  export function save(cfg, { toProject = false } = {}) {
@@ -235,7 +312,8 @@ export function save(cfg, { toProject = false } = {}) {
235
312
  if (잠근것) 프로필.apiKey = 잠근것;
236
313
  }
237
314
  }
238
- writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
315
+ // 정책이 얹은 값은 사람 파일에 안 적는다 (정책벗기기).
316
+ writeFileSync(p, JSON.stringify(정책벗기기(cfg), null, 2) + '\n', 'utf8');
239
317
  // 키가 들어 있는 파일이므로 가능한 환경에서는 본인만 읽게 잠근다.
240
318
  try { chmodSync(p, 0o600); } catch {}
241
319
  return p;
package/src/i18n/en.js CHANGED
@@ -36,7 +36,7 @@ export const en = {
36
36
  'cmd.compact.desc': 'shrink older turns',
37
37
  'cmd.model.desc': 'switch connection/model · `card` shows what this model has done here',
38
38
  'cmd.model.arg': '[name|list|models|card]',
39
- 'cmd.think.desc': 'reasoning effort (off/low/medium/high/max)',
39
+ 'cmd.think.desc': 'reasoning effort (off/low/medium/high/xhigh/max)',
40
40
  'cmd.think.arg': '<level>',
41
41
 
42
42
  // ── /think screen ──────────────────────────────────────────────────
@@ -115,8 +115,8 @@ export const en = {
115
115
  'cmd.bell.arg': '[on|off]',
116
116
  'cmd.motion.desc': 'what moves while deel works — knight, animals, office',
117
117
  'cmd.motion.arg': '[knight|animal|office|default|off]',
118
- 'cmd.lang.desc': 'screen language — 한국어 / English',
119
- 'cmd.lang.arg': '[ko|en] [prompt lang]',
118
+ 'cmd.lang.desc': 'screen language — 한국어 / English / 日本語 / 中文',
119
+ 'cmd.lang.arg': '[ko|en|ja|zh] [prompt lang]',
120
120
  'cmd.init.desc': 'create a DEEL.md rules file',
121
121
  'cmd.exit.desc': 'quit',
122
122
  'cmd.quit.desc': 'quit',
@@ -240,6 +240,8 @@ export const en = {
240
240
  'motion.unknown': '"{값}" is not one of the drawings.',
241
241
  'motion.pickOne': 'Pick one of:',
242
242
  'motion.envWins': 'DEEL_MOTION / DEEL_OFFICE is set, and the environment wins.',
243
+ 'motion.envUnknown': 'DEEL_MOTION is set to "{값}", which is not a name we know — it was ignored, and your saved choice is what applies.',
244
+ 'motion.envOff': 'DEEL_NO_MOTION is set, so nothing is drawn. Your choice is saved and takes effect once you clear that variable.',
243
245
  'motion.tooSmall': 'This terminal is {줄} x {칸} — the office needs at least {최소줄} rows and {최소칸} columns.',
244
246
  'motion.appleTerminal': "macOS Terminal spaces lines apart, so the room gets horizontal seams — Terminal › Settings › Profiles › Text › Font \"Change\" › set Line Spacing to 1.0. (iTerm2 defaults to 1.0, which is why it looks right there)",
245
247
 
@@ -251,7 +253,7 @@ export const en = {
251
253
  'lang.en': 'English',
252
254
  'lang.howto': 'To change it',
253
255
  'lang.changed': 'Screen language is now {이름}.',
254
- 'lang.unknown': 'Please say ko or en.',
256
+ 'lang.unknown': 'Please say one of ko, en, ja or zh.',
255
257
  'lang.progress': '{언어}: {옮김} of {전체} strings translated. The rest come through in Korean.',
256
258
  'lang.partial': 'Not everything is translated yet — untranslated lines show Korean rather than a blank.',
257
259
  'lang.codeStays': 'Unless you set it separately, what the model reads follows the screen. Code, tool names and argument names stay as they are.',
@@ -575,7 +577,7 @@ export const en = {
575
577
  'ctx.skills': 'Skill list ({실림}/{전체})',
576
578
  'ctx.tools': 'Tool definitions',
577
579
  'ctx.history': 'Conversation',
578
- 'ctx.toolResults': 'Tool results ({n} files)',
580
+ 'ctx.toolResults': 'Tool results ({n})',
579
581
  'ctx.left': 'Left',
580
582
  'ctx.compactHint': 'shrink the conversation',
581
583
  'ctx.clearHint': 'clear it all',
package/src/i18n/ja.js CHANGED
@@ -20,7 +20,7 @@ export const ja = {
20
20
  'cmd.compact.desc': '古いやり取りを畳む',
21
21
  'cmd.model.desc': '接続とモデルの切り替え · `card` はこのモデルがここで何をしたかを見せる',
22
22
  'cmd.model.arg': '[名前|list|models|card]',
23
- 'cmd.think.desc': '思考の量 (off/low/medium/high/max)',
23
+ 'cmd.think.desc': '思考の量 (off/low/medium/high/xhigh/max)',
24
24
  'cmd.think.arg': '<段階>',
25
25
 
26
26
  // ── /think 画面 ─────────────────────────────────────────────────────
@@ -213,6 +213,8 @@ export const ja = {
213
213
  'motion.unknown': '"{값}" はその中にありません。',
214
214
  'motion.pickOne': 'この中から選んでください:',
215
215
  'motion.envWins': 'DEEL_MOTION / DEEL_OFFICE が設定されていて、そちらが優先されます。',
216
+ 'motion.envUnknown': 'DEEL_MOTION の "{값}" は知らない名前なので無視しました — 設定で選んだものがそのまま使われます。',
217
+ 'motion.envOff': 'DEEL_NO_MOTION が設定されているので何も描きません。選んだものは保存済みで、その環境変数を消せば効きます。',
216
218
  'motion.tooSmall': 'この端末は {줄} x {칸} です — オフィスには少なくとも {최소줄} 行 {최소칸} 桁が要ります。',
217
219
  'motion.appleTerminal': 'macOS の Terminal は行間を空けるので部屋に横筋が入ります — Terminal › 設定 › プロファイル › テキスト › フォント「変更」› 行間を 1.0 に。(iTerm2 は既定が 1.0 なので、あちらでは綺麗に出ます)',
218
220
  'lang.now': '画面の言葉は {이름} です。',
@@ -529,7 +531,7 @@ export const ja = {
529
531
  'ctx.skills': 'スキル一覧 ({실림}/{전체})',
530
532
  'ctx.tools': '道具の定義',
531
533
  'ctx.history': '会話の履歴',
532
- 'ctx.toolResults': '道具の結果 (ファイル{n}個)',
534
+ 'ctx.toolResults': '道具の結果 ({n}件)',
533
535
  'ctx.left': '残り',
534
536
  'ctx.compactHint': '会話を畳む',
535
537
  'ctx.clearHint': 'まるごと消す',
package/src/i18n/ko.js CHANGED
@@ -23,7 +23,7 @@ export const ko = {
23
23
  'cmd.compact.desc': '오래된 대화 줄이기',
24
24
  'cmd.model.desc': '연결·모델 바꾸기 · `카드` 면 이 모델을 겪어 본 결과',
25
25
  'cmd.model.arg': '[이름|list|models|카드]',
26
- 'cmd.think.desc': '추론 강도 (off/low/medium/high/max)',
26
+ 'cmd.think.desc': '추론 강도 (off/low/medium/high/xhigh/max)',
27
27
  'cmd.think.arg': '<수준>',
28
28
 
29
29
  // ── /think 화면 ────────────────────────────────────────────────────
@@ -104,8 +104,8 @@ export const ko = {
104
104
  'cmd.bell.arg': '[on|off]',
105
105
  'cmd.motion.desc': '일하는 동안 뭐가 도나 — 기사·동물·사무실',
106
106
  'cmd.motion.arg': '[기사|동물|사무실|기본|끔]',
107
- 'cmd.lang.desc': '화면 말 — 한국어 / English',
108
- 'cmd.lang.arg': '[ko|en] [시킬말]',
107
+ 'cmd.lang.desc': '화면 말 — 한국어 / English / 日本語 / 中文',
108
+ 'cmd.lang.arg': '[ko|en|ja|zh] [시킬말]',
109
109
  'cmd.init.desc': 'DEEL.md 규칙 파일 만들기',
110
110
  'cmd.exit.desc': '끝내기',
111
111
  'cmd.quit.desc': '끝내기',
@@ -229,6 +229,8 @@ export const ko = {
229
229
  'motion.unknown': '"{값}"{~값:은는} 모르는 그림입니다.',
230
230
  'motion.pickOne': '고를 수 있는 것:',
231
231
  'motion.envWins': '환경변수(DEEL_MOTION·DEEL_OFFICE)가 켜져 있어 그쪽이 이깁니다.',
232
+ 'motion.envUnknown': 'DEEL_MOTION 에 적힌 "{값}" — 모르는 이름이라 무시했습니다. 설정에 고른 것이 그대로 쓰입니다.',
233
+ 'motion.envOff': 'DEEL_NO_MOTION 이 켜져 있어 그림은 안 돕니다. 고른 것은 적어 뒀고, 그 환경변수를 치우면 그때 살아납니다.',
232
234
  'motion.tooSmall': '이 터미널은 {줄}줄 × {칸}칸입니다 — 사무실은 {최소줄}줄 × {최소칸}칸부터 뜹니다.',
233
235
  'motion.appleTerminal': '맥 기본 터미널은 줄 간격이 넓어 방에 가로줄이 그어집니다 — 터미널 › 설정 › 프로파일 › 텍스트 › 글꼴 「변경」 › 행간을 1.0 으로. (iTerm2 는 기본이 1.0 이라 멀쩡합니다)',
234
236
 
@@ -240,7 +242,7 @@ export const ko = {
240
242
  'lang.en': 'English (영어)',
241
243
  'lang.howto': '바꾸려면',
242
244
  'lang.changed': '화면 말을 {이름:으로} 바꿨습니다.',
243
- 'lang.unknown': 'ko 나 en 으로 적어 주세요.',
245
+ 'lang.unknown': 'ko · en · ja · zh 가운데 하나로 적어 주세요.',
244
246
  'lang.progress': '{언어}: {옮김}/{전체} 마디가 옮겨져 있습니다. 나머지는 한국어로 나옵니다.',
245
247
  'lang.partial': '아직 다 안 옮겼습니다 — 빈칸을 내는 대신 한국어를 그대로 냅니다.',
246
248
  'lang.codeStays': '따로 안 정하면 모델이 읽는 글도 화면 말을 따라갑니다. 코드와 도구·인자 이름은 그대로입니다.',
@@ -622,7 +624,7 @@ export const ko = {
622
624
  'ctx.skills': '스킬 목록 ({실림}/{전체}개)',
623
625
  'ctx.tools': '도구 정의',
624
626
  'ctx.history': '대화 이력',
625
- 'ctx.toolResults': '도구 결과 (파일 {n}개)',
627
+ 'ctx.toolResults': '도구 결과 ({n}개)',
626
628
  'ctx.left': '남음',
627
629
  'ctx.compactHint': '대화 줄이기',
628
630
  'ctx.clearHint': '통째로 비우기',
package/src/i18n/zh.js CHANGED
@@ -20,7 +20,7 @@ export const zh = {
20
20
  'cmd.compact.desc': '折叠较早的对话',
21
21
  'cmd.model.desc': '切换连接与模型 · `card` 显示这个模型在这里做过什么',
22
22
  'cmd.model.arg': '[名称|list|models|card]',
23
- 'cmd.think.desc': '推理力度 (off/low/medium/high/max)',
23
+ 'cmd.think.desc': '推理力度 (off/low/medium/high/xhigh/max)',
24
24
  'cmd.think.arg': '<级别>',
25
25
 
26
26
  // ── /think 画面 ─────────────────────────────────────────────────────
@@ -213,6 +213,8 @@ export const zh = {
213
213
  'motion.unknown': '"{값}" 不在其中。',
214
214
  'motion.pickOne': '从这些里选一个:',
215
215
  'motion.envWins': '设置了 DEEL_MOTION / DEEL_OFFICE,环境变量优先。',
216
+ 'motion.envUnknown': 'DEEL_MOTION 里的 "{값}" 不是我们认识的名字,已忽略 — 实际生效的是配置里选的那个。',
217
+ 'motion.envOff': '设置了 DEEL_NO_MOTION,所以什么都不画。选择已保存,清掉那个环境变量后才会生效。',
216
218
  'motion.tooSmall': '这个终端是 {줄} x {칸} — 办公室至少要 {최소줄} 行 {최소칸} 列。',
217
219
  'motion.appleTerminal': 'macOS 的 Terminal 会拉开行距,房间里会出现横缝 — Terminal › 设置 › 描述文件 › 文本 › 字体「更改」› 把行距设为 1.0。(iTerm2 默认就是 1.0,所以那边看着正常)',
218
220
  'lang.now': '屏幕语言是 {이름}。',
@@ -529,7 +531,7 @@ export const zh = {
529
531
  'ctx.skills': '技能列表 ({실림}/{전체})',
530
532
  'ctx.tools': '工具定义',
531
533
  'ctx.history': '对话历史',
532
- 'ctx.toolResults': '工具结果 (文件{n}个)',
534
+ 'ctx.toolResults': '工具结果 ({n}个)',
533
535
  'ctx.left': '剩余',
534
536
  'ctx.compactHint': '折叠对话',
535
537
  'ctx.clearHint': '全部清空',
package/src/lsp/client.js CHANGED
@@ -151,14 +151,40 @@ export class 언어서버 {
151
151
  */
152
152
  this.#놓기();
153
153
 
154
- this.아이.on('error', (e) => this.#무너짐(`못 띄웠습니다: ${e.message}`));
154
+ /*
155
+ * 파이프마다 손을 달아 둔다.
156
+ *
157
+ * 아이가 죽는 **바로 그때** 쓰면 EPIPE 가 비동기로 터진다. 그 찰나에는
158
+ * `stdin.writable` 이 아직 true 라 #보내기 의 검사도, 그 안의 try/catch 도
159
+ * 못 막는다 — 오류가 한 박자 뒤에 소켓에서 튀어나오기 때문이다. 손이
160
+ * 없으면 그것을 아무도 안 받아서 **deel 이 통째로 죽는다.** 언어 서버가
161
+ * 죽었을 뿐인데 하던 답이 그 자리에서 날아간다.
162
+ */
163
+ for (const 관 of [this.아이.stdin, this.아이.stdout, this.아이.stderr]) 관?.on('error', () => {});
164
+
165
+ /*
166
+ * 이 손은 **이 아이**의 것이다.
167
+ *
168
+ * exit 은 kill() 뒤에 한 박자 늦게 온다. 그 사이에 다시 켜면 옛 아이의
169
+ * exit 이 새 아이를 무너뜨린다 — 「서버가 스스로 끝났습니다」 라고 적고
170
+ * 죽음을 세워서, 멀쩡한 서버를 세션이 끝날 때까지 못 쓰게 만든다.
171
+ * 그러면 Def·Refs 는 「언어 서버가 이 자리에 없습니다」 라고 하고,
172
+ * 고친 뒤 진단은 조용해진다 — 이 프로그램에서 조용한 것은 성하다는 뜻이다.
173
+ */
174
+ const 이아이 = this.아이;
175
+ const 내아이인가 = () => this.아이 === 이아이;
176
+ this.아이.on('error', (e) => { if (내아이인가()) this.#무너짐(`못 띄웠습니다: ${e.message}`); });
155
177
  this.아이.on('exit', (code) => {
156
178
  // 우리가 끈 것이 아니면 무너진 것이다. 기다리던 물음을 다 풀어 준다 —
157
179
  // 안 풀면 그 자리에서 시한까지 통째로 멎는다.
158
- if (!this.끄는중) this.#무너짐(`서버가 스스로 끝났습니다 (코드 ${code})`);
180
+ if (내아이인가() && !this.끄는중) this.#무너짐(`서버가 스스로 끝났습니다 (코드 ${code})`);
159
181
  });
160
182
  this.아이.stdout.on('data', (d) => {
161
- for (const 통 of this.받개.넣기(d)) this.#받음(통);
183
+ // 옛 아이가 죽으면서 남긴 바이트가 뒤늦게 올 수 있다. 받개는 하나뿐이라
184
+ // 그것을 넣으면 **새 서버의 통 경계가 어긋난다** — 길이만 적힌 반쪽
185
+ // 머리말 하나면 그 뒤 답이 전부 안 열려서, 물음마다 시한까지 기다리다
186
+ // 빈손으로 돌아온다. 어디가 잘못됐는지는 화면에 안 뜬다.
187
+ if (내아이인가()) for (const 통 of this.받개.넣기(d)) this.#받음(통);
162
188
  });
163
189
  // stderr 는 읽되 버린다. 안 읽으면 파이프가 차서 서버가 멎는다 —
164
190
  // 언어 서버는 진행 상황을 stderr 로 꽤 많이 쏟는다.
@@ -237,26 +263,50 @@ export class 언어서버 {
237
263
  // ── 주고받기 ──────────────────────────────────────────────────────────
238
264
 
239
265
  #받음(통) {
266
+ /*
267
+ * 답인지 물음인지는 **method 로** 가른다 — 답에는 method 가 없다.
268
+ *
269
+ * 번호로 먼저 가르면 안 된다. 오가는 번호는 양쪽이 **따로** 세고, 서버도
270
+ * 우리처럼 1부터 센다. 번호가 겹치는 순간 서버의 물음(configuration ·
271
+ * registerCapability · workDoneProgress/create)이 우리 물음의 답으로
272
+ * 소비된다. 그 물음은 `{값: undefined}` 로 풀려서 화면에는 「쓰는 자리가
273
+ * 없습니다」 가 뜨고 — 서버는 3곳을 줬는데도 — 서버는 제 물음의 답을 영영
274
+ * 못 받아 그 자리에서 멈춘다.
275
+ */
276
+ if (통.method) {
277
+ // 서버가 우리에게 묻는 것. 안 답하면 서버가 거기서 멈춰 버리는 것이 있어서
278
+ // (configuration 이 그렇다) 빈 답이라도 반드시 돌려준다.
279
+ if (통.id !== undefined) {
280
+ const 값 = 통.method === 'workspace/configuration'
281
+ ? (통.params?.items ?? []).map(() => ({}))
282
+ : null;
283
+ this.#보내기({ jsonrpc: '2.0', id: 통.id, result: 값 });
284
+ return;
285
+ }
286
+ if (통.method === 'textDocument/publishDiagnostics') {
287
+ const p = 통.params ?? {};
288
+ if (!p.uri) return;
289
+ /*
290
+ * 옛 판 진단을 지금 판의 답으로 내주면 안 된다.
291
+ *
292
+ * 서버는 디바운스를 두므로, 한 파일을 1~2초 안에 두 번 고치면 첫 판
293
+ * 결과가 둘째 판 뒤에 도착한다. 그것을 받아 적으면 방금 쓴 파일을
294
+ * 「아무 말 없음 = 성하다」 로 말하게 된다 — 확인 안 한 것을 확인했다고
295
+ * 하는 셈이다. 판을 안 주는 서버는 어쩔 수 없이 그대로 받는다.
296
+ */
297
+ const 열쇠 = 열쇠주소(p.uri);
298
+ const 지금판 = this.연것.get(열쇠);
299
+ if (typeof p.version === 'number' && typeof 지금판 === 'number' && p.version < 지금판) return;
300
+ this.진단.set(열쇠, Array.isArray(p.diagnostics) ? p.diagnostics : []);
301
+ }
302
+ return;
303
+ }
240
304
  if (통.id !== undefined && this.기다림.has(통.id)) {
241
305
  const 것 = this.기다림.get(통.id);
242
306
  this.기다림.delete(통.id);
243
307
  clearTimeout(것.시계);
244
308
  this.#놓기();
245
309
  것.풀기(통.error ? { 오류: 통.error.message ?? String(통.error.code) } : { 값: 통.result });
246
- return;
247
- }
248
- // 서버가 우리에게 묻는 것. 안 답하면 서버가 거기서 멈춰 버리는 것이 있어서
249
- // (configuration 이 그렇다) 빈 답이라도 반드시 돌려준다.
250
- if (통.id !== undefined && 통.method) {
251
- const 값 = 통.method === 'workspace/configuration'
252
- ? (통.params?.items ?? []).map(() => ({}))
253
- : null;
254
- this.#보내기({ jsonrpc: '2.0', id: 통.id, result: 값 });
255
- return;
256
- }
257
- if (통.method === 'textDocument/publishDiagnostics') {
258
- const p = 통.params ?? {};
259
- if (p.uri) this.진단.set(열쇠주소(p.uri), Array.isArray(p.diagnostics) ? p.diagnostics : []);
260
310
  }
261
311
  }
262
312
 
@@ -318,8 +368,12 @@ export class 언어서버 {
318
368
  if (내용 == null) {
319
369
  try { 내용 = readFileSync(abs, 'utf8'); } catch { return null; }
320
370
  }
321
- const 판 = (this.연것.get(uri) ?? 0) + 1;
322
- this.연것.set(uri, 판);
371
+ // 판 번호는 **경로**로 센다. 서버가 주소를 다르게 적어 돌려주므로
372
+ // (pyright 의 `file:///c%3A/…`), 글자 그대로 열쇠를 삼으면 진단이 왔을 때
373
+ // 그 판이 몇 판이었는지 못 찾는다 — 옛 판을 지금 판으로 받아 적게 된다.
374
+ const 열쇠 = 열쇠주소(uri);
375
+ const 판 = (this.연것.get(열쇠) ?? 0) + 1;
376
+ this.연것.set(열쇠, 판);
323
377
  if (판 === 1) {
324
378
  this.알림('textDocument/didOpen', {
325
379
  textDocument: { uri, languageId: 언어아이디(abs), version: 판, text: 내용 },
@@ -384,6 +438,15 @@ export class 언어서버 {
384
438
  try { this.아이.kill(); } catch { /* 이미 갔다 */ }
385
439
  this.아이 = null;
386
440
  this.기다림.clear();
441
+ /*
442
+ * 열어 뒀던 것도 같이 잊는다.
443
+ *
444
+ * 놀림시계로 끈 것은 풀에 그대로 남아서 다음에 다시 켜진다. 판 번호를
445
+ * 안 지우면 새 서버에게 didOpen 없이 `didChange 2판` 을 보내게 되고,
446
+ * 서버는 모르는 파일이라며 조용히 버린다 — 진단이 영영 안 온다.
447
+ */
448
+ this.연것.clear();
449
+ this.진단.clear();
387
450
  this.끄는중 = false;
388
451
  }
389
452
  }
package/src/oneshot.js CHANGED
@@ -32,7 +32,7 @@ import { newId } from './agent/store.js';
32
32
  import { discover, loadCommand } from './skills/discover.js';
33
33
  import { 명령들 } from './cmdnames.js';
34
34
  import { allowEndpoint, setOffline } from './safety/network.js';
35
- import { 지금모드, 바깥인가, 나갈수있나 } from './safety/runmode.js';
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
38
  import { 잠잠기본, 무소식기본 } from './backend/http.js';
@@ -303,7 +303,8 @@ export async function runOnce(opts = {}) {
303
303
  const 실행모드 = 지금모드({
304
304
  online: opts.online === true,
305
305
  // 관리 정책이 offline 을 못박아 뒀으면 옵션과 상관없이 켠다 (safety/policy.js).
306
- offline: !!(opts.offline ?? prof.offline ?? cfg?.offline),
306
+ // 셋 중 하나라도 켜져 있으면 켜진 것이다 (safety/runmode.js 의 봉인됐나).
307
+ offline: 봉인됐나({ 깃발: opts.offline, prof, cfg }),
307
308
  });
308
309
  const 나감 = 나갈수있나(실행모드, { 바깥: 바깥연결, 허가: prof.online === true });
309
310
  if (나감.물어볼까) {
@@ -3,7 +3,7 @@
3
3
  // 온라인 기기에서 /plugin install 로 받고, /plugin pack 으로 묶어
4
4
  // 오프라인 기기에 반입한다. 오프라인에서는 압축만 풀면 그대로 인식된다.
5
5
  import { execFile } from 'node:child_process';
6
- import { homedir } from 'node:os';
6
+ import { homeDir } from '../config.js';
7
7
  import { join, dirname, basename, resolve, sep } from 'node:path';
8
8
  import {
9
9
  existsSync, mkdirSync, writeFileSync, readFileSync, rmSync,
@@ -26,7 +26,16 @@ export function 플러그인되돌림(다음) {
26
26
  }
27
27
  import { copyDir } from '../tools/fsutil.js';
28
28
 
29
- export const pluginsDir = (home = homedir()) => join(home, '.deel', 'plugins');
29
+ /*
30
+ * 플러그인이 깔리는 자리.
31
+ *
32
+ * 여기 넘기는 `home` 은 **살림 자리**(`~/.deel` 또는 `DEEL_HOME`)다. 예전에는
33
+ * OS 의 집 폴더를 받아 `.deel` 을 직접 붙였는데, 그러면 `DEEL_HOME` 이 안
34
+ * 먹었다 — 휴대용 설치(USB·공유폴더)에서 설정은 USB 로 가고 플러그인만
35
+ * 로밍 프로필에 남았고, `deel reset plugins` 는 엉뚱한 데를 보며 「0개 ·
36
+ * 이미 비어 있습니다」 를 찍었다. 살림 자리 하나로 모은다.
37
+ */
38
+ export const pluginsDir = (home = homeDir()) => join(home, 'plugins');
30
39
 
31
40
  // owner/repo · owner/repo#가지 · 전체 URL 을 모두 받는다.
32
41
  export function parseSpec(spec) {
@@ -243,7 +252,7 @@ function walkCount(dir, match, depth = 5) {
243
252
  return n;
244
253
  }
245
254
 
246
- export async function install(spec, { home = homedir(), onStep } = {}) {
255
+ export async function install(spec, { home = homeDir(), onStep } = {}) {
247
256
  const base = pluginsDir(home);
248
257
 
249
258
  // 이미 풀어 놓은 폴더를 그대로 넣는 길. 오프라인 기기에서 이쪽을 쓴다.
@@ -312,7 +321,7 @@ export async function install(spec, { home = homedir(), onStep } = {}) {
312
321
  return { name, version: info?.version ?? '', license: info?.license ?? null, path: dest, ...counts, how: got.how };
313
322
  }
314
323
 
315
- export function list({ home = homedir() } = {}) {
324
+ export function list({ home = homeDir() } = {}) {
316
325
  const base = pluginsDir(home);
317
326
  if (!existsSync(base)) return [];
318
327
  const out = [];
@@ -335,7 +344,7 @@ export function list({ home = homedir() } = {}) {
335
344
  return out.sort((a, b) => b.skills - a.skills);
336
345
  }
337
346
 
338
- export function remove(name, { home = homedir() } = {}) {
347
+ export function remove(name, { home = homeDir() } = {}) {
339
348
  /*
340
349
  * 지우는 자리도 이름으로 정해진다. 그러니 여기도 한 칸으로 자른다.
341
350
  *
@@ -357,7 +366,7 @@ export function remove(name, { home = homedir() } = {}) {
357
366
  const PACK_SKIP_DIRS = new Set(['node_modules', '.git', 'test', 'tests', '__pycache__', '.github']);
358
367
  const PACK_SKIP_EXT = /\.(js|cjs|mjs|sh|ps1|cmd|bat|py|exe|dll|so|dylib)$/i;
359
368
 
360
- export function pack(outFile, { home = homedir(), only = null } = {}) {
369
+ export function pack(outFile, { home = homeDir(), only = null } = {}) {
361
370
  const base = pluginsDir(home);
362
371
  if (!existsSync(base)) return { error: '설치된 플러그인이 없습니다.' };
363
372
 
package/src/repl.js CHANGED
@@ -31,7 +31,7 @@ import { 프로젝트설정줄들 } from './safety/trust.js';
31
31
  import { 남길것읽기 } from './safety/shellenv.js';
32
32
  import { discover } from './skills/discover.js';
33
33
  import { allowEndpoint, setOffline, isOffline } from './safety/network.js';
34
- import { 지금모드, 바깥인가, 나갈수있나 } from './safety/runmode.js';
34
+ import { 지금모드, 바깥인가, 나갈수있나, 봉인됐나 } from './safety/runmode.js';
35
35
  import { 세션요금, 돈셈, 돈말 } from './backend/price.js';
36
36
  import { Store, latest, prune } from './agent/store.js';
37
37
  import { Threads } from './agent/threads.js';
@@ -280,7 +280,8 @@ export async function chatLoop(opts = {}) {
280
280
  const 실행모드 = 지금모드({
281
281
  online: opts.online === true,
282
282
  // 관리 정책이 offline 을 못박아 뒀으면 옵션과 상관없이 켠다 (safety/policy.js).
283
- offline: !!(opts.offline ?? prof.offline ?? cfg?.offline),
283
+ // 셋 중 하나라도 켜져 있으면 켜진 것이다 (safety/runmode.js 의 봉인됐나).
284
+ offline: 봉인됐나({ 깃발: opts.offline, prof, cfg }),
284
285
  });
285
286
  const 나감 = 나갈수있나(실행모드, { 바깥: 바깥연결, 허가: prof.online === true });
286
287
  // 이 자리 하나만 연다. 다른 어디로도 나가지 못한다.
@@ -455,6 +456,8 @@ export async function chatLoop(opts = {}) {
455
456
  // 되돌리기 이력을 못 읽는다는 말은 한 판에 한 번만 한다 — 턴마다 세는 자리라
456
457
  // 안 막으면 같은 줄이 턴 수만큼 쌓인다.
457
458
  let 되돌리기못읽음말했나 = false;
459
+ // 이력이 커졌는데 못 줄인 것·깨진 줄. 둘 다 한 판에 한 번만 말한다.
460
+ let 이력탈말했나 = false;
458
461
  /*
459
462
  * 정리가 실패해도 켜는 것은 막지 않는다. 다만 **삼키지는 않는다.**
460
463
  * 계속 실패하면 .deel/sessions 가 끝없이 커지는데 화면에는 영영 안 떴다.
@@ -2335,6 +2338,21 @@ export async function chatLoop(opts = {}) {
2335
2338
  const 것들 = ev.result.되돌릴것;
2336
2339
  say(` ${c.gray(`↩ ${것들.slice(0, 3).join(' · ')}${것들.length > 3 ? ` 외 ${것들.length - 3}개` : ''} 는 떠 뒀습니다 — /undo 로 되돌아갑니다`)}`);
2337
2340
  }
2341
+ /*
2342
+ * ── 못 뜬 것은 **되돌아가지 않는다** ──────────────────────
2343
+ *
2344
+ * 위 한 줄만 있을 때, `rm 로고.png` 는 「로고.png 는 떠 뒀습니다
2345
+ * — /undo 로 되돌아갑니다」 를 찍었다. 그림·xlsx·zip 은 내용을
2346
+ * 못 떠서 /undo 가 손도 안 대는데도 그랬다. 파일은 이미 없고,
2347
+ * 안전망이 있다는 말만 남는다.
2348
+ */
2349
+ if (ev.result?.못뜬것?.length) {
2350
+ const 못 = ev.result.못뜬것;
2351
+ say(` ${mark.warn} ${c.gray(`${못.slice(0, 3).join(' · ')}${못.length > 3 ? ` 외 ${못.length - 3}개` : ''} 는 못 떠 뒀습니다 — /undo 로 안 돌아옵니다`)}`);
2352
+ }
2353
+ if (ev.result?.스냅샷상한걸림) {
2354
+ say(` ${mark.warn} ${c.gray(`대상이 많아 앞 ${ev.result.되돌릴것?.length ?? 0}개만 떠 뒀습니다 — 나머지는 /undo 로 안 돌아옵니다`)}`);
2355
+ }
2338
2356
  // 파일을 고쳤으면 무엇이 바뀌었는지 바로 보여 준다.
2339
2357
  //
2340
2358
  // auto 모드는 안 물어보고 고친다. 여기서 안 보여주면 사람이
@@ -2383,6 +2401,20 @@ export async function chatLoop(opts = {}) {
2383
2401
  * 줄 수는 0 이다 — 옮긴 것이지 고친 것이 아니다.
2384
2402
  */
2385
2403
  for (const f of ev.result.바뀐것들) session.noteChange(f, { added: 0, removed: 0 });
2404
+ } else if (ev.result?.changed) {
2405
+ /*
2406
+ * ── 바뀐 **경로만** 주는 도구가 있다 ─────────────────────
2407
+ *
2408
+ * 파일 하나짜리 Move 와 hwpx Write 가 그렇다. 옮긴 것·새로 만든
2409
+ * 문서라 「몇 줄 바뀌었나」 가 없다. 그런데 위 세 갈래가 전부
2410
+ * diff·여럿·바뀐것들 을 보므로 셋 다 안 걸리고, 그러면
2411
+ * noteChange 가 **아예 안 불린다.**
2412
+ *
2413
+ * 파일은 진짜로 옮겨졌는데 /diff 목록에 없고, 상태줄 ✎ 에도 안
2414
+ * 들고, /commit 이 담는 목록에도 없다. 커밋은 초록으로 끝나고
2415
+ * 옮긴 파일만 안 담긴다 — 나중에 원격에서만 깨진다.
2416
+ */
2417
+ session.noteChange(ev.result.changed, { added: 0, removed: 0 });
2386
2418
  }
2387
2419
  }
2388
2420
  flush(); // 도구가 하나 끝날 때마다 적어 둔다
@@ -2899,6 +2931,27 @@ export async function chatLoop(opts = {}) {
2899
2931
  */
2900
2932
  try {
2901
2933
  session.되돌릴턴 = ctx.history.turns().length;
2934
+ /*
2935
+ * ── 안전망이 **제 무게로 무너지는** 것도 여기서 잡힌다 ──────────
2936
+ *
2937
+ * 되돌리기 이력은 파일 내용을 통째로 담아서 금방 수십 MB 가 된다.
2938
+ * 32MB 를 넘으면 오래된 턴부터 버리는데, 그 버리기가 실패하면
2939
+ * (읽기 전용·디스크 참) 이력은 끝없이 자라고 화면에는 영영 안 떴다.
2940
+ * 실패는 대개 이어지는 종류라 저절로 낫지도 않는다.
2941
+ *
2942
+ * 깨진 줄도 같다. 이력 한 줄이 곧 한 파일의 원문이라, 줄이 깨졌다는
2943
+ * 것은 그 파일을 되돌릴 길이 사라졌다는 뜻이다. 상태줄의 `↩ 3` 은
2944
+ * 그 셋이 다 되돌아간다는 말인데, 깨진 줄이 있으면 그게 아니다.
2945
+ */
2946
+ if (!이력탈말했나 && (ctx.history.줄이기못함 || ctx.history.깨진줄)) {
2947
+ 이력탈말했나 = true;
2948
+ if (ctx.history.줄이기못함) {
2949
+ say(` ${mark.warn} ${c.gray('되돌리기 이력이 커졌는데 못 줄였습니다')} ${c.gray('— ' + clip(String(ctx.history.줄이기못함), 60))}`);
2950
+ }
2951
+ if (ctx.history.깨진줄) {
2952
+ say(` ${mark.warn} ${c.gray(`되돌리기 이력에서 ${ctx.history.깨진줄}줄을 못 읽었습니다 — 그만큼은 ${'/undo'} 로 안 돌아옵니다.`)}`);
2953
+ }
2954
+ }
2902
2955
  } catch (e) {
2903
2956
  session.되돌릴턴 = 0;
2904
2957
  if (!되돌리기못읽음말했나) {