pinglet-cli 0.1.5 → 0.2.0

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
 
@@ -64,10 +64,12 @@ install 시 `~/.claude/commands/pinglet.md` slash command가 함께 설치되며
64
64
  `node <cli> statusline`을 연결한다. statusline은 UI 영역이라 LLM context에
65
65
  전혀 들어가지 않으며(Zero Token), Thinking/Working 중에 메시지가 보인다.
66
66
  기존 statusLine 설정은 백업 후 uninstall 시 복원한다.
67
- - **Spinner verbs** — `refresh` 배치가 feed를 받을 때 `spinnerVerbs`(mode:
68
- "replace")에도 메시지를 등록해, "Befuddling…" 같은 기본 verb 자리에 Ping이
69
- 그대로 표시된다. Pinglet이 쓴 verb는 zero-width space 마커로 식별하므로
70
- 사용자가 직접 등록한 verb는 건드리지 않고, uninstall 시 원래대로 복원한다.
67
+ - **Spinner verbs (pool=1 회전)** — `spinnerVerbs`(mode: "replace")에 메시지를
68
+ **한 번에 하나만** 등록해 "Befuddling…" 자리에 Ping이 표시된다. statusline이
69
+ 60초마다 다음 메시지로 교체하며, Claude Code가 설정을 핫리로드하므로 세션
70
+ 중에도 즉시 반영된다. pool이 1개라 "spinner가 돌았다 = 이 메시지가 표시됐다"가
71
+ 확정된다. Pinglet verb는 zero-width space 마커로 식별하므로 사용자가 직접
72
+ 등록한 verb는 건드리지 않고, uninstall 시 원래대로 복원한다.
71
73
  - **Codex Adapter (experimental)** — `~/.codex/config.toml`의 `notify` hook에 연결.
72
74
  notify는 TUI 밖 프로세스라 터미널 안에 그릴 수 없어, MVP에서는 turn 완료 시
73
75
  macOS 알림으로 전달한다.
@@ -79,9 +81,11 @@ install 시 `~/.claude/commands/pinglet.md` slash command가 함께 설치되며
79
81
  네트워크가 절대 들어가지 않는다.
80
82
  - **오프라인 동작** — 서버 미연결 시 시드 메시지로 동작하고, 등록/feed 갱신은
81
83
  백그라운드 `refresh`가 온라인이 되면 자동 재시도한다.
82
- - **DELIVERED / QUALIFIED_IMPRESSION 분리** — 메시지 표시 시 DELIVERED를 기록하고,
83
- 30초 회전 주기에 3초 이상 유지된 메시지만 QUALIFIED_IMPRESSION(visibleMs 포함)으로
84
- 정산한다. 세션이 끊긴 구간은 마지막 tick까지만 노출로 계산한다.
84
+ - **DELIVERED / QUALIFIED_IMPRESSION 분리** — 메시지가 spinner에 armed되면
85
+ DELIVERED(설치당 1회), armed된 동안 spinner가 3초 이상 실제로 돌았으면
86
+ QUALIFIED_IMPRESSION(visibleMs 포함)으로 정산한다. "실제로 돌았는지"는
87
+ statusline hook이 받는 `cost.total_api_duration_ms` 증가분으로 측정한다 —
88
+ pool이 1개이므로 API 활동 시간만큼 그 메시지가 표시된 것이 확정된다.
85
89
  - **Async Telemetry** — 이벤트는 `~/.pinglet/events.jsonl`(Local Event Queue)에
86
90
  append하고, 20개 이상 또는 60초 주기로 detached 프로세스가 batch 전송한다.
87
91
  전송 성공분만 큐에서 제거하므로 실패분은 자동 재시도되며, 재전송 중복은
@@ -34,7 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.detectClaude = detectClaude;
37
- exports.syncSpinnerVerbs = syncSpinnerVerbs;
37
+ exports.armSpinnerMessage = armSpinnerMessage;
38
38
  exports.spinnerVerbsStatus = spinnerVerbsStatus;
39
39
  exports.isClaudeIntegrationInstalled = isClaudeIntegrationInstalled;
40
40
  exports.installClaudeIntegration = installClaudeIntegration;
@@ -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);
@@ -184,52 +207,68 @@ function cleanupSpinnerTips(settings, config) {
184
207
  return true;
185
208
  }
186
209
  /**
187
- * Claude Code spinner verb("Befuddling…" 자리)를 feed 메시지로 교체한다.
188
- * mode: "replace"라 기본 verb 대신 Ping이 그대로 spinner 본문에 표시된다.
210
+ * Claude Code spinner verb("Befuddling…" 자리)에 메시지 **하나만** armed한다.
211
+ * pool을 1개로 유지해야 "spinner가 돌았다 = 이 메시지가 표시됐다"가 확정되고,
212
+ * cost 델타 기반 노출 측정(statusline)이 정확해진다. 설정은 핫리로드된다.
213
+ * message가 null이면 우리 verb를 걷어내고 기본 spinner로 되돌린다.
189
214
  * 마커가 붙은 항목만 Pinglet 소유로 보고 교체하며, 사용자 verb는 유지한다.
190
215
  */
191
- function syncSpinnerVerbs(config, messages) {
216
+ function armSpinnerMessage(config, message) {
192
217
  const adapter = config.adapters.claude;
193
218
  if (!adapter)
194
219
  return false;
195
- const settings = readSettings();
196
- cleanupSpinnerTips(settings, config);
197
- const existing = settings.spinnerVerbs;
198
- const existingVerbs = existing?.verbs ?? [];
199
- // 사용자가 원래 갖고 있던 spinnerVerbs는 최초 1회 백업해 두고 uninstall 시 복원한다.
200
- const isOurs = existingVerbs.some(isPingletTip);
201
- if (existing && !isOurs && adapter.spinnerVerbsBackup === undefined) {
202
- adapter.spinnerVerbsBackup = existing;
203
- (0, config_1.saveConfig)(config);
204
- }
205
- const userVerbs = existingVerbs.filter((verb) => !isPingletTip(verb));
206
- const pingletVerbs = buildPingletVerbs(messages);
207
- // 실제 feed 메시지가 없으면 spinner를 교체하지 않는다 —
208
- // 우리가 넣어둔 verb가 있으면 걷어내고 Claude Code 기본 spinner로 되돌린다.
209
- if (pingletVerbs.length === 0) {
210
- if (!isOurs)
211
- return false;
212
- const backup = adapter.spinnerVerbsBackup;
213
- if (backup) {
214
- settings.spinnerVerbs = backup;
215
- 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;
216
231
  (0, config_1.saveConfig)(config);
217
232
  }
218
- else if (userVerbs.length > 0) {
219
- 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;
220
258
  }
221
- else {
222
- 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;
223
265
  }
224
- writeSettings(settings);
225
- return false;
266
+ settings.spinnerVerbs = { mode: "replace", verbs: next };
267
+ if (writeSettings(settings, mtimeMs))
268
+ return true;
226
269
  }
227
- settings.spinnerVerbs = {
228
- mode: "replace",
229
- verbs: [...userVerbs, ...pingletVerbs],
230
- };
231
- writeSettings(settings);
232
- return true;
270
+ // 계속 충돌하면 이번 tick은 건너뛴다 — 다음 statusline tick에서 재시도된다.
271
+ return false;
233
272
  }
234
273
  /** doctor용: spinner verb로 등록된 Pinglet 메시지 개수. */
235
274
  function spinnerVerbsStatus() {
@@ -242,75 +281,84 @@ function isClaudeIntegrationInstalled() {
242
281
  return settings.statusLine?.command?.includes("pinglet") ?? false;
243
282
  }
244
283
  function installClaudeIntegration(config, options = {}) {
245
- const settings = readSettings();
246
- const existing = settings.statusLine;
247
- const isOurs = existing?.command?.includes("pinglet");
248
- if (existing && !isOurs && !options.force) {
249
- return {
250
- ok: false,
251
- 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 }),
252
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 };
253
311
  }
254
- config.adapters.claude = {
255
- // 재설치 시 기존 백업(spinnerVerbsBackup 등)이 날아가지 않게 유지한다.
256
- ...config.adapters.claude,
257
- installedAt: new Date().toISOString(),
258
- settingsPath: SETTINGS_PATH,
259
- // 사용자의 기존 설정은 백업해 두고 uninstall 시 복원한다.
260
- ...(existing && !isOurs && { statusLineBackup: existing }),
312
+ return {
313
+ ok: false,
314
+ reason: "settings.json이 계속 변경되고 있어 설치하지 못했습니다. 잠시 후 다시 시도하세요.",
261
315
  };
262
- (0, config_1.saveConfig)(config);
263
- settings.statusLine = { type: "command", command: statusLineCommand() };
264
- uninstallPromptHook(settings); // 구버전 hook 방식 흔적 정리
265
- writeSettings(settings);
266
- installSlashCommand();
267
- // spinner verb는 로컬 캐시에 실제 feed가 있을 때만 채운다. 없으면 기본 spinner 유지.
268
- syncSpinnerVerbs(config, (0, cache_1.loadFeedMessages)());
269
- return { ok: true };
270
316
  }
271
317
  function uninstallClaudeIntegration(config) {
272
- const settings = readSettings();
273
- let changed = false;
274
- if (settings.statusLine?.command?.includes("pinglet")) {
275
- const backup = config.adapters.claude?.statusLineBackup;
276
- if (backup) {
277
- 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;
278
331
  }
279
- else {
280
- delete settings.statusLine;
332
+ if (cleanupSpinnerTips(settings, config)) {
333
+ settingsChanged = true;
281
334
  }
282
- changed = true;
283
- }
284
- if (cleanupSpinnerTips(settings, config)) {
285
- changed = true;
286
- }
287
- if (uninstallPromptHook(settings)) {
288
- changed = true;
289
- }
290
- if (uninstallSlashCommand()) {
291
- changed = true;
292
- }
293
- const verbs = settings.spinnerVerbs;
294
- if (verbs?.verbs?.some(isPingletTip)) {
295
- const verbsBackup = config.adapters.claude?.spinnerVerbsBackup;
296
- const userVerbs = verbs.verbs.filter((verb) => !isPingletTip(verb));
297
- if (verbsBackup) {
298
- settings.spinnerVerbs = verbsBackup;
299
- }
300
- else if (userVerbs.length > 0) {
301
- settings.spinnerVerbs = { ...verbs, verbs: userVerbs };
335
+ if (uninstallPromptHook(settings)) {
336
+ settingsChanged = true;
302
337
  }
303
- else {
304
- 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;
305
352
  }
306
- changed = true;
307
- }
308
- if (changed) {
309
- writeSettings(settings);
353
+ if (!settingsChanged)
354
+ break;
355
+ if (writeSettings(settings, mtimeMs))
356
+ break; // 충돌 시 재읽기 후 재적용
310
357
  }
358
+ const commandRemoved = uninstallSlashCommand();
311
359
  if (config.adapters.claude) {
312
360
  delete config.adapters.claude;
313
361
  (0, config_1.saveConfig)(config);
314
362
  }
315
- return changed;
363
+ return settingsChanged || commandRemoved;
316
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
  }
@@ -65,7 +65,7 @@ async function runInstall(args) {
65
65
  (0, cache_1.saveFeedMessages)(messages);
66
66
  if (messages.length > 0) {
67
67
  console.log(`✓ Feed cached (${messages.length} messages)`);
68
- if ((0, claude_1.syncSpinnerVerbs)(config, messages)) {
68
+ if ((0, claude_1.armSpinnerMessage)(config, messages[0] ?? null)) {
69
69
  console.log("✓ Claude spinner synced");
70
70
  }
71
71
  }
@@ -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
  }
@@ -82,7 +82,7 @@ async function runLogin(args) {
82
82
  (0, config_1.saveConfig)(config);
83
83
  console.log("✓ 로그인 정보 저장 완료");
84
84
  for (const [agentType, record] of Object.entries(config.installations)) {
85
- const linked = await (0, api_1.linkInstallation)(config, record.installationId);
85
+ const linked = await (0, api_1.linkInstallation)(config, record.token);
86
86
  console.log(linked
87
87
  ? `✓ ${agentType} installation을 계정에 연결했습니다`
88
88
  : `○ ${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":
@@ -4,7 +4,6 @@ exports.runRefresh = runRefresh;
4
4
  const config_1 = require("../config");
5
5
  const api_1 = require("../api");
6
6
  const cache_1 = require("../cache");
7
- const queue_1 = require("../queue");
8
7
  const claude_1 = require("../adapters/claude");
9
8
  /**
10
9
  * 서버 feed를 미리 받아 Local Feed Cache를 갱신한다 (아키텍처 §6).
@@ -33,24 +32,14 @@ async function runRefresh() {
33
32
  const messages = await (0, api_1.fetchFeed)(config, records[0]);
34
33
  if (messages) {
35
34
  (0, cache_1.saveFeedMessages)(messages);
36
- // 새 feed를 Claude Code spinner verb에도 반영한다 (설정 파일만 다시 쓰면 됨).
37
- // feed가 비었으면 spinner를 기본 상태로 되돌린다.
38
- (0, claude_1.syncSpinnerVerbs)(config, messages);
39
- // spinner는 실제 노출 시점을 알 수 없으므로, spinner pool에 실린 시점을
40
- // "전달(DELIVERED)"로 기록한다. 메시지당 설치 1회만.
41
- if (config.adapters.claude && config.installations.CLAUDE) {
42
- const state = (0, cache_1.loadState)();
43
- state.delivered ??= {};
44
- let changed = false;
45
- for (const m of messages) {
46
- if (state.delivered[m.id])
47
- continue;
48
- (0, queue_1.appendEvent)({ agentType: "CLAUDE", type: "DELIVERED", messageId: m.id });
49
- state.delivered[m.id] = 1;
50
- changed = true;
51
- }
52
- if (changed)
53
- (0, cache_1.saveState)(state);
35
+ // spinner 회전과 DELIVERED/QUALIFIED 기록은 statusline이 담당한다 (pool=1).
36
+ // 여기서는 armed된 메시지가 서버에서 사라진 경우만 즉시 정리한다.
37
+ const state = (0, cache_1.loadState)();
38
+ const current = state.current;
39
+ if (current && !messages.some((m) => m.id === current.messageId)) {
40
+ (0, claude_1.armSpinnerMessage)(config, messages[0] ?? null);
41
+ state.current = undefined; // 다음 statusline tick이 정식으로 다음 메시지를 armed한다
42
+ (0, cache_1.saveState)(state);
54
43
  }
55
44
  }
56
45
  // 이 기기에서 작성한 최근 메시지의 도달 통계를 갱신한다 (statusline 표시용).
@@ -1,20 +1,136 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.runStatusline = runStatusline;
37
+ const fs = __importStar(require("fs"));
38
+ const config_1 = require("../config");
4
39
  const cache_1 = require("../cache");
5
40
  const render_1 = require("../render");
41
+ const picker_1 = require("../picker");
42
+ const queue_1 = require("../queue");
43
+ const claude_1 = require("../adapters/claude");
6
44
  const runtime_1 = require("../runtime");
45
+ function readPayload() {
46
+ try {
47
+ return JSON.parse(fs.readFileSync(0, "utf8"));
48
+ }
49
+ catch {
50
+ return {};
51
+ }
52
+ }
53
+ /**
54
+ * cost.total_api_duration_ms 증가분 = 이 tick 사이에 spinner가 실제로 돈 시간.
55
+ * spinner pool이 1개이므로 그 시간만큼 armed 메시지가 화면에 표시된 것이 확정된다.
56
+ */
57
+ function trackSpinnerActivity(state, payload) {
58
+ const sessionId = payload.session_id;
59
+ const apiMs = payload.cost?.total_api_duration_ms;
60
+ if (!sessionId || typeof apiMs !== "number")
61
+ return;
62
+ state.sessions ??= {};
63
+ const prev = state.sessions[sessionId];
64
+ state.sessions[sessionId] = apiMs;
65
+ // 오래된 세션 기록이 무한히 쌓이지 않게 상한을 둔다.
66
+ const ids = Object.keys(state.sessions);
67
+ if (ids.length > 20)
68
+ delete state.sessions[ids[0]];
69
+ if (prev === undefined || apiMs <= prev || !state.current)
70
+ return;
71
+ state.current.visibleMs = Math.min((state.current.visibleMs ?? 0) + (apiMs - prev), runtime_1.ROTATE_MS);
72
+ }
73
+ /**
74
+ * 이전 armed 메시지의 노출을 정산하고 다음 메시지를 armed한다.
75
+ * QUALIFIED_MS 이상 spinner가 돌았으면 유효 노출(visibleMs 포함)로 기록한다.
76
+ */
77
+ function rotateSpinner(state, now) {
78
+ const config = (0, config_1.loadConfig)();
79
+ const prev = state.current;
80
+ if (prev && (prev.visibleMs ?? 0) >= runtime_1.QUALIFIED_MS) {
81
+ (0, queue_1.appendEvent)({
82
+ agentType: "CLAUDE",
83
+ type: "QUALIFIED_IMPRESSION",
84
+ messageId: prev.messageId,
85
+ visibleMs: prev.visibleMs,
86
+ });
87
+ }
88
+ const message = (0, picker_1.pickMessage)((0, cache_1.loadFeedMessages)(), state);
89
+ if (!message) {
90
+ (0, claude_1.armSpinnerMessage)(config, null);
91
+ state.current = undefined;
92
+ return;
93
+ }
94
+ (0, claude_1.armSpinnerMessage)(config, message);
95
+ state.seen[message.id] = (state.seen[message.id] ?? 0) + 1;
96
+ state.delivered ??= {};
97
+ if (!state.delivered[message.id]) {
98
+ (0, queue_1.appendEvent)({
99
+ agentType: "CLAUDE",
100
+ type: "DELIVERED",
101
+ messageId: message.id,
102
+ });
103
+ state.delivered[message.id] = 1;
104
+ }
105
+ state.current = {
106
+ messageId: message.id,
107
+ text: message.text,
108
+ author: message.author,
109
+ shownAt: now,
110
+ visibleMs: 0,
111
+ };
112
+ }
7
113
  /**
8
114
  * Claude Code statusLine hook 진입점.
9
- * Ping 메시지는 spinner(spinnerVerbs)가 담당하고, statusline에는
10
- * 이 기기에서 작성한 최근 메시지의 도달 현황을 보여준다.
11
- * flush/refresh 백그라운드 트리거도 이 경로에서 돈다 (Claude 사용 중 항상 호출되므로).
115
+ * - spinner 회전(pool=1): ROTATE_MS마다 다음 메시지를 armed (설정 핫리로드로 즉시 반영)
116
+ * - 노출 측정: stdin payload의 cost 델타로 armed 메시지의 실제 표시 시간을 누적
117
+ * - statusline 표시: 이 기기에서 작성한 최근 메시지의 도달 현황
118
+ * - flush/refresh 백그라운드 트리거 (Claude 사용 중 항상 호출되므로 이 경로에서)
12
119
  */
13
120
  function runStatusline() {
121
+ const payload = readPayload();
14
122
  const state = (0, cache_1.loadState)();
15
123
  const now = Date.now();
16
- // 300ms마다 호출되므로 상태 기록/maintenance는 1초 스로틀.
17
- if (state.lastTickAt === undefined || now - state.lastTickAt > 1_000) {
124
+ trackSpinnerActivity(state, payload);
125
+ const rotateDue = !state.current || now - state.current.shownAt >= runtime_1.ROTATE_MS;
126
+ // 300ms마다 호출되므로 저장/maintenance는 1초 스로틀. 회전 시점에는 항상 저장.
127
+ const saveDue = rotateDue ||
128
+ state.lastTickAt === undefined ||
129
+ now - state.lastTickAt > 1_000;
130
+ if (rotateDue) {
131
+ rotateSpinner(state, now);
132
+ }
133
+ if (saveDue) {
18
134
  (0, runtime_1.runMaintenance)(state, now);
19
135
  state.lastTickAt = now;
20
136
  (0, cache_1.saveState)(state);
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/dist/runtime.js CHANGED
@@ -1,11 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.FEED_TTL_MS = exports.FLUSH_COUNT = exports.FLUSH_INTERVAL_MS = void 0;
3
+ exports.FEED_TTL_MS = exports.FLUSH_COUNT = exports.FLUSH_INTERVAL_MS = exports.QUALIFIED_MS = exports.ROTATE_MS = void 0;
4
4
  exports.runMaintenance = runMaintenance;
5
5
  const child_process_1 = require("child_process");
6
6
  const config_1 = require("./config");
7
7
  const cache_1 = require("./cache");
8
8
  const queue_1 = require("./queue");
9
+ exports.ROTATE_MS = 60_000; // spinner 메시지 교체 주기 (설정 핫리로드로 즉시 반영)
10
+ exports.QUALIFIED_MS = 3_000; // 유효 노출로 인정하는 최소 spinner 가동 시간
9
11
  exports.FLUSH_INTERVAL_MS = 60_000; // batch 전송 주기
10
12
  exports.FLUSH_COUNT = 20; // 또는 이벤트 개수 기준
11
13
  exports.FEED_TTL_MS = 5 * 60_000; // feed 캐시 갱신 주기
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinglet-cli",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "AI가 생각하는 동안 다른 개발자의 Ping을 만나는 메시지 네트워크 — Claude Code / Codex integration",
5
5
  "license": "MIT",
6
6
  "repository": {