pinglet-cli 0.2.11 → 0.3.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.ja.md CHANGED
@@ -47,12 +47,15 @@ pinglet post "メッセージ"
47
47
  ```
48
48
 
49
49
  読むだけならログイン不要で、**投稿にのみ GitHub ログインが必要です。**
50
- 自分の Ping がどれだけ届いたかは statusline ですぐ確認できます:
50
+
51
+ いま何個のターミナルが一緒に起動しているかが statusline に表示されます:
51
52
 
52
53
  ```
53
- 💌 自分の Ping "今日もビルドが緑でありますように" → 到達率 17%
54
+ 🟢 いま41個のターミナルと一緒にコーディング中
54
55
  ```
55
56
 
57
+ statusline の言語は位置(システムのタイムゾーン)に応じて自動選択されます — 韓国では韓国語、日本では日本語、それ以外は英語。
58
+
56
59
  ## コマンド
57
60
 
58
61
  | コマンド | 説明 |
package/README.ko.md CHANGED
@@ -47,12 +47,15 @@ pinglet post "메시지"
47
47
  ```
48
48
 
49
49
  읽기는 로그인 없이 가능하고, **작성에만 GitHub 로그인이 필요합니다.**
50
- 내가 보낸 Ping이 얼마나 퍼졌는지는 statusline에서 바로 확인됩니다:
50
+
51
+ 지금 몇 개의 터미널이 함께 켜져 있는지는 statusline에서 보여줍니다:
51
52
 
52
53
  ```
53
- 💌 내 Ping "오늘도 빌드가 초록불이길" → 도달률 17%
54
+ 🟢 지금 41개 터미널과 함께 코딩 중
54
55
  ```
55
56
 
57
+ statusline 언어는 위치(시스템 타임존)에 따라 자동 선택됩니다 — 한국은 한국어, 일본은 일본어, 그 외는 영어.
58
+
56
59
  ## 명령어
57
60
 
58
61
  | 명령 | 설명 |
package/README.md CHANGED
@@ -47,12 +47,15 @@ pinglet post "message"
47
47
  ```
48
48
 
49
49
  Reading requires no login; **only posting requires a GitHub login.**
50
- You can see how far your Ping has traveled right in the statusline:
50
+
51
+ The statusline shows how many terminals are coding alongside you right now:
51
52
 
52
53
  ```
53
- 💌 My Ping "may your build stay green today" → reach 17%
54
+ 🟢 coding along with 41 terminals right now
54
55
  ```
55
56
 
57
+ The statusline picks its language from your location (system timezone) — Korean in Korea, Japanese in Japan, English everywhere else.
58
+
56
59
  ## Commands
57
60
 
58
61
  | Command | Description |
@@ -180,9 +180,25 @@ function writeSettings(settings, expectedMtimeMs) {
180
180
  if (settingsMtimeMs() !== expectedMtimeMs)
181
181
  return false;
182
182
  // settings.json은 Claude Code가 수시로 읽는 파일이라 tmp+rename으로 원자적으로 쓴다.
183
- const tmp = SETTINGS_PATH + ".pinglet-tmp";
184
- fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
185
- fs.renameSync(tmp, SETTINGS_PATH);
183
+ // tmp 이름에 pid를 붙여 프로세스마다 다르게 만든다 — 여러 세션의 statusline이
184
+ // 동시에 돌면 같은 tmp에 겹쳐 쓰게 되고, writeFileSync는 truncate 후 쓰기라
185
+ // 짧은 내용 뒤에 긴 내용의 꼬리가 남은 채로 rename 되어 settings.json이 깨진다.
186
+ // rename 자체는 tmp 이름과 무관하게 원자적이라 이것만으로 충분하다.
187
+ const tmp = `${SETTINGS_PATH}.pinglet-tmp.${process.pid}`;
188
+ try {
189
+ fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
190
+ fs.renameSync(tmp, SETTINGS_PATH);
191
+ }
192
+ catch (error) {
193
+ // rename 전에 실패하면 tmp가 남는다 — 이름이 pid별로 달라 방치하면 쌓인다.
194
+ try {
195
+ fs.unlinkSync(tmp);
196
+ }
197
+ catch {
198
+ // 이미 없으면 무시
199
+ }
200
+ throw error;
201
+ }
186
202
  return true;
187
203
  }
188
204
  function isPingletTip(tip) {
package/dist/api.js CHANGED
@@ -39,7 +39,6 @@ exports.registerInstallation = registerInstallation;
39
39
  exports.fetchFeed = fetchFeed;
40
40
  exports.uploadEventBatch = uploadEventBatch;
41
41
  exports.sendHeartbeat = sendHeartbeat;
42
- exports.fetchMessageStats = fetchMessageStats;
43
42
  exports.createMessage = createMessage;
44
43
  exports.linkInstallation = linkInstallation;
45
44
  const os = __importStar(require("os"));
@@ -123,20 +122,15 @@ async function uploadEventBatch(config, record, events) {
123
122
  });
124
123
  return res !== null;
125
124
  }
125
+ /** POST /installations/heartbeat — lastSeenAt 갱신. 응답의 온라인 설치 수를 돌려준다(구서버는 null). */
126
126
  async function sendHeartbeat(config, record) {
127
- await request(config, "POST", "/installations/heartbeat", {
127
+ const res = await request(config, "POST", "/installations/heartbeat", {
128
128
  token: record.token,
129
129
  body: { clientVersion: config_1.VERSION },
130
130
  });
131
- }
132
- /** GET /messages/:id/stats — 내가 쓴 메시지의 도달 통계 (익명 작성자는 설치 토큰으로). */
133
- async function fetchMessageStats(config, messageId) {
134
- const token = config.userToken ?? Object.values(config.installations)[0]?.token;
135
- if (!token)
136
- return null;
137
- return request(config, "GET", `/messages/${messageId}/stats`, {
138
- token,
139
- });
131
+ return typeof res?.onlineInstallations === "number"
132
+ ? res.onlineInstallations
133
+ : null;
140
134
  }
141
135
  /**
142
136
  * POST /messages — 메시지 작성 (분당 5개 제한).
package/dist/cache.js CHANGED
@@ -6,8 +6,8 @@ exports.feedAgeMs = feedAgeMs;
6
6
  exports.loadState = loadState;
7
7
  exports.loadMyPosts = loadMyPosts;
8
8
  exports.saveMyPost = saveMyPost;
9
- exports.loadMyStats = loadMyStats;
10
- exports.saveMyStats = saveMyStats;
9
+ exports.loadOnline = loadOnline;
10
+ exports.saveOnline = saveOnline;
11
11
  exports.saveState = saveState;
12
12
  const config_1 = require("./config");
13
13
  /**
@@ -49,11 +49,15 @@ function saveMyPost(post) {
49
49
  const posts = [post, ...loadMyPosts()].slice(0, 20);
50
50
  (0, config_1.writeJsonFile)(config_1.POSTS_PATH, posts);
51
51
  }
52
- function loadMyStats() {
53
- return (0, config_1.readJsonFile)(config_1.MYSTATS_PATH);
52
+ function loadOnline() {
53
+ return (0, config_1.readJsonFile)(config_1.ONLINE_PATH);
54
54
  }
55
- function saveMyStats(stats) {
56
- (0, config_1.writeJsonFile)(config_1.MYSTATS_PATH, stats);
55
+ function saveOnline(count) {
56
+ const cache = {
57
+ onlineInstallations: count,
58
+ updatedAt: new Date().toISOString(),
59
+ };
60
+ (0, config_1.writeJsonFile)(config_1.ONLINE_PATH, cache);
57
61
  }
58
62
  function saveState(state) {
59
63
  // seen/delivered map이 무한히 자라지 않게 상한을 둔다.
@@ -65,7 +65,7 @@ async function runPost(args) {
65
65
  process.exitCode = 1;
66
66
  return;
67
67
  }
68
- // statusline 도달 표시용으로 이 기기에서 쓴 메시지를 기록해 둔다.
68
+ // 이 기기에서 쓴 메시지를 기록해 둔다.
69
69
  if (result.id && result.status !== "REJECTED") {
70
70
  (0, cache_1.saveMyPost)({ id: result.id, text, postedAt: new Date().toISOString() });
71
71
  }
@@ -28,7 +28,11 @@ async function runRefresh() {
28
28
  const records = Object.values(config.installations);
29
29
  if (records.length === 0)
30
30
  return;
31
- await Promise.all(records.map((r) => (0, api_1.sendHeartbeat)(config, r)));
31
+ const onlineCounts = await Promise.all(records.map((r) => (0, api_1.sendHeartbeat)(config, r)));
32
+ const online = onlineCounts.find((n) => n !== null);
33
+ if (online !== undefined) {
34
+ (0, cache_1.saveOnline)(online);
35
+ }
32
36
  const messages = await (0, api_1.fetchFeed)(config, records[0]);
33
37
  if (messages) {
34
38
  (0, cache_1.saveFeedMessages)(messages);
@@ -42,21 +46,4 @@ async function runRefresh() {
42
46
  (0, cache_1.saveState)(state);
43
47
  }
44
48
  }
45
- // 이 기기에서 작성한 최근 메시지의 도달 통계를 갱신한다 (statusline 표시용).
46
- const latest = (0, cache_1.loadMyPosts)()[0];
47
- if (latest) {
48
- const stats = await (0, api_1.fetchMessageStats)(config, latest.id);
49
- if (stats) {
50
- (0, cache_1.saveMyStats)({
51
- messageId: latest.id,
52
- text: latest.text,
53
- reachedInstallations: stats.reachedInstallations,
54
- activeInstallations: stats.activeInstallations,
55
- delivered: stats.delivered,
56
- qualifiedImpressions: stats.qualifiedImpressions,
57
- reactions: stats.reactions,
58
- updatedAt: new Date().toISOString(),
59
- });
60
- }
61
- }
62
49
  }
@@ -42,6 +42,22 @@ const picker_1 = require("../picker");
42
42
  const queue_1 = require("../queue");
43
43
  const claude_1 = require("../adapters/claude");
44
44
  const runtime_1 = require("../runtime");
45
+ /**
46
+ * online 캐시 유효 기간 — refresh(≈5분) 3주기. 오프라인이 되면
47
+ * 오래된 숫자를 "지금"이라고 보여주는 대신 표시를 접는다.
48
+ */
49
+ const ONLINE_STALE_MS = 15 * 60_000;
50
+ /** 신선한 online 캐시가 있을 때, 나를 제외한 켜진 터미널 수 (없거나 혼자면 null). */
51
+ function onlineOthers(now) {
52
+ const cache = (0, cache_1.loadOnline)();
53
+ if (!cache)
54
+ return null;
55
+ const t = Date.parse(cache.updatedAt);
56
+ if (Number.isNaN(t) || now - t > ONLINE_STALE_MS)
57
+ return null;
58
+ const others = cache.onlineInstallations - 1; // 이 기기의 heartbeat 제외
59
+ return others >= 1 ? others : null;
60
+ }
45
61
  function readPayload() {
46
62
  try {
47
63
  return JSON.parse(fs.readFileSync(0, "utf8"));
@@ -114,7 +130,7 @@ function rotateSpinner(state, now) {
114
130
  * Claude Code statusLine hook 진입점.
115
131
  * - spinner 회전(pool=1): ROTATE_MS마다 다음 메시지를 armed (설정 핫리로드로 즉시 반영)
116
132
  * - 노출 측정: stdin payload의 cost 델타로 armed 메시지의 실제 표시 시간을 누적
117
- * - statusline 표시: 이 기기에서 작성한 최근 메시지의 도달 현황
133
+ * - statusline 표시: 지금 함께 켜져 있는 터미널 수
118
134
  * - flush/refresh 백그라운드 트리거 (Claude 사용 중 항상 호출되므로 이 경로에서)
119
135
  */
120
136
  function runStatusline() {
@@ -135,6 +151,6 @@ function runStatusline() {
135
151
  state.lastTickAt = now;
136
152
  (0, cache_1.saveState)(state);
137
153
  }
138
- const stats = (0, cache_1.loadMyStats)();
139
- process.stdout.write(stats ? (0, render_1.formatMyReach)(stats) : "");
154
+ const others = onlineOthers(now);
155
+ process.stdout.write(others !== null ? (0, render_1.formatOnlineNow)(others) : "");
140
156
  }
package/dist/config.js CHANGED
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.DEFAULT_API_BASE_URL = exports.MYSTATS_PATH = exports.POSTS_PATH = exports.EVENTS_PATH = exports.STATE_PATH = exports.FEED_PATH = exports.CONFIG_PATH = exports.PINGLET_DIR = exports.VERSION = void 0;
36
+ exports.DEFAULT_API_BASE_URL = exports.ONLINE_PATH = exports.POSTS_PATH = exports.EVENTS_PATH = exports.STATE_PATH = exports.FEED_PATH = exports.CONFIG_PATH = exports.PINGLET_DIR = exports.VERSION = void 0;
37
37
  exports.ensurePingletDir = ensurePingletDir;
38
38
  exports.readJsonFile = readJsonFile;
39
39
  exports.writeJsonFile = writeJsonFile;
@@ -58,10 +58,10 @@ exports.CONFIG_PATH = path.join(exports.PINGLET_DIR, "config.json");
58
58
  exports.FEED_PATH = path.join(exports.PINGLET_DIR, "feed.json");
59
59
  exports.STATE_PATH = path.join(exports.PINGLET_DIR, "state.json");
60
60
  exports.EVENTS_PATH = path.join(exports.PINGLET_DIR, "events.jsonl");
61
- /** 이 기기에서 작성한 메시지 기록 (statusline 도달 표시용) */
61
+ /** 이 기기에서 작성한 메시지 기록 */
62
62
  exports.POSTS_PATH = path.join(exports.PINGLET_DIR, "posts.json");
63
- /** 최근 작성 메시지의 도달 통계 캐시 — refresh가 갱신, statusline이 읽는다 */
64
- exports.MYSTATS_PATH = path.join(exports.PINGLET_DIR, "mystats.json");
63
+ /** 지금 켜져 있는 터미널 수 캐시 — heartbeat 응답으로 갱신, statusline이 읽는다 */
64
+ exports.ONLINE_PATH = path.join(exports.PINGLET_DIR, "online.json");
65
65
  exports.DEFAULT_API_BASE_URL = process.env.PINGLET_API_URL ?? "https://pinglet.halluci.co.kr";
66
66
  function ensurePingletDir() {
67
67
  fs.mkdirSync(exports.PINGLET_DIR, { recursive: true });
package/dist/render.js CHANGED
@@ -1,9 +1,38 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectLocale = detectLocale;
3
4
  exports.supportsUnicode = supportsUnicode;
4
5
  exports.sanitizeForTerminal = sanitizeForTerminal;
5
6
  exports.formatPing = formatPing;
6
- exports.formatMyReach = formatMyReach;
7
+ exports.formatOnlineNow = formatOnlineNow;
8
+ /**
9
+ * statusline 표시 언어: 사용자 "위치" 기준 — 한국이면 한국어, 일본이면 일본어,
10
+ * 그 외 영어. 네트워크 없이 판정해야 하므로(오프라인 동작 원칙) 시스템
11
+ * 타임존을 위치의 근사로 쓰고, 타임존을 못 읽으면 시스템 언어로 추정한다.
12
+ */
13
+ function detectLocale() {
14
+ try {
15
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? "";
16
+ if (tz === "Asia/Seoul")
17
+ return "ko";
18
+ if (tz === "Asia/Tokyo")
19
+ return "ja";
20
+ if (tz)
21
+ return "en";
22
+ }
23
+ catch {
24
+ // Intl이 없는 환경 — 아래 언어 폴백으로.
25
+ }
26
+ const lang = (process.env.LC_ALL ||
27
+ process.env.LC_MESSAGES ||
28
+ process.env.LANG ||
29
+ "").toLowerCase();
30
+ if (lang.startsWith("ko"))
31
+ return "ko";
32
+ if (lang.startsWith("ja"))
33
+ return "ja";
34
+ return "en";
35
+ }
7
36
  /** 터미널 독립 원칙: unicode가 불확실하면 plain ASCII로 fallback (아키텍처 §5). */
8
37
  function supportsUnicode() {
9
38
  if (process.platform === "win32")
@@ -31,23 +60,19 @@ function formatPing(message) {
31
60
  }
32
61
  return `[ping] ${sponsored}"${text}"`;
33
62
  }
34
- const REACH_TEXT_MAX = 24;
35
- /** statusline용: 내가 쓴 최근 메시지의 도달 현황 한 줄 (커버리지 = 도달/활성 설치). */
36
- function formatMyReach(stats) {
37
- let text = sanitizeForTerminal(stats.text);
38
- if (text.length > REACH_TEXT_MAX)
39
- text = text.slice(0, REACH_TEXT_MAX - 1) + "…";
40
- const reached = stats.reachedInstallations;
41
- const active = stats.activeInstallations ?? 0;
42
- // 활성 설치 수를 모르면(구서버/구캐시) 도달 수만 표시한다.
43
- const coverage = active > 0
44
- ? `도달률 ${Math.round((reached / active) * 100)}%`
45
- : `터미널 ${reached}곳 도달`;
46
- if (supportsUnicode()) {
47
- return `💌 내 Ping "${text}" → ${coverage}`;
63
+ /** statusline용: 지금 함께 켜져 있는 다른 터미널 수 한 줄. */
64
+ function formatOnlineNow(othersCount) {
65
+ const n = othersCount;
66
+ // 한국어·일본어는 unicode 전제 — 못 그리는 터미널이면 ASCII 영어로.
67
+ if (!supportsUnicode()) {
68
+ return `[ping] coding along with ${n} ${n === 1 ? "terminal" : "terminals"} now`;
69
+ }
70
+ switch (detectLocale()) {
71
+ case "ko":
72
+ return `🟢 지금 ${n}개 터미널과 함께 코딩 중`;
73
+ case "ja":
74
+ return `🟢 いま${n}個のターミナルと一緒にコーディング中`;
75
+ default:
76
+ return `🟢 coding along with ${n} ${n === 1 ? "terminal" : "terminals"} right now`;
48
77
  }
49
- const asciiCoverage = active > 0
50
- ? `reach ${Math.round((reached / active) * 100)}%`
51
- : `reached ${reached} terminals`;
52
- return `[ping] "${text}" -> ${asciiCoverage}`;
53
78
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinglet-cli",
3
- "version": "0.2.11",
3
+ "version": "0.3.0",
4
4
  "description": "Short messages from other developers shown in your Claude Code spinner while the AI thinks — zero tokens, never reads your code. Claude Code / Codex integration",
5
5
  "license": "MIT",
6
6
  "repository": {