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 +25 -14
- package/dist/cache.js +10 -5
- package/dist/cli.js +26 -8
- package/dist/commands/flush.js +37 -8
- package/dist/commands/install.js +17 -8
- package/dist/commands/login.js +7 -1
- package/dist/commands/logout.js +6 -2
- package/dist/commands/notify.js +2 -2
- package/dist/commands/post.js +3 -1
- package/dist/commands/refresh.js +19 -12
- package/dist/commands/statusline.js +7 -1
- package/dist/commands/uninstall.js +33 -7
- package/dist/lock.js +54 -22
- package/dist/notifications.js +98 -0
- package/dist/pending-post.js +18 -3
- package/dist/postinstall.js +1 -1
- package/dist/queue.js +54 -7
- package/dist/runtime.js +2 -0
- package/package.json +1 -1
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
77
|
-
|
|
78
|
-
|
|
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);
|
package/dist/commands/flush.js
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/install.js
CHANGED
|
@@ -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,
|
|
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)(
|
|
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)(
|
|
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)(
|
|
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)(
|
|
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)(
|
|
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
|
}
|
package/dist/commands/login.js
CHANGED
|
@@ -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);
|
package/dist/commands/logout.js
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
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
|
}
|
package/dist/commands/notify.js
CHANGED
|
@@ -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);
|
package/dist/commands/post.js
CHANGED
|
@@ -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
|
-
|
|
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";
|
package/dist/commands/refresh.js
CHANGED
|
@@ -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,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
(
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
45
|
-
function owner() {
|
|
45
|
+
function owner(lockPath) {
|
|
46
46
|
try {
|
|
47
|
-
return fs.readFileSync(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/dist/pending-post.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/postinstall.js
CHANGED
|
@@ -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.
|
|
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).
|
|
46
|
-
*
|
|
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.
|
|
76
|
-
|
|
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.
|
|
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": {
|