deel-local-cli 1.20.11 → 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/commands.js CHANGED
@@ -1672,11 +1672,18 @@ export async function handle(line, session, ctx) {
1672
1672
  return { handled: true };
1673
1673
  }
1674
1674
  for (const [i, r] of rows.entries()) {
1675
- say(` ${i === 0 ? c.hgreen('●') : c.gray('·')} ${c.bold(pad(r.id, 17))}${c.gray(pad(`${r.turns}턴`, 6, 'right'))} ${c.gray(clip(r.model, 22))}`);
1676
- say(` ${c.gray(clip(r.first, 66))}`);
1675
+ // 못 읽은 것도 올린다 (agent/store.js 의 list). 없는 것과 못 여는 것은 다르다.
1676
+ const 앞 = r.못읽음 ? c.yellow(mark.warn) : i === 0 ? c.hgreen('●') : c.gray('·');
1677
+ say(` ${앞} ${c.bold(pad(r.id, 17))}${c.gray(pad(r.못읽음 ? '' : `${r.turns}턴`, 6, 'right'))} ${c.gray(clip(r.model, 22))}`);
1678
+ say(` ${r.못읽음 ? c.yellow(clip(r.first, 66)) : c.gray(clip(r.first, 66))}`);
1677
1679
  }
1678
1680
  say('');
1679
- say(` ${c.gray('이어하려면 나갔다가')} ${c.cyan('deel --continue')} ${c.gray('또는')} ${c.cyan(`deel --resume ${rows[0].id}`)}`);
1681
+ const 읽히는것 = rows.filter((r) => !r.못읽음);
1682
+ if (읽히는것.length) {
1683
+ say(` ${c.gray('이어하려면 나갔다가')} ${c.cyan('deel --continue')} ${c.gray('또는')} ${c.cyan(`deel --resume ${읽히는것[0].id}`)}`);
1684
+ } else {
1685
+ say(` ${mark.warn} ${c.yellow('여기 있는 대화를 하나도 못 열었습니다 — 이어할 수 없습니다.')}`);
1686
+ }
1680
1687
  /*
1681
1688
  * 바로 아래 줄이 약속이다. 못 지키고 있으면 약속을 되풀이하면 안 된다.
1682
1689
  *
@@ -2584,6 +2591,18 @@ async function 커밋명령(session, ctx, arg = '') {
2584
2591
  say('');
2585
2592
  say(` ${c.yellow('모델이 메시지를 못 만들어 바뀐 것만 적었습니다.')}`);
2586
2593
  }
2594
+ /*
2595
+ * diff 를 못 읽고 지은 메시지라면 그렇다고 적는다.
2596
+ *
2597
+ * 담긴 것이 64MB 를 넘으면 git 이 몸통을 못 뱉는다. 파일 목록은 멀쩡해서
2598
+ * 커밋은 그대로 이어지는데, 그때 메시지는 **내용을 한 줄도 안 보고** 지은
2599
+ * 것이다. 그 사실이 화면에 없으면 사람은 여느 메시지와 똑같이 믿는다.
2600
+ */
2601
+ if (r.diff못읽음) {
2602
+ say('');
2603
+ say(` ${c.yellow('내용(diff)을 못 읽고 지은 메시지입니다')} ${c.gray(`— ${clip(r.diff못읽음, 60)}`)}`);
2604
+ say(` ${c.gray('파일 이름과 줄 수만 보고 썼습니다. 찍기 전에 한 번 읽어 주세요.')}`);
2605
+ }
2587
2606
 
2588
2607
  const 상태줄 = r.상태 ? r.상태.split('\n') : [];
2589
2608
  if (상태줄.length) {
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',
@@ -253,7 +253,7 @@ export const en = {
253
253
  'lang.en': 'English',
254
254
  'lang.howto': 'To change it',
255
255
  'lang.changed': 'Screen language is now {이름}.',
256
- 'lang.unknown': 'Please say ko or en.',
256
+ 'lang.unknown': 'Please say one of ko, en, ja or zh.',
257
257
  'lang.progress': '{언어}: {옮김} of {전체} strings translated. The rest come through in Korean.',
258
258
  'lang.partial': 'Not everything is translated yet — untranslated lines show Korean rather than a blank.',
259
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.',
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 画面 ─────────────────────────────────────────────────────
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': '끝내기',
@@ -242,7 +242,7 @@ export const ko = {
242
242
  'lang.en': 'English (영어)',
243
243
  'lang.howto': '바꾸려면',
244
244
  'lang.changed': '화면 말을 {이름:으로} 바꿨습니다.',
245
- 'lang.unknown': 'ko 나 en 으로 적어 주세요.',
245
+ 'lang.unknown': 'ko · en · ja · zh 가운데 하나로 적어 주세요.',
246
246
  'lang.progress': '{언어}: {옮김}/{전체} 마디가 옮겨져 있습니다. 나머지는 한국어로 나옵니다.',
247
247
  'lang.partial': '아직 다 안 옮겼습니다 — 빈칸을 내는 대신 한국어를 그대로 냅니다.',
248
248
  'lang.codeStays': '따로 안 정하면 모델이 읽는 글도 화면 말을 따라갑니다. 코드와 도구·인자 이름은 그대로입니다.',
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 画面 ─────────────────────────────────────────────────────
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
  // 이 자리 하나만 연다. 다른 어디로도 나가지 못한다.
package/src/reset.js CHANGED
@@ -31,6 +31,7 @@ import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
31
31
  import { c, say, mark, rule, pad } from './ui/ansi.js';
32
32
  import { ask, confirm } from './ui/prompt.js';
33
33
  import { homeDir } from './config.js';
34
+ import { pluginsDir } from './plugins/manage.js';
34
35
  import { 잠긴것인가, 잠금지우기, 보관방식 } from './safety/keystore.js';
35
36
 
36
37
  /** 사람이 칠 수 있는 이름. bin/deel.js 의 도움말·완성 목록과 같아야 한다. */
@@ -41,16 +42,28 @@ export const 갈래들 = ['model', 'memory', 'sessions', 'learned', 'plugins', '
41
42
  // 세다가 나는 탈은 전부 삼킨다. 이건 '무엇이 있나' 를 보여 주려는 것이지
42
43
  // 검사가 아니다 — 한 줄 못 셌다고 초기화 자체를 못 하게 하면 안 된다.
43
44
 
45
+ /*
46
+ * 다만 **없는 것과 못 읽은 것은 가른다.** 둘 다 0 으로 적으면, 지우기 전
47
+ * 확인 화면이 「기억 0줄 → 돌아오지 않습니다」 라고 말한다. 사람은 잃을 것이
48
+ * 없다고 읽고 넘기는데, 바로 다음 줄에서 폴더가 통째로 사라진다.
49
+ * 없으면 0, 못 읽으면 null 이다 — null 은 화면에서 「알 수 없음」 으로 뜬다
50
+ * (설정살피기 가 이미 그렇게 하고 있다).
51
+ */
44
52
  function 줄수(파일) {
53
+ if (!existsSync(파일)) return 0;
45
54
  try { return readFileSync(파일, 'utf8').split('\n').filter((l) => l.trim()).length; }
46
- catch { return 0; }
55
+ catch { return null; }
47
56
  }
48
57
 
49
58
  function 안에것(폴더, 거르기 = () => true) {
59
+ if (!existsSync(폴더)) return 0;
50
60
  try { return readdirSync(폴더, { withFileTypes: true }).filter(거르기).length; }
51
- catch { return 0; }
61
+ catch { return null; }
52
62
  }
53
63
 
64
+ /** 여러 자리를 센 것을 하나로 모은다. 한 자리라도 못 셌으면 못 센 것이다. */
65
+ const 셈모으기 = (값들) => (값들.some((n) => n === null) ? null : 값들.reduce((a, n) => a + n, 0));
66
+
54
67
  /**
55
68
  * 설정을 읽어 본다. **깨져 있어도 답한다.**
56
69
  *
@@ -68,10 +81,21 @@ export function 설정살피기(파일) {
68
81
  return { 있나: true, 프로필: null, 잠긴열쇠: [], 왜: `읽을 수 없습니다 (${한줄})` };
69
82
  }
70
83
  const 목록 = Array.isArray(j?.profiles) ? j.profiles : [];
84
+ /*
85
+ * 연결 말고 **다른 것**이 이 파일에 적혀 있나.
86
+ *
87
+ * 저장소의 `.deel/config.json` 에는 프로필 대신 `mode` 나
88
+ * `permissions.deny` 만 적어 두는 쓰임이 있다 (config.js 가 권하는 그
89
+ * 쓰임이다). 그런 파일을 `deel reset model` 이 통째로 지우면, 사람이 손으로
90
+ * 적어 둔 **금지 규칙**이 초기화 한 번에 없어진다. 이 파일 머리말이
91
+ * 「사람이 손으로 적은 것은 어떤 길로도 안 지운다」 고 적어 둔 그 약속이다.
92
+ */
93
+ const 연결칸 = new Set(['profiles', 'active', '정책주소', 'api-version']);
71
94
  return {
72
95
  있나: true,
73
96
  프로필: 목록.length,
74
97
  잠긴열쇠: 목록.map((p) => p?.apiKey).filter((k) => 잠긴것인가(k)),
98
+ 다른것: Object.keys(j ?? {}).filter((k) => !연결칸.has(k)),
75
99
  왜: '',
76
100
  };
77
101
  }
@@ -91,12 +115,15 @@ export function 살펴보기({ home = homeDir(), root = process.cwd() } = {}) {
91
115
  const 집설정 = 설정살피기(집('config.json'));
92
116
  const 일설정 = 설정살피기(일('config.json'));
93
117
  const 열쇠들 = [...집설정.잠긴열쇠, ...일설정.잠긴열쇠];
118
+ // 저장소 설정에 연결 말고 다른 것이 적혀 있으면 그 파일은 안 건드린다.
119
+ const 일설정남길까 = 일설정.있나 && (일설정.프로필 === null || (일설정.다른것?.length ?? 0) > 0);
120
+ const 플러그인자리 = pluginsDir(home);
94
121
 
95
122
  const 항목 = [
96
123
  {
97
124
  키: 'model',
98
125
  이름: '연결·프로필',
99
- 자리: [집('config.json'), 일('config.json')],
126
+ 자리: [집('config.json'), ...(일설정남길까 ? [] : [일('config.json')])],
100
127
  몇: 집설정.프로필 === null || 일설정.프로필 === null
101
128
  ? null : (집설정.프로필 ?? 0) + (일설정.프로필 ?? 0),
102
129
  단위: '개',
@@ -139,7 +166,7 @@ export function 살펴보기({ home = homeDir(), root = process.cwd() } = {}) {
139
166
  키: '만든것',
140
167
  이름: '증거·내보낸 것·임시',
141
168
  자리: [일('증거'), 일('export'), 일('tmp'), 일('붙인그림')],
142
- 몇: [일('증거'), 일('export'), 일('tmp'), 일('붙인그림')].reduce((n, p) => n + 안에것(p), 0),
169
+ 몇: 셈모으기([일('증거'), 일('export'), 일('tmp'), 일('붙인그림')].map((p) => 안에것(p))),
143
170
  단위: '개',
144
171
  뒤: '안전합니다',
145
172
  all: true,
@@ -148,8 +175,11 @@ export function 살펴보기({ home = homeDir(), root = process.cwd() } = {}) {
148
175
  {
149
176
  키: 'plugins',
150
177
  이름: '플러그인',
151
- 자리: [집('plugins')],
152
- 몇: 안에것(집('plugins'), (e) => e.isDirectory() && !e.name.startsWith('.')),
178
+ // 깔리는 자리와 **같은 함수**로 고른다 (plugins/manage.js 의 pluginsDir).
179
+ // 두 자리가 갈리면 화면이 「0개 · 이미 비어 있습니다」 를 찍는데
180
+ // 플러그인은 그대로 살아 있다.
181
+ 자리: [플러그인자리],
182
+ 몇: 안에것(플러그인자리, (e) => e.isDirectory() && !e.name.startsWith('.')),
153
183
  단위: '개',
154
184
  뒤: c.yellow('다시 설치해야 합니다'),
155
185
  // 따로 고를 때만 지운다. 다시 받는 데 시간이 걸리고, 사람이 초기화하려는
@@ -181,6 +211,7 @@ export function 살펴보기({ home = homeDir(), root = process.cwd() } = {}) {
181
211
  // 어떤 길로도 안 지우는 것. 화면에 이름을 적어 둔다 — 「왜 이건 안 지웠지」
182
212
  // 를 나중에 묻게 하지 않으려고.
183
213
  const 안건드림 = [
214
+ { 이름: '.deel/config.json (연결 말고 다른 것이 적혀 있습니다)', 있나: 일설정남길까 },
184
215
  { 이름: '.deel/mcp.json', 있나: existsSync(일('mcp.json')) },
185
216
  { 이름: '.deelignore', 있나: existsSync(join(root, '.deelignore')) },
186
217
  { 이름: 'DEEL.md', 있나: existsSync(join(root, 'DEEL.md')) },
@@ -156,7 +156,10 @@ export function 훅펴기(raw, 출처) {
156
156
  if (Array.isArray(것)) {
157
157
  for (const x of 것) {
158
158
  const 자리 = 자리풀기(x?.때 ?? x?.자리 ?? x?.event);
159
- if (!자리) { 버린것.push(`모르는 자리: ${x?.때 ?? x?.event ?? '(없음)'}`); continue; }
159
+ // 바로 위에서 `때 · 자리 · event` 셋을 다 보고 골랐는데, 무엇이 틀렸는지
160
+ // 적을 때는 `자리` 만 빠져 있었다. `{자리: '오타'}` 로 적어 둔 사람은
161
+ // 「모르는 자리: (없음)」 을 받는다 — 제가 적은 글자가 화면에 안 나온다.
162
+ if (!자리) { 버린것.push(`모르는 자리: ${x?.때 ?? x?.자리 ?? x?.event ?? '(없음)'}`); continue; }
160
163
  const h = 한훅(자리, x?.도구 ?? x?.matcher ?? '*', x, 출처);
161
164
  if (h) 훅들.push(h); else 버린것.push(`${자리}: 명령이나 무늬가 잘못 적혀 있습니다`);
162
165
  }