pinglet-cli 0.6.0 → 0.6.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/dist/api.js CHANGED
@@ -38,6 +38,7 @@ exports.checkHealth = checkHealth;
38
38
  exports.registerInstallation = registerInstallation;
39
39
  exports.fetchFeed = fetchFeed;
40
40
  exports.uploadEventBatch = uploadEventBatch;
41
+ exports.uploadEventBatchResult = uploadEventBatchResult;
41
42
  exports.sendHeartbeat = sendHeartbeat;
42
43
  exports.createMessage = createMessage;
43
44
  exports.validateUserToken = validateUserToken;
@@ -110,20 +111,30 @@ async function fetchFeed(config, record, limit = 50) {
110
111
  }
111
112
  /** POST /events/batch — eventId 기반으로 서버가 dedupe하므로 재전송에 안전하다. */
112
113
  async function uploadEventBatch(config, record, events) {
113
- const res = await request(config, "POST", "/events/batch", {
114
- token: record.token,
115
- body: {
116
- batchId: "batch_" + (0, crypto_1.randomUUID)(),
117
- events: events.map(({ eventId, type, messageId, visibleMs, occurredAt }) => ({
118
- eventId,
119
- type,
120
- messageId,
121
- ...(visibleMs !== undefined && { visibleMs }),
122
- occurredAt,
123
- })),
124
- },
125
- });
126
- return res !== null;
114
+ return (await uploadEventBatchResult(config, record, events)).ok;
115
+ }
116
+ async function uploadEventBatchResult(config, record, events) {
117
+ try {
118
+ const res = await fetch(config.apiBaseUrl + "/events/batch", {
119
+ method: "POST",
120
+ headers: { "content-type": "application/json", authorization: `Bearer ${record.token}` },
121
+ signal: AbortSignal.timeout(TIMEOUT_MS),
122
+ body: JSON.stringify({
123
+ batchId: "batch_" + (0, crypto_1.randomUUID)(),
124
+ events: events.map(({ eventId, type, messageId, visibleMs, occurredAt }) => ({
125
+ eventId,
126
+ type,
127
+ messageId,
128
+ ...(visibleMs !== undefined && { visibleMs }),
129
+ occurredAt,
130
+ })),
131
+ }),
132
+ });
133
+ return { ok: res.ok, permanent: [400, 413, 422].includes(res.status), status: res.status };
134
+ }
135
+ catch {
136
+ return { ok: false, permanent: false };
137
+ }
127
138
  }
128
139
  /** POST /installations/heartbeat — lastSeenAt 갱신. 응답의 온라인 설치 수를 돌려준다(구서버는 null). */
129
140
  async function sendHeartbeat(config, record) {
package/dist/cache.js CHANGED
@@ -43,12 +43,17 @@ function feedAgeMs() {
43
43
  return Number.isNaN(t) ? null : Date.now() - t;
44
44
  }
45
45
  function loadState() {
46
- const state = (0, config_1.readJsonFile)(config_1.STATE_PATH);
47
- if (state) {
48
- state.seen ??= {};
49
- return state;
46
+ const state = (0, config_1.readJsonFile)(config_1.STATE_PATH) ?? { seen: {} };
47
+ const installationId = (0, config_1.readJsonFile)(config_1.CONFIG_PATH)?.installations?.CLAUDE?.installationId;
48
+ if (state.claudeInstallationId && state.claudeInstallationId !== installationId) {
49
+ state.seen = {};
50
+ state.delivered = {};
51
+ state.current = undefined;
52
+ state.sessions = undefined;
50
53
  }
51
- return { seen: {} };
54
+ state.claudeInstallationId = installationId;
55
+ state.seen ??= {};
56
+ return state;
52
57
  }
53
58
  function loadMyPosts() {
54
59
  return (0, config_1.readJsonFile)(config_1.POSTS_PATH) ?? [];
package/dist/cli.js CHANGED
@@ -8,13 +8,14 @@ const doctor_1 = require("./commands/doctor");
8
8
  const login_1 = require("./commands/login");
9
9
  const logout_1 = require("./commands/logout");
10
10
  const statusline_1 = require("./commands/statusline");
11
- const notify_1 = require("./commands/notify");
12
11
  const flush_1 = require("./commands/flush");
13
12
  const refresh_1 = require("./commands/refresh");
14
13
  const ping_1 = require("./commands/ping");
15
14
  const post_1 = require("./commands/post");
16
15
  const i18n_1 = require("./i18n");
17
16
  const lock_1 = require("./lock");
17
+ const notifications_1 = require("./notifications");
18
+ const runtime_1 = require("./runtime");
18
19
  const HELP = (0, i18n_1.t)("cli.help", { version: config_1.VERSION });
19
20
  async function main() {
20
21
  const [command, ...rest] = process.argv.slice(2);
@@ -43,9 +44,6 @@ async function main() {
43
44
  case "statusline":
44
45
  (0, statusline_1.runStatusline)();
45
46
  break;
46
- case "notify":
47
- (0, notify_1.runNotify)(rest);
48
- break;
49
47
  case "flush":
50
48
  await (0, flush_1.runFlush)();
51
49
  break;
@@ -73,12 +71,32 @@ async function main() {
73
71
  }
74
72
  }
75
73
  }
76
- (0, lock_1.withPingletLock)(main, {
77
- skipIfBusy: ["statusline", "notify", "refresh", "flush"].includes(process.argv[2]),
78
- }).catch((error) => {
74
+ async function dispatch() {
75
+ const command = process.argv[2];
76
+ if (command === "notify") {
77
+ if ((0, notifications_1.enqueueNotification)(process.argv.slice(3)))
78
+ (0, runtime_1.spawnDetachedCommand)(["notify-drain"]);
79
+ return;
80
+ }
81
+ if (command === "notify-drain" || command === "statusline") {
82
+ await (0, lock_1.withPingletLock)(() => {
83
+ (0, notifications_1.drainNotifications)();
84
+ if (command === "statusline")
85
+ (0, statusline_1.runStatusline)();
86
+ }, { skipIfBusy: command === "statusline" });
87
+ return;
88
+ }
89
+ if (["--version", "-v", "help", "--help"].includes(command)) {
90
+ await main();
91
+ return;
92
+ }
93
+ await (0, lock_1.withCommandLock)(main);
94
+ await (0, lock_1.withPingletLock)(notifications_1.drainNotifications);
95
+ }
96
+ dispatch().catch((error) => {
79
97
  // statusline/notify는 조용히 실패해야 Claude/Codex 경험을 해치지 않는다.
80
98
  const command = process.argv[2];
81
- if (command === "statusline" || command === "notify") {
99
+ if (command === "statusline" || command === "notify" || command === "notify-drain") {
82
100
  process.exit(0);
83
101
  }
84
102
  console.error("pinglet:", error instanceof Error ? error.message : error);
@@ -38,18 +38,49 @@ const config_1 = require("../config");
38
38
  const fs = __importStar(require("fs"));
39
39
  const api_1 = require("../api");
40
40
  const queue_1 = require("../queue");
41
+ const lock_1 = require("../lock");
41
42
  /**
42
43
  * Local Event Queue를 batch로 서버에 전송한다 (아키텍처 §8).
43
44
  * 전송 성공한 이벤트만 큐에서 제거하므로 실패분은 다음 주기에 재시도되고,
44
45
  * 재전송 중복은 서버가 eventId로 dedupe한다.
45
46
  */
46
47
  async function runFlush() {
47
- if (!fs.existsSync(config_1.CONFIG_PATH))
48
- return;
49
- const config = (0, config_1.loadConfig)();
50
- const events = (0, queue_1.readEvents)();
51
- if (events.length === 0)
48
+ const snapshot = await (0, lock_1.withPingletLock)(() => {
49
+ if (!fs.existsSync(config_1.CONFIG_PATH))
50
+ return;
51
+ const config = (0, config_1.loadConfig)();
52
+ const events = (0, queue_1.bindEvents)();
53
+ const valid = [];
54
+ for (const event of events) {
55
+ const reason = (0, queue_1.invalidEventReason)(event);
56
+ const record = config.installations[event.agentType];
57
+ if (reason)
58
+ (0, queue_1.quarantineEvents)([event], reason);
59
+ else if (event.installationId && event.installationId !== record?.installationId)
60
+ (0, queue_1.quarantineEvents)([event], "retired-installation");
61
+ else
62
+ valid.push(event);
63
+ }
64
+ return { config, events: valid };
65
+ });
66
+ if (!snapshot)
52
67
  return;
68
+ const { config, events } = snapshot;
69
+ const send = async (record, chunk) => {
70
+ const result = await (0, api_1.uploadEventBatchResult)(config, record, chunk);
71
+ if (result.ok) {
72
+ await (0, lock_1.withPingletLock)(() => (0, queue_1.removeEvents)(new Set(chunk.map(event => event.eventId))));
73
+ return true;
74
+ }
75
+ if (!result.permanent)
76
+ return false;
77
+ if (chunk.length === 1) {
78
+ await (0, lock_1.withPingletLock)(() => (0, queue_1.quarantineEvents)(chunk, `http-${result.status}`));
79
+ return true;
80
+ }
81
+ const middle = Math.floor(chunk.length / 2);
82
+ return await send(record, chunk.slice(0, middle)) && await send(record, chunk.slice(middle));
83
+ };
53
84
  for (const agentType of ["CLAUDE", "CODEX"]) {
54
85
  const record = config.installations[agentType];
55
86
  const group = events.filter((e) => e.agentType === agentType);
@@ -59,10 +90,8 @@ async function runFlush() {
59
90
  continue; // 미등록 상태 — refresh가 등록을 재시도한다.
60
91
  for (let i = 0; i < group.length; i += api_1.MAX_BATCH_SIZE) {
61
92
  const chunk = group.slice(i, i + api_1.MAX_BATCH_SIZE);
62
- const ok = await (0, api_1.uploadEventBatch)(config, record, chunk);
63
- if (!ok)
93
+ if (!await send(record, chunk))
64
94
  break;
65
- (0, queue_1.removeEvents)(new Set(chunk.map((e) => e.eventId)));
66
95
  }
67
96
  }
68
97
  }
@@ -41,6 +41,7 @@ const cache_1 = require("../cache");
41
41
  const claude_1 = require("../adapters/claude");
42
42
  const codex_1 = require("../adapters/codex");
43
43
  const i18n_1 = require("../i18n");
44
+ const lock_1 = require("../lock");
44
45
  /**
45
46
  * 기존 설정 교체 확인. Enter 또는 y면 진행(기본 Yes — 설치 의도가 명확하므로).
46
47
  * non-TTY(postinstall, CI)에서는 묻지 않고 false — 사용자 설정을 말없이
@@ -72,7 +73,11 @@ async function runInstall(args) {
72
73
  const apiIndex = args.indexOf("--api");
73
74
  if (apiIndex !== -1 && args[apiIndex + 1]) {
74
75
  config.apiBaseUrl = args[apiIndex + 1].replace(/\/$/, "");
75
- (0, config_1.saveConfig)(config);
76
+ await (0, lock_1.withPingletLock)(() => {
77
+ const current = (0, config_1.loadConfig)();
78
+ current.apiBaseUrl = config.apiBaseUrl;
79
+ (0, config_1.saveConfig)(current);
80
+ });
76
81
  }
77
82
  console.log("");
78
83
  const hasClaude = wantClaude && (0, claude_1.detectClaude)();
@@ -99,17 +104,21 @@ async function runInstall(args) {
99
104
  return;
100
105
  const record = await (0, api_1.registerInstallation)(config, agentType);
101
106
  if (record) {
107
+ await (0, lock_1.withPingletLock)(() => {
108
+ const current = (0, config_1.loadConfig)();
109
+ current.installations[agentType] = record;
110
+ (0, config_1.saveConfig)(current);
111
+ });
102
112
  config.installations[agentType] = record;
103
- (0, config_1.saveConfig)(config);
104
113
  }
105
114
  };
106
115
  if (hasClaude) {
107
116
  await register("CLAUDE");
108
- let result = (0, claude_1.installClaudeIntegration)(config, { force });
117
+ let result = (await (0, lock_1.withPingletLock)(() => (0, claude_1.installClaudeIntegration)((0, config_1.loadConfig)(), { force })));
109
118
  if (!result.ok && result.needsForce) {
110
119
  const yes = await confirmReplace((0, i18n_1.t)("install.askReplaceStatusLine"));
111
120
  if (yes)
112
- result = (0, claude_1.installClaudeIntegration)(config, { force: true });
121
+ result = (await (0, lock_1.withPingletLock)(() => (0, claude_1.installClaudeIntegration)((0, config_1.loadConfig)(), { force: true })));
113
122
  }
114
123
  console.log(result.ok
115
124
  ? "✓ Claude integration installed"
@@ -117,11 +126,11 @@ async function runInstall(args) {
117
126
  }
118
127
  if (hasCodex) {
119
128
  await register("CODEX");
120
- let result = (0, codex_1.installCodexIntegration)(config, { force });
129
+ let result = (await (0, lock_1.withPingletLock)(() => (0, codex_1.installCodexIntegration)((0, config_1.loadConfig)(), { force })));
121
130
  if (!result.ok && result.needsForce) {
122
131
  const yes = await confirmReplace((0, i18n_1.t)("install.askReplaceNotify"));
123
132
  if (yes)
124
- result = (0, codex_1.installCodexIntegration)(config, { force: true });
133
+ result = (await (0, lock_1.withPingletLock)(() => (0, codex_1.installCodexIntegration)((0, config_1.loadConfig)(), { force: true })));
125
134
  }
126
135
  console.log(result.ok
127
136
  ? "✓ Codex integration installed (experimental)"
@@ -132,10 +141,10 @@ async function runInstall(args) {
132
141
  if (online && record) {
133
142
  const messages = await (0, api_1.fetchFeed)(config, record);
134
143
  if (messages) {
135
- (0, cache_1.saveFeedMessages)(messages);
144
+ await (0, lock_1.withPingletLock)(() => (0, cache_1.saveFeedMessages)(messages));
136
145
  if (messages.length > 0) {
137
146
  console.log(`✓ Feed cached (${messages.length} messages)`);
138
- if ((0, claude_1.armSpinnerMessage)(config, messages[0] ?? null)) {
147
+ if (await (0, lock_1.withPingletLock)(() => (0, claude_1.armSpinnerMessage)((0, config_1.loadConfig)(), messages[0] ?? null))) {
139
148
  console.log("✓ Claude spinner synced");
140
149
  }
141
150
  }
@@ -40,6 +40,7 @@ const readline = __importStar(require("readline"));
40
40
  const config_1 = require("../config");
41
41
  const api_1 = require("../api");
42
42
  const i18n_1 = require("../i18n");
43
+ const lock_1 = require("../lock");
43
44
  const LOGIN_TIMEOUT_MS = 180_000;
44
45
  function prompt(question) {
45
46
  const rl = readline.createInterface({
@@ -188,8 +189,13 @@ async function runLogin(args) {
188
189
  throw new Error((0, i18n_1.t)("logout.failed"));
189
190
  }
190
191
  }
192
+ await (0, lock_1.withPingletLock)(() => {
193
+ // Hooks may have added adapter restoration metadata while OAuth was open.
194
+ const current = (0, config_1.loadConfig)();
195
+ current.userToken = token;
196
+ (0, config_1.saveConfig)(current);
197
+ });
191
198
  config.userToken = token;
192
- (0, config_1.saveConfig)(config);
193
199
  console.log((0, i18n_1.t)("login.saved"));
194
200
  for (const [agentType, record] of Object.entries(config.installations)) {
195
201
  const linked = await (0, api_1.linkInstallation)(config, record.token);
@@ -4,6 +4,7 @@ exports.runLogout = runLogout;
4
4
  const config_1 = require("../config");
5
5
  const i18n_1 = require("../i18n");
6
6
  const api_1 = require("../api");
7
+ const lock_1 = require("../lock");
7
8
  /**
8
9
  * Revoke the user session and unlink this device's installations before
9
10
  * removing local credentials. On failure preserve credentials for retry.
@@ -22,7 +23,10 @@ async function runLogout() {
22
23
  if (!await (0, api_1.releaseCredential)(config, "user", config.userToken)) {
23
24
  throw new Error((0, i18n_1.t)("logout.failed"));
24
25
  }
25
- delete config.userToken;
26
- (0, config_1.saveConfig)(config);
26
+ await (0, lock_1.withPingletLock)(() => {
27
+ const current = (0, config_1.loadConfig)();
28
+ delete current.userToken;
29
+ (0, config_1.saveConfig)(current);
30
+ });
27
31
  console.log((0, i18n_1.t)("logout.done"));
28
32
  }
@@ -46,7 +46,7 @@ const config_1 = require("../config");
46
46
  * Codex는 turn 완료 시 마지막 인자로 notification JSON을 넘긴다.
47
47
  * notify 프로세스는 TUI 밖에서 돌기 때문에 MVP에서는 macOS 알림으로 Ping을 전달한다.
48
48
  */
49
- function runNotify(args) {
49
+ function runNotify(args, eventId) {
50
50
  if (!fs.existsSync(config_1.CONFIG_PATH) || !(0, config_1.loadConfig)().adapters.codex)
51
51
  return;
52
52
  let notificationType;
@@ -68,7 +68,7 @@ function runNotify(args) {
68
68
  (0, cache_1.saveState)(state);
69
69
  return;
70
70
  }
71
- (0, queue_1.appendEvent)({ agentType: "CODEX", type: "DELIVERED", messageId: message.id });
71
+ (0, queue_1.appendEvent)({ eventId, agentType: "CODEX", type: "DELIVERED", messageId: message.id });
72
72
  state.seen[message.id] = (state.seen[message.id] ?? 0) + 1;
73
73
  (0, runtime_1.runMaintenance)(state, now);
74
74
  (0, cache_1.saveState)(state);
@@ -43,7 +43,9 @@ async function runPost(args) {
43
43
  const config = (0, config_1.loadConfig)();
44
44
  const requestId = (0, pending_post_1.pendingPostId)(config, text, category);
45
45
  const result = await (0, api_1.createMessage)(config, text, category, requestId);
46
- if (result.ok || (result.error !== "NETWORK" && result.error !== "SERVER"))
46
+ // Only a definitive response resolves an earlier ambiguous commit. In
47
+ // particular 401/429 say nothing about whether the previous request committed.
48
+ if (result.ok)
47
49
  (0, pending_post_1.completePost)(requestId);
48
50
  // 슬래시 명령 안에서는 같은 방식의 슬래시 명령을 안내한다.
49
51
  const loginCmd = quiet ? "/pinglet-login" : "pinglet login";
@@ -40,6 +40,7 @@ const api_1 = require("../api");
40
40
  const update_1 = require("../update");
41
41
  const cache_1 = require("../cache");
42
42
  const claude_1 = require("../adapters/claude");
43
+ const lock_1 = require("../lock");
43
44
  /**
44
45
  * 서버 feed를 미리 받아 Local Feed Cache를 갱신한다 (아키텍처 §6).
45
46
  * 설치 당시 오프라인이었다면 installation 등록도 여기서 재시도한다.
@@ -57,8 +58,12 @@ async function runRefresh() {
57
58
  if (!config.installations[agentType]) {
58
59
  const record = await (0, api_1.registerInstallation)(config, agentType);
59
60
  if (record) {
61
+ await (0, lock_1.withPingletLock)(() => {
62
+ const current = (0, config_1.loadConfig)();
63
+ current.installations[agentType] = record;
64
+ (0, config_1.saveConfig)(current);
65
+ });
60
66
  config.installations[agentType] = record;
61
- (0, config_1.saveConfig)(config);
62
67
  }
63
68
  }
64
69
  }
@@ -68,20 +73,22 @@ async function runRefresh() {
68
73
  const onlineCounts = await Promise.all(records.map((r) => (0, api_1.sendHeartbeat)(config, r)));
69
74
  const online = onlineCounts.find((n) => n !== null);
70
75
  if (online !== undefined) {
71
- (0, cache_1.saveOnline)(online);
76
+ await (0, lock_1.withPingletLock)(() => (0, cache_1.saveOnline)(online));
72
77
  }
73
78
  const messages = await (0, api_1.fetchFeed)(config, records[0]);
74
79
  if (messages) {
75
- (0, cache_1.saveFeedMessages)(messages);
76
- // spinner 회전과 DELIVERED/QUALIFIED 기록은 statusline이 담당한다 (pool=1).
77
- // 여기서는 armed된 메시지가 서버에서 사라진 경우만 즉시 정리한다.
78
- const state = (0, cache_1.loadState)();
79
- const current = state.current;
80
- if (current && !messages.some((m) => m.id === current.messageId)) {
81
- (0, claude_1.armSpinnerMessage)(config, messages[0] ?? null);
82
- state.current = undefined; // 다음 statusline tick이 정식으로 다음 메시지를 armed한다
83
- (0, cache_1.saveState)(state);
84
- }
80
+ await (0, lock_1.withPingletLock)(() => {
81
+ (0, cache_1.saveFeedMessages)(messages);
82
+ // spinner 회전과 DELIVERED/QUALIFIED 기록은 statusline이 담당한다 (pool=1).
83
+ // 여기서는 armed된 메시지가 서버에서 사라진 경우만 즉시 정리한다.
84
+ const state = (0, cache_1.loadState)();
85
+ const current = state.current;
86
+ if (current && !messages.some((m) => m.id === current.messageId)) {
87
+ (0, claude_1.armSpinnerMessage)((0, config_1.loadConfig)(), messages[0] ?? null);
88
+ state.current = undefined; // 다음 statusline tick이 정식으로 다음 메시지를 armed한다
89
+ (0, cache_1.saveState)(state);
90
+ }
91
+ });
85
92
  }
86
93
  // 하루 1회 새 버전 확인 후 npm 전역 설치면 백그라운드 자동 업데이트.
87
94
  await (0, update_1.maybeSelfUpdate)(config);
@@ -101,6 +101,7 @@ function rotateSpinner(state, now) {
101
101
  messageId: prev.messageId,
102
102
  visibleMs: prev.visibleMs,
103
103
  });
104
+ prev.visibleMs = 0; // A failed replacement must not account this interval twice.
104
105
  }
105
106
  const message = (0, picker_1.pickMessage)((0, cache_1.loadFeedMessages)(), state);
106
107
  if (!message) {
@@ -108,7 +109,12 @@ function rotateSpinner(state, now) {
108
109
  state.current = undefined;
109
110
  return;
110
111
  }
111
- (0, claude_1.armSpinnerMessage)(config, message);
112
+ if (!(0, claude_1.armSpinnerMessage)(config, message)) {
113
+ // No delivery/current transition until the settings write actually commits.
114
+ // Clear attribution of subsequent API time and retry on the next tick.
115
+ state.current = undefined;
116
+ return;
117
+ }
112
118
  state.seen[message.id] = (state.seen[message.id] ?? 0) + 1;
113
119
  state.delivered ??= {};
114
120
  if (!state.delivered[message.id]) {
@@ -40,32 +40,58 @@ const claude_1 = require("../adapters/claude");
40
40
  const codex_1 = require("../adapters/codex");
41
41
  const i18n_1 = require("../i18n");
42
42
  const api_1 = require("../api");
43
+ const lock_1 = require("../lock");
44
+ const queue_1 = require("../queue");
45
+ const cache_1 = require("../cache");
46
+ const flush_1 = require("./flush");
43
47
  async function runUninstall(args) {
44
- const config = (0, config_1.loadConfig)();
45
- const claudeRemoved = (0, claude_1.uninstallClaudeIntegration)(config);
46
- const codexRemoved = (0, codex_1.uninstallCodexIntegration)(config);
48
+ const restored = await (0, lock_1.withPingletLock)(() => {
49
+ const config = (0, config_1.loadConfig)();
50
+ (0, queue_1.bindEvents)();
51
+ const claudeRemoved = (0, claude_1.uninstallClaudeIntegration)(config);
52
+ const codexRemoved = (0, codex_1.uninstallCodexIntegration)(config);
53
+ (0, cache_1.saveState)({ seen: {} });
54
+ return { config, claudeRemoved, codexRemoved };
55
+ });
56
+ if (!restored)
57
+ return;
58
+ const { config, claudeRemoved, codexRemoved } = restored;
47
59
  console.log(claudeRemoved
48
60
  ? "✓ Claude integration removed"
49
61
  : "○ Claude integration not installed");
50
62
  console.log(codexRemoved
51
63
  ? "✓ Codex integration removed"
52
64
  : "○ Codex integration not installed");
65
+ await (0, flush_1.runFlush)();
53
66
  for (const [agent, record] of Object.entries(config.installations)) {
54
67
  if (!await (0, api_1.releaseCredential)(config, "installation", record.token)) {
55
68
  throw new Error((0, i18n_1.t)("uninstall.failed"));
56
69
  }
57
- delete config.installations[agent];
58
- (0, config_1.saveConfig)(config);
70
+ await (0, lock_1.withPingletLock)(() => {
71
+ const pending = (0, queue_1.readEvents)().filter(event => event.agentType === agent);
72
+ (0, queue_1.quarantineEvents)(pending, "uninstalled-before-upload");
73
+ if (pending.length && !args.includes("--purge"))
74
+ console.log(`○ ${pending.length} unsent events archived in events-quarantine.jsonl (not reassigned on reinstall)`);
75
+ delete config.installations[agent];
76
+ (0, config_1.saveConfig)(config);
77
+ });
59
78
  }
79
+ // Also retire offline events produced before registration ever succeeded.
80
+ await (0, lock_1.withPingletLock)(() => {
81
+ const pending = (0, queue_1.readEvents)();
82
+ (0, queue_1.quarantineEvents)(pending, "uninstalled-without-registration");
83
+ if (pending.length && !args.includes("--purge"))
84
+ console.log(`○ ${pending.length} unregistered events archived in events-quarantine.jsonl`);
85
+ });
60
86
  if (config.userToken) {
61
87
  if (!await (0, api_1.releaseCredential)(config, "user", config.userToken)) {
62
88
  throw new Error((0, i18n_1.t)("uninstall.failed"));
63
89
  }
64
90
  delete config.userToken;
65
- (0, config_1.saveConfig)(config);
91
+ await (0, lock_1.withPingletLock)(() => (0, config_1.saveConfig)(config));
66
92
  }
67
93
  if (args.includes("--purge")) {
68
- fs.rmSync(config_1.PINGLET_DIR, { recursive: true, force: true });
94
+ await (0, lock_1.withPingletLock)(() => fs.rmSync(config_1.PINGLET_DIR, { recursive: true, force: true }));
69
95
  console.log("✓ ~/.pinglet removed (config / cache / event queue)");
70
96
  }
71
97
  else {
package/dist/lock.js CHANGED
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.withPingletLock = withPingletLock;
37
+ exports.withCommandLock = withCommandLock;
37
38
  const fs = __importStar(require("fs"));
38
39
  const os = __importStar(require("os"));
39
40
  const path = __importStar(require("path"));
@@ -41,10 +42,9 @@ const crypto_1 = require("crypto");
41
42
  const storage_1 = require("./storage");
42
43
  // Outside ~/.pinglet so purge cannot remove a lock held by another command.
43
44
  const LOCK_PATH = path.join(os.homedir(), ".pinglet.lock");
44
- const RECOVERY_PATH = LOCK_PATH + ".recovery";
45
- function owner() {
45
+ function owner(lockPath) {
46
46
  try {
47
- return fs.readFileSync(LOCK_PATH, "utf8");
47
+ return fs.readFileSync(lockPath, "utf8");
48
48
  }
49
49
  catch (error) {
50
50
  if ((0, storage_1.isMissing)(error))
@@ -52,11 +52,11 @@ function owner() {
52
52
  throw error;
53
53
  }
54
54
  }
55
- function abandoned(value) {
56
- const pid = Number(value.split(":")[0]);
55
+ function abandoned(value, lockPath) {
56
+ const pid = Number(value.split(/[:-]/)[0]);
57
57
  if (!Number.isInteger(pid) || pid <= 0) {
58
58
  // The creating process may not have written its PID yet.
59
- return Date.now() - fs.statSync(LOCK_PATH).mtimeMs > 30_000;
59
+ return Date.now() - fs.statSync(lockPath).mtimeMs > 30_000;
60
60
  }
61
61
  try {
62
62
  process.kill(pid, 0);
@@ -66,46 +66,74 @@ function abandoned(value) {
66
66
  return error.code === "ESRCH";
67
67
  }
68
68
  }
69
- function recover() {
69
+ function recover(lockPath) {
70
+ const recoveryPath = lockPath + ".recovery";
71
+ const identity = `${process.pid}-${(0, crypto_1.randomUUID)()}`;
70
72
  // Serialize reapers and re-read ownership: a second reaper must never unlink
71
73
  // a fresh lock acquired after the first reaper removed the abandoned one.
72
74
  try {
73
- fs.mkdirSync(RECOVERY_PATH, { mode: 0o700 });
75
+ fs.mkdirSync(recoveryPath, { mode: 0o700 });
74
76
  }
75
77
  catch (error) {
76
- if (error.code === "EEXIST")
78
+ if (error.code === "EEXIST") {
79
+ // A killed reaper must not block recovery forever. Unique owner filenames
80
+ // ensure only the process that removed the old owner may remove its dir.
81
+ try {
82
+ const entries = fs.readdirSync(recoveryPath);
83
+ if (entries.length === 1 && abandoned(entries[0], recoveryPath)) {
84
+ fs.unlinkSync(path.join(recoveryPath, entries[0]));
85
+ fs.rmdirSync(recoveryPath);
86
+ }
87
+ else if (entries.length === 0 && Date.now() - fs.statSync(recoveryPath).mtimeMs > 30_000) {
88
+ // Legacy/partially-created recovery directory; allow initialization grace.
89
+ fs.rmdirSync(recoveryPath);
90
+ }
91
+ }
92
+ catch (cleanupError) {
93
+ if (!(0, storage_1.isMissing)(cleanupError) && !["ENOTEMPTY", "EEXIST"].includes(cleanupError.code ?? ""))
94
+ throw cleanupError;
95
+ }
77
96
  return;
97
+ }
78
98
  throw error;
79
99
  }
100
+ const ownerPath = path.join(recoveryPath, identity);
80
101
  try {
81
- const value = owner();
82
- if (value !== null && abandoned(value))
83
- fs.unlinkSync(LOCK_PATH);
102
+ fs.writeFileSync(ownerPath, "", { flag: "wx", mode: 0o600 });
103
+ const value = owner(lockPath);
104
+ if (value !== null && abandoned(value, lockPath))
105
+ fs.unlinkSync(lockPath);
84
106
  }
85
107
  catch (error) {
86
108
  if (!(0, storage_1.isMissing)(error))
87
109
  throw error;
88
110
  }
89
111
  finally {
90
- fs.rmdirSync(RECOVERY_PATH);
112
+ try {
113
+ fs.unlinkSync(ownerPath);
114
+ fs.rmdirSync(recoveryPath);
115
+ }
116
+ catch (error) {
117
+ if (!(0, storage_1.isMissing)(error))
118
+ throw error;
119
+ }
91
120
  }
92
121
  }
93
- /** Serialize the entire read/modify/write command, including asynchronous work.
94
- * Hooks skip a busy tick instead of blocking Claude/Codex rendering.
95
- */
122
+ /** Short local read/modify/write transactions. Never hold this across network I/O. */
96
123
  async function withPingletLock(action, options = {}) {
124
+ const lockPath = options.command ? LOCK_PATH + ".command" : LOCK_PATH;
97
125
  const identity = `${process.pid}:${(0, crypto_1.randomUUID)()}`;
98
126
  const deadline = Date.now() + (options.timeoutMs ?? 15_000);
99
127
  for (;;) {
100
128
  let fd;
101
129
  try {
102
- fd = fs.openSync(LOCK_PATH, "wx", 0o600);
130
+ fd = fs.openSync(lockPath, "wx", 0o600);
103
131
  }
104
132
  catch (error) {
105
133
  if (error.code !== "EEXIST")
106
134
  throw error;
107
- recover();
108
- if (owner() === null)
135
+ recover(lockPath);
136
+ if (owner(lockPath) === null)
109
137
  continue;
110
138
  if (options.skipIfBusy)
111
139
  return undefined;
@@ -118,7 +146,7 @@ async function withPingletLock(action, options = {}) {
118
146
  fs.writeFileSync(fd, identity);
119
147
  }
120
148
  catch (error) {
121
- fs.unlinkSync(LOCK_PATH);
149
+ fs.unlinkSync(lockPath);
122
150
  throw error;
123
151
  }
124
152
  finally {
@@ -130,7 +158,11 @@ async function withPingletLock(action, options = {}) {
130
158
  return await action();
131
159
  }
132
160
  finally {
133
- if (owner() === identity)
134
- fs.unlinkSync(LOCK_PATH);
161
+ if (owner(lockPath) === identity)
162
+ fs.unlinkSync(lockPath);
135
163
  }
136
164
  }
165
+ /** Serializes administrative/network commands without excluding local hooks. */
166
+ function withCommandLock(action) {
167
+ return withPingletLock(action, { command: true });
168
+ }
@@ -0,0 +1,98 @@
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
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.enqueueNotification = enqueueNotification;
37
+ exports.drainNotifications = drainNotifications;
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const crypto_1 = require("crypto");
41
+ const config_1 = require("./config");
42
+ const notify_1 = require("./commands/notify");
43
+ const storage_1 = require("./storage");
44
+ const PREFIX = "notify-";
45
+ /** Unique files allow enqueueing without waiting for the state lock. Store only
46
+ * the event type, never the Codex payload (which can contain user/code content).
47
+ * Do not mkdir: a concurrent purge must not be undone by a stale hook.
48
+ */
49
+ function enqueueNotification(args) {
50
+ if (!fs.existsSync(config_1.CONFIG_PATH))
51
+ return false;
52
+ const config = (0, config_1.loadConfig)();
53
+ const adapter = config.adapters.codex;
54
+ if (!adapter)
55
+ return false;
56
+ let type = "agent-turn-complete";
57
+ try {
58
+ type = JSON.parse(args[args.length - 1] ?? "")?.type ?? type;
59
+ }
60
+ catch { /* manual invocation */ }
61
+ if (type !== "agent-turn-complete")
62
+ return false;
63
+ const file = path.join(config_1.PINGLET_DIR, `${PREFIX}${(0, crypto_1.randomUUID)()}.json`);
64
+ const temp = file + ".tmp";
65
+ try {
66
+ fs.writeFileSync(temp, JSON.stringify({ type, installedAt: adapter.installedAt, apiBaseUrl: config.apiBaseUrl }), { flag: "wx", mode: 0o600 });
67
+ fs.renameSync(temp, file);
68
+ return true;
69
+ }
70
+ catch (error) {
71
+ if (!(0, storage_1.isMissing)(error))
72
+ throw error;
73
+ return false;
74
+ }
75
+ finally {
76
+ try {
77
+ fs.unlinkSync(temp);
78
+ }
79
+ catch (error) {
80
+ if (!(0, storage_1.isMissing)(error))
81
+ throw error;
82
+ }
83
+ }
84
+ }
85
+ /** Caller holds the short state lock. Failed work stays on disk for retry. */
86
+ function drainNotifications() {
87
+ if (!fs.existsSync(config_1.CONFIG_PATH))
88
+ return;
89
+ const config = (0, config_1.loadConfig)();
90
+ for (const name of fs.readdirSync(config_1.PINGLET_DIR).filter(name => name.startsWith(PREFIX) && name.endsWith(".json"))) {
91
+ const file = path.join(config_1.PINGLET_DIR, name);
92
+ const event = JSON.parse(fs.readFileSync(file, "utf8"));
93
+ if (config.adapters.codex?.installedAt === event.installedAt && config.apiBaseUrl === event.apiBaseUrl) {
94
+ (0, notify_1.runNotify)([JSON.stringify({ type: event.type })], "evt_" + name.slice(PREFIX.length, -5));
95
+ }
96
+ fs.unlinkSync(file); // Stale installation notifications must not survive reinstall.
97
+ }
98
+ }
@@ -43,11 +43,26 @@ const PENDING_PATH = path.join(config_1.PINGLET_DIR, "pending-posts.json");
43
43
  * Only hashes are stored: no message text or bearer token in the retry journal.
44
44
  */
45
45
  function pendingPostId(config, text, category) {
46
- const key = (0, crypto_1.createHash)("sha256").update(JSON.stringify([config.apiBaseUrl, config.userToken, text, category ?? null])).digest("hex");
46
+ // The JWT subject is used only as a local journal namespace, never for auth.
47
+ // Refreshing/replacing a token for the same user must retain pending requests.
48
+ let account = config.userToken;
49
+ try {
50
+ const payload = JSON.parse(Buffer.from(config.userToken?.split(".")[1] ?? "", "base64url").toString());
51
+ if (payload.kind === "user" && typeof payload.sub === "string")
52
+ account = payload.sub;
53
+ }
54
+ catch { /* Legacy/non-JWT fixture tokens retain their previous namespace. */ }
55
+ const digest = (identity) => (0, crypto_1.createHash)("sha256").update(JSON.stringify([config.apiBaseUrl, identity, text, category || null])).digest("hex");
56
+ const key = digest(account);
47
57
  const pending = (0, config_1.readJsonFile)(PENDING_PATH) ?? [];
48
- const existing = pending.find((p) => p.key === key);
49
- if (existing)
58
+ const existing = pending.find((p) => p.key === key || p.key === digest(config.userToken));
59
+ if (existing) {
60
+ if (existing.key !== key) {
61
+ existing.key = key;
62
+ (0, config_1.writeJsonFile)(PENDING_PATH, pending);
63
+ }
50
64
  return existing.requestId;
65
+ }
51
66
  const requestId = (0, crypto_1.randomUUID)();
52
67
  (0, config_1.writeJsonFile)(PENDING_PATH, [...pending, { key, requestId }]);
53
68
  return requestId;
@@ -15,7 +15,7 @@ async function main() {
15
15
  if (process.env.npm_config_global !== "true")
16
16
  return;
17
17
  console.log((0, i18n_1.t)("postinstall.start"));
18
- await (0, lock_1.withPingletLock)(() => (0, install_1.runInstall)([]));
18
+ await (0, lock_1.withCommandLock)(() => (0, install_1.runInstall)([]));
19
19
  }
20
20
  main()
21
21
  .catch(() => {
package/dist/queue.js CHANGED
@@ -37,20 +37,25 @@ exports.appendEvent = appendEvent;
37
37
  exports.readEvents = readEvents;
38
38
  exports.countEvents = countEvents;
39
39
  exports.removeEvents = removeEvents;
40
+ exports.quarantineEvents = quarantineEvents;
41
+ exports.bindEvents = bindEvents;
42
+ exports.invalidEventReason = invalidEventReason;
40
43
  const fs = __importStar(require("fs"));
41
44
  const crypto_1 = require("crypto");
42
45
  const config_1 = require("./config");
46
+ const path = __importStar(require("path"));
43
47
  const storage_1 = require("./storage");
44
48
  /**
45
- * Local Event Queue (JSONL). All CLI writers hold withPingletLock for the
46
- * complete command, so append and removal cannot interleave across processes.
49
+ * Local Event Queue (JSONL). Writers hold the short local transaction lock;
50
+ * uploads use a snapshot and acknowledge only its event IDs afterwards.
47
51
  * 노출 즉시 서버를 호출하지 않고 여기 기록했다가 batch 전송한다 (아키텍처 §8).
48
52
  */
49
53
  function appendEvent(input) {
50
54
  (0, config_1.ensurePingletDir)();
51
55
  const event = {
52
- eventId: "evt_" + (0, crypto_1.randomUUID)(),
56
+ eventId: input.eventId ?? "evt_" + (0, crypto_1.randomUUID)(),
53
57
  agentType: input.agentType,
58
+ installationId: (0, config_1.loadConfig)().installations[input.agentType]?.installationId,
54
59
  type: input.type,
55
60
  messageId: input.messageId,
56
61
  ...(input.visibleMs !== undefined && { visibleMs: input.visibleMs }),
@@ -72,12 +77,16 @@ function readEvents() {
72
77
  continue;
73
78
  try {
74
79
  const parsed = JSON.parse(line);
75
- if (parsed.eventId && parsed.messageId && parsed.type) {
76
- events.push(parsed);
77
- }
80
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || typeof parsed.eventId !== "string" || !parsed.eventId)
81
+ throw new Error("invalid queue record");
82
+ events.push(parsed);
78
83
  }
79
84
  catch {
80
- // 손상된 라인은 버린다.
85
+ // Preserve damaged input for quarantine rather than silently deleting it
86
+ // when the next successful upload rewrites the queue.
87
+ events.push({ eventId: "invalid_" + (0, crypto_1.createHash)("sha256").update(line).digest("hex").slice(0, 48),
88
+ agentType: "CLAUDE", type: "DELIVERED", messageId: "", occurredAt: "",
89
+ queueError: "malformed-queue-line", rawLine: line });
81
90
  }
82
91
  }
83
92
  return events;
@@ -91,3 +100,41 @@ function removeEvents(sentEventIds) {
91
100
  const body = remaining.map((e) => JSON.stringify(e)).join("\n");
92
101
  (0, storage_1.atomicWrite)(config_1.EVENTS_PATH, body ? body + "\n" : "");
93
102
  }
103
+ /** Persist before removing from the retry queue. No tokens or server writes. */
104
+ function quarantineEvents(events, reason) {
105
+ if (!events.length)
106
+ return;
107
+ (0, config_1.ensurePingletDir)();
108
+ fs.appendFileSync(path.join(config_1.PINGLET_DIR, "events-quarantine.jsonl"), events.map(event => JSON.stringify({ reason, quarantinedAt: new Date().toISOString(), event })).join("\n") + "\n", { mode: 0o600 });
109
+ removeEvents(new Set(events.map(event => event.eventId)));
110
+ }
111
+ /** Legacy/offline events bind once, before any installation can be revoked. */
112
+ function bindEvents() {
113
+ const config = (0, config_1.loadConfig)();
114
+ const events = readEvents();
115
+ let changed = false;
116
+ for (const event of events) {
117
+ if (!event.installationId && config.installations[event.agentType]) {
118
+ event.installationId = config.installations[event.agentType].installationId;
119
+ changed = true;
120
+ }
121
+ }
122
+ if (changed)
123
+ (0, storage_1.atomicWrite)(config_1.EVENTS_PATH, events.map(event => JSON.stringify(event)).join("\n") + "\n");
124
+ return events;
125
+ }
126
+ function invalidEventReason(event) {
127
+ if (event.queueError)
128
+ return event.queueError;
129
+ if (!["CLAUDE", "CODEX"].includes(event.agentType) ||
130
+ !["DELIVERED", "QUALIFIED_IMPRESSION"].includes(event.type))
131
+ return "invalid-type";
132
+ if ([event.eventId, event.messageId].some(value => typeof value !== "string" || !value.length || value.length > 64))
133
+ return "invalid-id";
134
+ if (event.visibleMs != null && (!Number.isInteger(event.visibleMs) || event.visibleMs < 0 || event.visibleMs > 60_000))
135
+ return "invalid-visible-ms";
136
+ const time = typeof event.occurredAt === "string" ? Date.parse(event.occurredAt) : NaN;
137
+ if (!Number.isFinite(time) || time < Date.UTC(2000, 0, 1) || time > Date.now() + 5 * 60_000)
138
+ return "invalid-timestamp";
139
+ return null;
140
+ }
package/dist/runtime.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FEED_TTL_MS = exports.FLUSH_COUNT = exports.FLUSH_INTERVAL_MS = exports.QUALIFIED_MS = exports.ROTATE_MS = void 0;
4
+ exports.spawnDetachedCommand = spawnDetachedCommand;
4
5
  exports.runMaintenance = runMaintenance;
5
6
  const child_process_1 = require("child_process");
6
7
  const config_1 = require("./config");
@@ -17,6 +18,7 @@ function spawnDetachedCommand(args) {
17
18
  detached: true,
18
19
  stdio: "ignore",
19
20
  });
21
+ child.on("error", () => { });
20
22
  child.unref();
21
23
  }
22
24
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinglet-cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
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": {