pinglet-cli 0.1.6 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,7 +41,7 @@ codex # turn 완료 시 macOS 알림으로 Ping 전달 (experimental)
41
41
  | `pinglet install [--api <url>] [--force]` | Claude Code / Codex 자동 감지 후 integration 설치 + 서버 등록 + 초기 feed 캐시 |
42
42
  | `pinglet uninstall [--purge]` | integration 제거, 기존 설정 복원. `--purge`는 `~/.pinglet`까지 삭제 |
43
43
  | `pinglet login [--token <jwt>]` | GitHub 로그인 후 익명 설치를 계정에 연결 |
44
- | `pinglet post "메시지" [--category <c>]` | 메시지 작성 (설치만 해도 익명 작성 가능, 로그인 시 닉네임 표시) |
44
+ | `pinglet post "메시지" [--category <c>]` | 메시지 작성 (GitHub 로그인 필요, 읽기는 로그인 없이 가능) |
45
45
  | `pinglet doctor` | 설치/캐시/큐/서버 상태 진단 |
46
46
  | `pinglet ping` | 메시지 미리보기 (이벤트 기록 없음) |
47
47
 
@@ -114,3 +114,14 @@ install 시 `~/.claude/commands/pinglet.md` slash command가 함께 설치되며
114
114
  state.json # 현재 표시 중 메시지, seen 카운트, flush/refresh 타이밍
115
115
  events.jsonl # Local Event Queue (append-only)
116
116
  ```
117
+
118
+ ## 약관 및 개인정보처리방침
119
+
120
+ Pinglet은 GitHub 계정 정보(로그인 시), 설치 환경 정보, 접속 IP를 서비스 제공·어뷰징
121
+ 방지 목적으로 처리합니다. 로그인하여 메시지를 작성하면 아래 문서에 동의한 것으로
122
+ 간주됩니다.
123
+
124
+ - [이용약관](https://pinglet.halluci.co.kr/terms)
125
+ - [개인정보처리방침](https://pinglet.halluci.co.kr/privacy)
126
+
127
+ 문의: leeyeonggyun@naver.com
@@ -131,19 +131,42 @@ const MAX_VERBS = 30;
131
131
  /** spinner 한 줄에 "(27s · ↓ 1.0k tokens)" 등이 뒤에 붙으므로 여유를 둔다. */
132
132
  const MAX_VERB_LEN = 60;
133
133
  function readSettings() {
134
+ return readSettingsWithMtime().settings;
135
+ }
136
+ function settingsMtimeMs() {
137
+ try {
138
+ return fs.statSync(SETTINGS_PATH).mtimeMs;
139
+ }
140
+ catch {
141
+ return null; // 파일 없음
142
+ }
143
+ }
144
+ function readSettingsWithMtime() {
145
+ const mtimeMs = settingsMtimeMs();
134
146
  try {
135
- return JSON.parse(fs.readFileSync(SETTINGS_PATH, "utf8"));
147
+ return {
148
+ settings: JSON.parse(fs.readFileSync(SETTINGS_PATH, "utf8")),
149
+ mtimeMs,
150
+ };
136
151
  }
137
152
  catch {
138
- return {};
153
+ return { settings: {}, mtimeMs };
139
154
  }
140
155
  }
141
- function writeSettings(settings) {
156
+ /**
157
+ * mtime이 읽던 시점 그대로일 때만 쓴다 — 변경이 감지되면 false를 돌려주고
158
+ * 호출부가 다시 읽어 재적용한다. Claude Code 자신도 이 파일을 쓰기 때문에,
159
+ * 그 사이의 변경을 우리 스냅샷으로 덮어써 사용자 설정을 유실하면 안 된다.
160
+ */
161
+ function writeSettings(settings, expectedMtimeMs) {
142
162
  fs.mkdirSync(CLAUDE_DIR, { recursive: true });
163
+ if (settingsMtimeMs() !== expectedMtimeMs)
164
+ return false;
143
165
  // settings.json은 Claude Code가 수시로 읽는 파일이라 tmp+rename으로 원자적으로 쓴다.
144
166
  const tmp = SETTINGS_PATH + ".pinglet-tmp";
145
167
  fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
146
168
  fs.renameSync(tmp, SETTINGS_PATH);
169
+ return true;
147
170
  }
148
171
  function isPingletTip(tip) {
149
172
  return tip.startsWith(PINGLET_TIP_MARKER);
@@ -194,47 +217,58 @@ function armSpinnerMessage(config, message) {
194
217
  const adapter = config.adapters.claude;
195
218
  if (!adapter)
196
219
  return false;
197
- const settings = readSettings();
198
- cleanupSpinnerTips(settings, config);
199
- const existing = settings.spinnerVerbs;
200
- const existingVerbs = existing?.verbs ?? [];
201
- // 사용자가 원래 갖고 있던 spinnerVerbs는 최초 1회 백업해 두고 uninstall 시 복원한다.
202
- const isOurs = existingVerbs.some(isPingletTip);
203
- if (existing && !isOurs && adapter.spinnerVerbsBackup === undefined) {
204
- adapter.spinnerVerbsBackup = existing;
205
- (0, config_1.saveConfig)(config);
206
- }
207
- const userVerbs = existingVerbs.filter((verb) => !isPingletTip(verb));
208
- const pingletVerbs = message ? buildPingletVerbs([message]) : [];
209
- // armed할 메시지가 없으면 spinner를 기본 상태로 되돌린다.
210
- if (pingletVerbs.length === 0) {
211
- if (!isOurs)
212
- return false;
213
- const backup = adapter.spinnerVerbsBackup;
214
- if (backup) {
215
- settings.spinnerVerbs = backup;
216
- delete adapter.spinnerVerbsBackup;
220
+ // 읽기~쓰기 사이에 다른 프로세스(Claude Code 등)가 파일을 바꿨으면
221
+ // writeSettings가 거부하므로, 다시 읽어 재적용한다.
222
+ for (let attempt = 0; attempt < 3; attempt++) {
223
+ const { settings, mtimeMs } = readSettingsWithMtime();
224
+ cleanupSpinnerTips(settings, config);
225
+ const existing = settings.spinnerVerbs;
226
+ const existingVerbs = existing?.verbs ?? [];
227
+ // 사용자가 원래 갖고 있던 spinnerVerbs는 최초 1회 백업해 두고 uninstall 시 복원한다.
228
+ const isOurs = existingVerbs.some(isPingletTip);
229
+ if (existing && !isOurs && adapter.spinnerVerbsBackup === undefined) {
230
+ adapter.spinnerVerbsBackup = existing;
217
231
  (0, config_1.saveConfig)(config);
218
232
  }
219
- else if (userVerbs.length > 0) {
220
- settings.spinnerVerbs = { ...existing, verbs: userVerbs };
233
+ const userVerbs = existingVerbs.filter((verb) => !isPingletTip(verb));
234
+ const pingletVerbs = message ? buildPingletVerbs([message]) : [];
235
+ // armed할 메시지가 없으면 spinner를 기본 상태로 되돌린다.
236
+ if (pingletVerbs.length === 0) {
237
+ if (!isOurs)
238
+ return false;
239
+ const backup = adapter.spinnerVerbsBackup;
240
+ if (backup) {
241
+ settings.spinnerVerbs = backup;
242
+ }
243
+ else if (userVerbs.length > 0) {
244
+ settings.spinnerVerbs = { ...existing, verbs: userVerbs };
245
+ }
246
+ else {
247
+ delete settings.spinnerVerbs;
248
+ }
249
+ if (writeSettings(settings, mtimeMs)) {
250
+ // 쓰기가 확정된 뒤에만 백업을 소비한다 (실패 시 백업 유실 방지).
251
+ if (backup) {
252
+ delete adapter.spinnerVerbsBackup;
253
+ (0, config_1.saveConfig)(config);
254
+ }
255
+ return false;
256
+ }
257
+ continue;
221
258
  }
222
- else {
223
- delete settings.spinnerVerbs;
259
+ const next = [...userVerbs, ...pingletVerbs];
260
+ // 같은 메시지를 다시 armed하는 경우 파일 쓰기를 생략한다 (300ms 렌더링 경로 보호).
261
+ if (existing?.mode === "replace" &&
262
+ existingVerbs.length === next.length &&
263
+ existingVerbs.every((v, i) => v === next[i])) {
264
+ return true;
224
265
  }
225
- writeSettings(settings);
226
- return false;
227
- }
228
- const next = [...userVerbs, ...pingletVerbs];
229
- // 같은 메시지를 다시 armed하는 경우 파일 쓰기를 생략한다 (300ms 렌더링 경로 보호).
230
- if (existing?.mode === "replace" &&
231
- existingVerbs.length === next.length &&
232
- existingVerbs.every((v, i) => v === next[i])) {
233
- return true;
266
+ settings.spinnerVerbs = { mode: "replace", verbs: next };
267
+ if (writeSettings(settings, mtimeMs))
268
+ return true;
234
269
  }
235
- settings.spinnerVerbs = { mode: "replace", verbs: next };
236
- writeSettings(settings);
237
- return true;
270
+ // 계속 충돌하면 이번 tick은 건너뛴다 — 다음 statusline tick에서 재시도된다.
271
+ return false;
238
272
  }
239
273
  /** doctor용: spinner verb로 등록된 Pinglet 메시지 개수. */
240
274
  function spinnerVerbsStatus() {
@@ -247,75 +281,84 @@ function isClaudeIntegrationInstalled() {
247
281
  return settings.statusLine?.command?.includes("pinglet") ?? false;
248
282
  }
249
283
  function installClaudeIntegration(config, options = {}) {
250
- const settings = readSettings();
251
- const existing = settings.statusLine;
252
- const isOurs = existing?.command?.includes("pinglet");
253
- if (existing && !isOurs && !options.force) {
254
- return {
255
- ok: false,
256
- reason: "기존 statusLine 설정이 있습니다. 덮어쓰려면 --force로 다시 실행하세요.",
284
+ for (let attempt = 0; attempt < 3; attempt++) {
285
+ const { settings, mtimeMs } = readSettingsWithMtime();
286
+ const existing = settings.statusLine;
287
+ const isOurs = existing?.command?.includes("pinglet");
288
+ if (existing && !isOurs && !options.force) {
289
+ return {
290
+ ok: false,
291
+ reason: "기존 statusLine 설정이 있습니다. 덮어쓰려면 --force로 다시 실행하세요.",
292
+ };
293
+ }
294
+ config.adapters.claude = {
295
+ // 재설치 시 기존 백업(spinnerVerbsBackup 등)이 날아가지 않게 유지한다.
296
+ ...config.adapters.claude,
297
+ installedAt: new Date().toISOString(),
298
+ settingsPath: SETTINGS_PATH,
299
+ // 사용자의 기존 설정은 백업해 두고 uninstall 시 복원한다.
300
+ ...(existing && !isOurs && { statusLineBackup: existing }),
257
301
  };
302
+ (0, config_1.saveConfig)(config);
303
+ settings.statusLine = { type: "command", command: statusLineCommand() };
304
+ uninstallPromptHook(settings); // 구버전 hook 방식 흔적 정리
305
+ if (!writeSettings(settings, mtimeMs))
306
+ continue; // 다른 프로세스와 충돌 → 재읽기
307
+ installSlashCommand();
308
+ // spinner verb는 로컬 캐시에 실제 feed가 있을 때만 채운다. 없으면 기본 spinner 유지.
309
+ armSpinnerMessage(config, (0, cache_1.loadFeedMessages)()[0] ?? null);
310
+ return { ok: true };
258
311
  }
259
- config.adapters.claude = {
260
- // 재설치 시 기존 백업(spinnerVerbsBackup 등)이 날아가지 않게 유지한다.
261
- ...config.adapters.claude,
262
- installedAt: new Date().toISOString(),
263
- settingsPath: SETTINGS_PATH,
264
- // 사용자의 기존 설정은 백업해 두고 uninstall 시 복원한다.
265
- ...(existing && !isOurs && { statusLineBackup: existing }),
312
+ return {
313
+ ok: false,
314
+ reason: "settings.json이 계속 변경되고 있어 설치하지 못했습니다. 잠시 후 다시 시도하세요.",
266
315
  };
267
- (0, config_1.saveConfig)(config);
268
- settings.statusLine = { type: "command", command: statusLineCommand() };
269
- uninstallPromptHook(settings); // 구버전 hook 방식 흔적 정리
270
- writeSettings(settings);
271
- installSlashCommand();
272
- // spinner verb는 로컬 캐시에 실제 feed가 있을 때만 채운다. 없으면 기본 spinner 유지.
273
- armSpinnerMessage(config, (0, cache_1.loadFeedMessages)()[0] ?? null);
274
- return { ok: true };
275
316
  }
276
317
  function uninstallClaudeIntegration(config) {
277
- const settings = readSettings();
278
- let changed = false;
279
- if (settings.statusLine?.command?.includes("pinglet")) {
280
- const backup = config.adapters.claude?.statusLineBackup;
281
- if (backup) {
282
- settings.statusLine = backup;
318
+ let settingsChanged = false;
319
+ for (let attempt = 0; attempt < 3; attempt++) {
320
+ const { settings, mtimeMs } = readSettingsWithMtime();
321
+ settingsChanged = false;
322
+ if (settings.statusLine?.command?.includes("pinglet")) {
323
+ const backup = config.adapters.claude?.statusLineBackup;
324
+ if (backup) {
325
+ settings.statusLine = backup;
326
+ }
327
+ else {
328
+ delete settings.statusLine;
329
+ }
330
+ settingsChanged = true;
283
331
  }
284
- else {
285
- delete settings.statusLine;
332
+ if (cleanupSpinnerTips(settings, config)) {
333
+ settingsChanged = true;
286
334
  }
287
- changed = true;
288
- }
289
- if (cleanupSpinnerTips(settings, config)) {
290
- changed = true;
291
- }
292
- if (uninstallPromptHook(settings)) {
293
- changed = true;
294
- }
295
- if (uninstallSlashCommand()) {
296
- changed = true;
297
- }
298
- const verbs = settings.spinnerVerbs;
299
- if (verbs?.verbs?.some(isPingletTip)) {
300
- const verbsBackup = config.adapters.claude?.spinnerVerbsBackup;
301
- const userVerbs = verbs.verbs.filter((verb) => !isPingletTip(verb));
302
- if (verbsBackup) {
303
- settings.spinnerVerbs = verbsBackup;
304
- }
305
- else if (userVerbs.length > 0) {
306
- settings.spinnerVerbs = { ...verbs, verbs: userVerbs };
335
+ if (uninstallPromptHook(settings)) {
336
+ settingsChanged = true;
307
337
  }
308
- else {
309
- delete settings.spinnerVerbs;
338
+ const verbs = settings.spinnerVerbs;
339
+ if (verbs?.verbs?.some(isPingletTip)) {
340
+ const verbsBackup = config.adapters.claude?.spinnerVerbsBackup;
341
+ const userVerbs = verbs.verbs.filter((verb) => !isPingletTip(verb));
342
+ if (verbsBackup) {
343
+ settings.spinnerVerbs = verbsBackup;
344
+ }
345
+ else if (userVerbs.length > 0) {
346
+ settings.spinnerVerbs = { ...verbs, verbs: userVerbs };
347
+ }
348
+ else {
349
+ delete settings.spinnerVerbs;
350
+ }
351
+ settingsChanged = true;
310
352
  }
311
- changed = true;
312
- }
313
- if (changed) {
314
- writeSettings(settings);
353
+ if (!settingsChanged)
354
+ break;
355
+ if (writeSettings(settings, mtimeMs))
356
+ break; // 충돌 시 재읽기 후 재적용
315
357
  }
358
+ const commandRemoved = uninstallSlashCommand();
316
359
  if (config.adapters.claude) {
317
360
  delete config.adapters.claude;
318
361
  (0, config_1.saveConfig)(config);
319
362
  }
320
- return changed;
363
+ return settingsChanged || commandRemoved;
321
364
  }
package/dist/api.js CHANGED
@@ -140,12 +140,12 @@ async function fetchMessageStats(config, messageId) {
140
140
  }
141
141
  /**
142
142
  * POST /messages — 메시지 작성 (분당 5개 제한).
143
- * 로그인했으면 유저 토큰으로(닉네임 표시), 아니면 설치 토큰으로 익명 작성한다.
143
+ * 작성은 GitHub 로그인이 필요하다 (읽기는 계속 익명).
144
144
  */
145
145
  async function createMessage(config, text, category) {
146
- const token = config.userToken ?? Object.values(config.installations)[0]?.token;
146
+ const token = config.userToken;
147
147
  if (!token)
148
- return { ok: false, error: "UNAUTHORIZED" };
148
+ return { ok: false, error: "LOGIN_REQUIRED" };
149
149
  try {
150
150
  const res = await fetch(config.apiBaseUrl + "/messages", {
151
151
  method: "POST",
@@ -174,10 +174,13 @@ async function createMessage(config, text, category) {
174
174
  return { ok: false, error: "NETWORK" };
175
175
  }
176
176
  }
177
- /** POST /installations/link — 로그인 후 기존 익명 설치를 계정에 연결한다. */
178
- async function linkInstallation(config, installationId) {
177
+ /**
178
+ * POST /installations/link — 로그인 후 기존 익명 설치를 계정에 연결한다.
179
+ * 소유 증명으로 installation "토큰"을 보낸다 (ID만으로는 연결 불가).
180
+ */
181
+ async function linkInstallation(config, installationToken) {
179
182
  if (!config.userToken)
180
183
  return false;
181
- const res = await request(config, "POST", "/installations/link", { token: config.userToken, body: { installationId } });
184
+ const res = await request(config, "POST", "/installations/link", { token: config.userToken, body: { installationToken } });
182
185
  return res?.linked === true;
183
186
  }
package/dist/cli.js CHANGED
@@ -19,8 +19,8 @@ const HELP = `pinglet v${config_1.VERSION} — AI가 생각하는 동안 만나
19
19
  install [--api <url>] [--force] Claude Code / Codex 자동 감지 후 integration 설치
20
20
  [--claude | --codex] 특정 agent만 설치
21
21
  uninstall [--purge] integration 제거 (--purge: ~/.pinglet까지 삭제)
22
- login [--token <jwt>] GitHub 계정 연결 (선택)
23
- post "메시지" [--category <c>] 메시지 작성 (설치만 해도 익명 작성 가능)
22
+ login [--token <jwt>] GitHub 계정 연결
23
+ post "메시지" [--category <c>] 메시지 작성 (GitHub 로그인 필요, 읽기는 익명 가능)
24
24
  doctor 설치/캐시/서버 상태 진단
25
25
  ping 메시지 미리보기
26
26
 
@@ -81,6 +81,6 @@ async function runDoctor() {
81
81
  if (config.userToken)
82
82
  ok("로그인됨");
83
83
  else
84
- warn("로그인 안 됨 — 익명 작성은 가능, 닉네임 표시/반응은 `pinglet login` 후");
84
+ warn("로그인 안 됨 — 읽기는 가능, 메시지 작성/반응은 `pinglet login` 후");
85
85
  console.log("");
86
86
  }
@@ -73,6 +73,6 @@ async function runInstall(args) {
73
73
  }
74
74
  console.log("\n설치 완료! 평소처럼 claude 또는 codex를 실행하면 Ping이 표시됩니다.");
75
75
  console.log(" 상태 확인: pinglet doctor");
76
- console.log(' 메시지 작성: pinglet post "메시지" (익명 — 바로 가능)');
77
- console.log(" 계정 연결: pinglet login (선택 — 닉네임 표시/반응에 필요)");
76
+ console.log(' 메시지 작성: pinglet login 후 pinglet post "메시지"');
77
+ console.log(" 계정 연결: pinglet login (메시지 작성/반응에 필요)");
78
78
  }
@@ -61,7 +61,8 @@ async function runLogin(args) {
61
61
  }
62
62
  if (!token) {
63
63
  const url = `${config.apiBaseUrl}/auth/github`;
64
- console.log("\n브라우저에서 GitHub 로그인 후, 응답 JSON의 token 값을 붙여넣어 주세요.");
64
+ console.log(`\n로그인하면 이용약관(${config.apiBaseUrl}/terms)과 개인정보처리방침(${config.apiBaseUrl}/privacy)에 동의하는 것으로 간주됩니다.`);
65
+ console.log("브라우저에서 GitHub 로그인 후, 응답 JSON의 token 값을 붙여넣어 주세요.");
65
66
  console.log(` ${url}\n`);
66
67
  if (process.platform === "darwin") {
67
68
  try {
@@ -82,7 +83,7 @@ async function runLogin(args) {
82
83
  (0, config_1.saveConfig)(config);
83
84
  console.log("✓ 로그인 정보 저장 완료");
84
85
  for (const [agentType, record] of Object.entries(config.installations)) {
85
- const linked = await (0, api_1.linkInstallation)(config, record.installationId);
86
+ const linked = await (0, api_1.linkInstallation)(config, record.token);
86
87
  console.log(linked
87
88
  ? `✓ ${agentType} installation을 계정에 연결했습니다`
88
89
  : `○ ${agentType} installation 연결 실패 — 서버 연결 후 다시 시도하세요`);
@@ -10,6 +10,7 @@ const REASON_LABELS = {
10
10
  URL_NOT_ALLOWED: "URL은 허용되지 않습니다",
11
11
  PII_DETECTED: "이메일/전화번호 등 개인정보가 포함되어 있습니다",
12
12
  BANNED_WORD: "정책상 검토가 필요한 표현이 포함되어 있습니다",
13
+ CONTROL_CHARS: "제어 문자(escape sequence)는 허용되지 않습니다",
13
14
  };
14
15
  /**
15
16
  * `pinglet post "메시지" [--category <카테고리>] [--quiet]`
@@ -40,8 +41,11 @@ async function runPost(args) {
40
41
  const result = await (0, api_1.createMessage)(config, text, category);
41
42
  if (!result.ok) {
42
43
  switch (result.error) {
44
+ case "LOGIN_REQUIRED":
45
+ console.log("✗ 메시지 작성에는 GitHub 로그인이 필요합니다. `pinglet login`을 실행하세요 (읽기는 로그인 없이 가능).");
46
+ break;
43
47
  case "UNAUTHORIZED":
44
- console.log("✗ 인증 정보가 없습니다. 먼저 `pinglet install`을 실행하세요 (로그인 없이도 익명 작성 가능).");
48
+ console.log("✗ 로그인이 만료됐습니다. `pinglet login`으로 다시 로그인하세요.");
45
49
  break;
46
50
  case "RATE_LIMITED":
47
51
  console.log("✗ 작성 제한(분당 5개)에 걸렸습니다. 잠시 후 다시 시도하세요.");
@@ -67,9 +71,6 @@ async function runPost(args) {
67
71
  console.log("✓ 메시지가 등록됐습니다. 곧 다른 개발자들의 터미널에 표시됩니다.");
68
72
  if (!quiet) {
69
73
  console.log(` id: ${result.id}`);
70
- if (!config.userToken) {
71
- console.log(" '익명의 개발자'로 표시됩니다 — 닉네임을 달려면 `pinglet login`");
72
- }
73
74
  }
74
75
  break;
75
76
  case "PENDING_REVIEW":
package/dist/render.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.supportsUnicode = supportsUnicode;
4
+ exports.sanitizeForTerminal = sanitizeForTerminal;
4
5
  exports.formatPing = formatPing;
5
6
  exports.formatMyReach = formatMyReach;
6
7
  /** 터미널 독립 원칙: unicode가 불확실하면 plain ASCII로 fallback (아키텍처 §5). */
@@ -10,18 +11,30 @@ function supportsUnicode() {
10
11
  const locale = process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || "";
11
12
  return /utf-?8/i.test(locale);
12
13
  }
14
+ /**
15
+ * 터미널에 그리기 전 최종 방어선: 제어·서식 문자를 제거한다.
16
+ * 서버 moderation이 이미 거르지만, 구버전 서버·손상된 캐시를 신뢰하지 않는다
17
+ * (ANSI escape가 살아 있으면 수신자 터미널을 조작할 수 있다).
18
+ */
19
+ function sanitizeForTerminal(text) {
20
+ return text
21
+ .replace(/\s+/g, " ")
22
+ .replace(/[\p{Cc}\p{Cf}]/gu, "")
23
+ .trim();
24
+ }
13
25
  /** 작성자 닉네임 없이 메시지만 표시한다 (spinner verb용). */
14
26
  function formatPing(message) {
15
27
  const sponsored = message.contentType === "SPONSORED" ? "[AD] " : "";
28
+ const text = sanitizeForTerminal(message.text);
16
29
  if (supportsUnicode()) {
17
- return `💌 ${sponsored}"${message.text}"`;
30
+ return `💌 ${sponsored}"${text}"`;
18
31
  }
19
- return `[ping] ${sponsored}"${message.text}"`;
32
+ return `[ping] ${sponsored}"${text}"`;
20
33
  }
21
34
  const REACH_TEXT_MAX = 24;
22
35
  /** statusline용: 내가 쓴 최근 메시지의 도달 현황 한 줄 (커버리지 = 도달/활성 설치). */
23
36
  function formatMyReach(stats) {
24
- let text = stats.text;
37
+ let text = sanitizeForTerminal(stats.text);
25
38
  if (text.length > REACH_TEXT_MAX)
26
39
  text = text.slice(0, REACH_TEXT_MAX - 1) + "…";
27
40
  const reached = stats.reachedInstallations;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinglet-cli",
3
- "version": "0.1.6",
3
+ "version": "0.2.1",
4
4
  "description": "AI가 생각하는 동안 다른 개발자의 Ping을 만나는 메시지 네트워크 — Claude Code / Codex integration",
5
5
  "license": "MIT",
6
6
  "repository": {