pinglet-cli 0.7.0 → 0.8.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 +21 -1
- package/dist/commands/statusline.js +33 -23
- package/dist/render.js +15 -0
- package/dist/turn.js +100 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.MAX_BATCH_SIZE = void 0;
|
|
37
37
|
exports.checkHealth = checkHealth;
|
|
38
38
|
exports.registerInstallation = registerInstallation;
|
|
39
|
+
exports.safeLinkUrl = safeLinkUrl;
|
|
39
40
|
exports.fetchFeed = fetchFeed;
|
|
40
41
|
exports.uploadEventBatch = uploadEventBatch;
|
|
41
42
|
exports.uploadEventBatchResult = uploadEventBatchResult;
|
|
@@ -91,6 +92,24 @@ async function registerInstallation(config, agentType) {
|
|
|
91
92
|
registeredAt: new Date().toISOString(),
|
|
92
93
|
};
|
|
93
94
|
}
|
|
95
|
+
const MAX_URL_LENGTH = 2048;
|
|
96
|
+
/**
|
|
97
|
+
* statusline이 OSC 8 링크로 감싸므로, ESC·BEL 같은 제어 문자나 공백이 있으면
|
|
98
|
+
* 시퀀스를 탈출할 수 있다. http(s)만 받고 나머지는 링크 없이 제목만 보여준다.
|
|
99
|
+
*/
|
|
100
|
+
function safeLinkUrl(url) {
|
|
101
|
+
if (typeof url !== "string" || url.length === 0 || url.length > MAX_URL_LENGTH)
|
|
102
|
+
return null;
|
|
103
|
+
if (/[\s\p{Cc}\p{Cf}]/u.test(url))
|
|
104
|
+
return null;
|
|
105
|
+
try {
|
|
106
|
+
const parsed = new URL(url);
|
|
107
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? url : null;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
94
113
|
/** GET /feed — 로컬 캐시용 메시지 30~50개를 미리 받는다. */
|
|
95
114
|
async function fetchFeed(config, record, limit = 50) {
|
|
96
115
|
const res = await request(config, "GET", `/feed?limit=${limit}`, { token: record.token });
|
|
@@ -104,9 +123,10 @@ async function fetchFeed(config, record, limit = 50) {
|
|
|
104
123
|
author: item.authorNickname || "익명의 개발자",
|
|
105
124
|
category: item.category,
|
|
106
125
|
expiresAt: item.expiresAt,
|
|
107
|
-
contentType: item.contentType === "SYSTEM" || item.contentType === "SPONSORED"
|
|
126
|
+
contentType: item.contentType === "SYSTEM" || item.contentType === "SPONSORED" || item.contentType === "NEWS"
|
|
108
127
|
? item.contentType
|
|
109
128
|
: "USER",
|
|
129
|
+
url: safeLinkUrl(item.url),
|
|
110
130
|
}));
|
|
111
131
|
}
|
|
112
132
|
/** POST /events/batch — eventId 기반으로 서버가 dedupe하므로 재전송에 안전하다. */
|
|
@@ -43,24 +43,8 @@ const picker_1 = require("../picker");
|
|
|
43
43
|
const queue_1 = require("../queue");
|
|
44
44
|
const claude_1 = require("../adapters/claude");
|
|
45
45
|
const runtime_1 = require("../runtime");
|
|
46
|
+
const turn_1 = require("../turn");
|
|
46
47
|
const hud_1 = require("../hud");
|
|
47
|
-
/**
|
|
48
|
-
* online 캐시 유효 기간 — refresh(≈5분) 2주기. 서버의 온라인 판정 창
|
|
49
|
-
* (ONLINE_WINDOW_MS 10분)과 정렬해, 서버가 이미 오프라인으로 세는 시점에
|
|
50
|
-
* 클라이언트가 옛 숫자를 "지금"이라고 보여주지 않게 한다.
|
|
51
|
-
*/
|
|
52
|
-
const ONLINE_STALE_MS = 10 * 60_000;
|
|
53
|
-
/** 신선한 online 캐시가 있을 때, 나를 제외한 켜진 터미널 수 (없거나 혼자면 null). */
|
|
54
|
-
function onlineOthers(now) {
|
|
55
|
-
const cache = (0, cache_1.loadOnline)();
|
|
56
|
-
if (!cache)
|
|
57
|
-
return null;
|
|
58
|
-
const t = Date.parse(cache.updatedAt);
|
|
59
|
-
if (Number.isNaN(t) || now - t > ONLINE_STALE_MS)
|
|
60
|
-
return null;
|
|
61
|
-
const others = cache.onlineInstallations - 1; // 이 기기의 heartbeat 제외
|
|
62
|
-
return others >= 1 ? others : null;
|
|
63
|
-
}
|
|
64
48
|
function readPayload() {
|
|
65
49
|
try {
|
|
66
50
|
const parsed = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
@@ -90,6 +74,27 @@ function trackSpinnerActivity(state, payload) {
|
|
|
90
74
|
return;
|
|
91
75
|
state.current.visibleMs = Math.min((state.current.visibleMs ?? 0) + (apiMs - prev), runtime_1.ROTATE_MS);
|
|
92
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* spinner는 시작할 때 고른 문구를 턴 끝까지 유지한다. spinnerVerbs는 모든 세션이
|
|
79
|
+
* 공유하므로, 어느 세션이든 턴이 진행 중이면 회전을 미뤄 spinner와 statusline을 맞춘다.
|
|
80
|
+
* 세션이 턴 도중 닫혀도 TURN_STALE_MS 뒤에는 기록이 풀린다.
|
|
81
|
+
*/
|
|
82
|
+
function updateBusySessions(state, payload, now) {
|
|
83
|
+
const busy = state.busySessions ?? {};
|
|
84
|
+
const sessionId = payload.session_id;
|
|
85
|
+
if (sessionId) {
|
|
86
|
+
if ((0, turn_1.isTurnInProgress)(payload.transcript_path, now))
|
|
87
|
+
busy[sessionId] = now;
|
|
88
|
+
else
|
|
89
|
+
delete busy[sessionId];
|
|
90
|
+
}
|
|
91
|
+
for (const [id, seenAt] of Object.entries(busy)) {
|
|
92
|
+
if (now - seenAt > turn_1.TURN_STALE_MS)
|
|
93
|
+
delete busy[id];
|
|
94
|
+
}
|
|
95
|
+
state.busySessions = Object.keys(busy).length > 0 ? busy : undefined;
|
|
96
|
+
return state.busySessions !== undefined;
|
|
97
|
+
}
|
|
93
98
|
/**
|
|
94
99
|
* 이전 armed 메시지의 노출을 정산하고 다음 메시지를 armed한다.
|
|
95
100
|
* QUALIFIED_MS 이상 spinner가 돌았으면 유효 노출(visibleMs 포함)로 기록한다.
|
|
@@ -136,6 +141,7 @@ function rotateSpinner(state, now) {
|
|
|
136
141
|
messageId: message.id,
|
|
137
142
|
text: message.text,
|
|
138
143
|
author: message.author,
|
|
144
|
+
url: message.url ?? null,
|
|
139
145
|
shownAt: now,
|
|
140
146
|
visibleMs: 0,
|
|
141
147
|
};
|
|
@@ -157,8 +163,11 @@ function runStatusline() {
|
|
|
157
163
|
const state = (0, cache_1.loadState)();
|
|
158
164
|
const now = Date.now();
|
|
159
165
|
trackSpinnerActivity(state, payload);
|
|
160
|
-
const
|
|
161
|
-
|
|
166
|
+
const turnBusy = updateBusySessions(state, payload, now);
|
|
167
|
+
// 피드에서 빠진(삭제·만료) 항목은 턴 중이라도 바로 내린다.
|
|
168
|
+
const rotateDue = !state.current ||
|
|
169
|
+
!(0, cache_1.loadFeedMessages)().some((message) => message.id === state.current?.messageId) ||
|
|
170
|
+
(!turnBusy && now - state.current.shownAt >= runtime_1.ROTATE_MS);
|
|
162
171
|
// 300ms마다 호출되므로 저장/maintenance는 1초 스로틀. 회전 시점에는 항상 저장.
|
|
163
172
|
const saveDue = rotateDue ||
|
|
164
173
|
state.lastTickAt === undefined ||
|
|
@@ -172,13 +181,14 @@ function runStatusline() {
|
|
|
172
181
|
(0, cache_1.saveState)(state);
|
|
173
182
|
}
|
|
174
183
|
const lines = [];
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
184
|
+
// spinner와 같은 항목을 링크로 보여준다. HUD 세그먼트 폭 계산(hud/width)은
|
|
185
|
+
// OSC 8을 모르므로 HUD 안이 아니라 별도 줄로 둔다.
|
|
186
|
+
if (state.current?.url)
|
|
187
|
+
lines.push((0, render_1.formatNewsLink)(state.current.text, state.current.url));
|
|
178
188
|
return { payload, lines, hud: (0, hud_1.loadHudConfig)(config) };
|
|
179
189
|
}
|
|
180
190
|
/**
|
|
181
|
-
* 잠금 밖에서 HUD(모델·컨텍스트·사용량·활동)를 그려
|
|
191
|
+
* 잠금 밖에서 HUD(모델·컨텍스트·사용량·활동)를 그려 뉴스 링크 줄 아래에 출력한다.
|
|
182
192
|
* HUD는 실패해도 위 줄에 영향을 주면 안 된다.
|
|
183
193
|
*/
|
|
184
194
|
async function writeStatusline(tick, now = Date.now(), write = (text) => { process.stdout.write(text); }) {
|
package/dist/render.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.detectLocale = detectLocale;
|
|
|
4
4
|
exports.supportsUnicode = supportsUnicode;
|
|
5
5
|
exports.sanitizeForTerminal = sanitizeForTerminal;
|
|
6
6
|
exports.formatPing = formatPing;
|
|
7
|
+
exports.formatNewsLink = formatNewsLink;
|
|
7
8
|
exports.formatOnlineNow = formatOnlineNow;
|
|
8
9
|
/**
|
|
9
10
|
* statusline 표시 언어. 시스템 언어를 먼저 보고(사용자가 실제로 읽는
|
|
@@ -53,6 +54,9 @@ function sanitizeForTerminal(text) {
|
|
|
53
54
|
}
|
|
54
55
|
/** 작성자 닉네임 없이 메시지만 표시한다 (spinner verb용). */
|
|
55
56
|
function formatPing(message) {
|
|
57
|
+
if (message.contentType === "NEWS") {
|
|
58
|
+
return `${supportsUnicode() ? "📰" : "[news]"} ${sanitizeForTerminal(message.text)}`;
|
|
59
|
+
}
|
|
56
60
|
const sponsored = message.contentType === "SPONSORED" ? "[AD] " : "";
|
|
57
61
|
const text = sanitizeForTerminal(message.text);
|
|
58
62
|
if (supportsUnicode()) {
|
|
@@ -60,6 +64,17 @@ function formatPing(message) {
|
|
|
60
64
|
}
|
|
61
65
|
return `[ping] ${sponsored}"${text}"`;
|
|
62
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* statusline용 뉴스 한 줄: 제목을 OSC 8 하이퍼링크로 감싸 클릭하면 원문으로 간다.
|
|
69
|
+
* 제목은 감싸기 전에 sanitize한다 — 완성된 escape를 sanitize에 넣으면 시퀀스가 지워진다.
|
|
70
|
+
* url은 fetchFeed의 safeLinkUrl을 통과한 값만 온다. 없으면 제목만 보여준다.
|
|
71
|
+
*/
|
|
72
|
+
function formatNewsLink(title, url) {
|
|
73
|
+
const text = `${supportsUnicode() ? "📰" : "[news]"} ${sanitizeForTerminal(title)}`;
|
|
74
|
+
if (!url)
|
|
75
|
+
return text;
|
|
76
|
+
return `\u001b]8;;${url}\u001b\\${text}\u001b]8;;\u001b\\`;
|
|
77
|
+
}
|
|
63
78
|
/** statusline용: 지금 함께 켜져 있는 다른 터미널 수 한 줄. */
|
|
64
79
|
function formatOnlineNow(othersCount) {
|
|
65
80
|
const n = othersCount;
|
package/dist/turn.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
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.TURN_STALE_MS = void 0;
|
|
37
|
+
exports.isTurnInProgress = isTurnInProgress;
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
/** transcript 끝에서 읽는 양 — 마지막 몇 줄만 보면 된다. 큰 tool_result 한 줄보다는 넉넉하게. */
|
|
40
|
+
const TAIL_BYTES = 256 * 1024;
|
|
41
|
+
/**
|
|
42
|
+
* transcript가 이만큼 조용하면 진행 중으로 보지 않는다. 비정상 종료·로컬 명령처럼
|
|
43
|
+
* 응답이 기록되지 않는 경우 회전이 영원히 멈추지 않게 하는 상한.
|
|
44
|
+
*/
|
|
45
|
+
exports.TURN_STALE_MS = 10 * 60_000;
|
|
46
|
+
function isInterrupt(entry) {
|
|
47
|
+
const content = entry.message?.content;
|
|
48
|
+
const texts = typeof content === "string" ? [content]
|
|
49
|
+
: Array.isArray(content) ? content.map((part) => part?.text).filter((t) => typeof t === "string")
|
|
50
|
+
: [];
|
|
51
|
+
return texts.some((text) => text.startsWith("[Request interrupted"));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 이 세션의 턴이 진행 중인지(= spinner가 돌고 있는지) transcript 꼬리로 판단한다.
|
|
55
|
+
* Claude Code는 spinner가 시작될 때 고른 문구를 턴이 끝날 때까지 유지하므로,
|
|
56
|
+
* 진행 중에 spinnerVerbs를 바꾸면 spinner와 statusline이 서로 다른 항목을 보여준다.
|
|
57
|
+
* 판단할 수 없으면 false — 예전처럼 회전한다.
|
|
58
|
+
*/
|
|
59
|
+
function isTurnInProgress(transcriptPath, now) {
|
|
60
|
+
if (!transcriptPath)
|
|
61
|
+
return false;
|
|
62
|
+
let fd;
|
|
63
|
+
try {
|
|
64
|
+
const stat = fs.statSync(transcriptPath);
|
|
65
|
+
if (now - stat.mtimeMs > exports.TURN_STALE_MS)
|
|
66
|
+
return false;
|
|
67
|
+
const length = Math.min(stat.size, TAIL_BYTES);
|
|
68
|
+
const buffer = Buffer.alloc(length);
|
|
69
|
+
fd = fs.openSync(transcriptPath, "r");
|
|
70
|
+
fs.readSync(fd, buffer, 0, length, stat.size - length);
|
|
71
|
+
const lines = buffer.toString("utf8").split("\n");
|
|
72
|
+
if (length < stat.size)
|
|
73
|
+
lines.shift(); // 잘린 첫 줄
|
|
74
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
75
|
+
let entry;
|
|
76
|
+
try {
|
|
77
|
+
entry = JSON.parse(lines[i]);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (entry.type === "system" && entry.subtype === "turn_duration")
|
|
83
|
+
return false;
|
|
84
|
+
if (entry.type === "assistant") {
|
|
85
|
+
const stop = entry.message?.stop_reason;
|
|
86
|
+
return stop === "tool_use" || stop == null;
|
|
87
|
+
}
|
|
88
|
+
if (entry.type === "user")
|
|
89
|
+
return !isInterrupt(entry);
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
if (fd !== undefined)
|
|
98
|
+
fs.closeSync(fd);
|
|
99
|
+
}
|
|
100
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pinglet-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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": {
|