pinglet-cli 0.1.4 → 0.1.6

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
@@ -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;
@@ -184,11 +184,13 @@ function cleanupSpinnerTips(settings, config) {
184
184
  return true;
185
185
  }
186
186
  /**
187
- * Claude Code spinner verb("Befuddling…" 자리)를 feed 메시지로 교체한다.
188
- * mode: "replace"라 기본 verb 대신 Ping이 그대로 spinner 본문에 표시된다.
187
+ * Claude Code spinner verb("Befuddling…" 자리)에 메시지 **하나만** armed한다.
188
+ * pool을 1개로 유지해야 "spinner가 돌았다 = 이 메시지가 표시됐다"가 확정되고,
189
+ * cost 델타 기반 노출 측정(statusline)이 정확해진다. 설정은 핫리로드된다.
190
+ * message가 null이면 우리 verb를 걷어내고 기본 spinner로 되돌린다.
189
191
  * 마커가 붙은 항목만 Pinglet 소유로 보고 교체하며, 사용자 verb는 유지한다.
190
192
  */
191
- function syncSpinnerVerbs(config, messages) {
193
+ function armSpinnerMessage(config, message) {
192
194
  const adapter = config.adapters.claude;
193
195
  if (!adapter)
194
196
  return false;
@@ -203,9 +205,8 @@ function syncSpinnerVerbs(config, messages) {
203
205
  (0, config_1.saveConfig)(config);
204
206
  }
205
207
  const userVerbs = existingVerbs.filter((verb) => !isPingletTip(verb));
206
- const pingletVerbs = buildPingletVerbs(messages);
207
- // 실제 feed 메시지가 없으면 spinner를 교체하지 않는다 —
208
- // 우리가 넣어둔 verb가 있으면 걷어내고 Claude Code 기본 spinner로 되돌린다.
208
+ const pingletVerbs = message ? buildPingletVerbs([message]) : [];
209
+ // armed할 메시지가 없으면 spinner를 기본 상태로 되돌린다.
209
210
  if (pingletVerbs.length === 0) {
210
211
  if (!isOurs)
211
212
  return false;
@@ -224,10 +225,14 @@ function syncSpinnerVerbs(config, messages) {
224
225
  writeSettings(settings);
225
226
  return false;
226
227
  }
227
- settings.spinnerVerbs = {
228
- mode: "replace",
229
- verbs: [...userVerbs, ...pingletVerbs],
230
- };
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;
234
+ }
235
+ settings.spinnerVerbs = { mode: "replace", verbs: next };
231
236
  writeSettings(settings);
232
237
  return true;
233
238
  }
@@ -265,7 +270,7 @@ function installClaudeIntegration(config, options = {}) {
265
270
  writeSettings(settings);
266
271
  installSlashCommand();
267
272
  // spinner verb는 로컬 캐시에 실제 feed가 있을 때만 채운다. 없으면 기본 spinner 유지.
268
- syncSpinnerVerbs(config, (0, cache_1.loadFeedMessages)());
273
+ armSpinnerMessage(config, (0, cache_1.loadFeedMessages)()[0] ?? null);
269
274
  return { ok: true };
270
275
  }
271
276
  function uninstallClaudeIntegration(config) {
@@ -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
  }
@@ -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/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.4",
3
+ "version": "0.1.6",
4
4
  "description": "AI가 생각하는 동안 다른 개발자의 Ping을 만나는 메시지 네트워크 — Claude Code / Codex integration",
5
5
  "license": "MIT",
6
6
  "repository": {