@ryan_nookpi/pi-extension-subagent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,255 @@
1
+ // Vendored from shared personal-extension utilities as a self-contained copy.
2
+ // Maintained independently within this package.
3
+ /**
4
+ * Pure formatting utility functions extracted from various extensions.
5
+ *
6
+ * These functions convert data into display-ready strings. They have no
7
+ * side effects and depend only on their arguments and Node built-ins.
8
+ */
9
+
10
+ import { visibleWidth } from "@earendil-works/pi-tui";
11
+ import type { AgentConfigLike } from "./agent-utils.ts";
12
+
13
+ // ─── Types ───────────────────────────────────────────────────────────────────
14
+
15
+ export type { AgentConfigLike };
16
+
17
+ export interface UsageStats {
18
+ input: number;
19
+ output: number;
20
+ cacheRead: number;
21
+ cacheWrite: number;
22
+ cost: number;
23
+ contextTokens?: number;
24
+ turns?: number;
25
+ }
26
+
27
+ export interface CommandRunSummaryInput {
28
+ id: number;
29
+ status: string;
30
+ agent: string;
31
+ contextMode?: string;
32
+ turnCount?: number;
33
+ elapsedMs: number;
34
+ toolCalls: number;
35
+ }
36
+
37
+ // ─── Constants ───────────────────────────────────────────────────────────────
38
+
39
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
40
+
41
+ /** Vibrant ANSI-256 foreground colors for per-agent name coloring. */
42
+ export const AGENT_NAME_PALETTE = [39, 208, 114, 204, 220, 141, 81, 209, 156, 177];
43
+
44
+ // ─── USD / Token Formatting ──────────────────────────────────────────────────
45
+
46
+ /** Format a cost value as a USD string with adaptive precision. */
47
+ export function formatUsd(cost: number): string {
48
+ if (!Number.isFinite(cost) || cost <= 0) return "$0.00";
49
+ if (cost >= 1) return `$${cost.toFixed(2)}`;
50
+ if (cost >= 0.1) return `$${cost.toFixed(3)}`;
51
+ return `$${cost.toFixed(4)}`;
52
+ }
53
+
54
+ /** Rough token estimate from text length (~4 chars/token). */
55
+ export function estimateTokens(text: string): number {
56
+ return Math.max(0, Math.ceil(text.length / 4));
57
+ }
58
+
59
+ /**
60
+ * Safely extract a total cost number from a usage object.
61
+ * Handles `{ cost: number }`, `{ cost: string }`, `{ cost: { total: number } }`.
62
+ */
63
+ export function extractCostTotal(usage: unknown): number {
64
+ if (!usage || typeof usage !== "object") return 0;
65
+ const rec = usage as Record<string, unknown>;
66
+ const c = rec.cost;
67
+ if (typeof c === "number") return Number.isFinite(c) ? c : 0;
68
+ if (typeof c === "string") {
69
+ const n = Number(c);
70
+ return Number.isFinite(n) ? n : 0;
71
+ }
72
+ if (c && typeof c === "object") {
73
+ const t = (c as Record<string, unknown>).total;
74
+ if (typeof t === "number") return Number.isFinite(t) ? t : 0;
75
+ if (typeof t === "string") {
76
+ const n = Number(t);
77
+ return Number.isFinite(n) ? n : 0;
78
+ }
79
+ }
80
+ return 0;
81
+ }
82
+
83
+ /** Format a token count to a compact string (e.g. "1.2k", "300k", "1.5M"). */
84
+ export function formatTokens(count: number): string {
85
+ if (count < 1000) return count.toString();
86
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
87
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
88
+ return `${(count / 1000000).toFixed(1)}M`;
89
+ }
90
+
91
+ /**
92
+ * Build a compact usage stats summary string.
93
+ *
94
+ * Format example: `3 turns ↑12k ↓4.2k R8k W1k $0.0521 ctx:32k sonnet`
95
+ */
96
+ export function formatUsageStats(usage: UsageStats, model?: string): string {
97
+ const parts: string[] = [];
98
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
99
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
100
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
101
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
102
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
103
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
104
+ if (usage.contextTokens && usage.contextTokens > 0) {
105
+ parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
106
+ }
107
+ if (model) parts.push(model);
108
+ return parts.join(" ");
109
+ }
110
+
111
+ // ─── Model / Context ─────────────────────────────────────────────────────────
112
+
113
+ /** Split a model reference into optional provider and id. */
114
+ export function normalizeModelRef(modelRef: string): { provider?: string; id: string } {
115
+ const cleaned = modelRef.trim().split(":")[0] ?? modelRef.trim();
116
+ if (cleaned.includes("/")) {
117
+ const [provider, ...idParts] = cleaned.split("/");
118
+ return { provider, id: idParts.join("/") };
119
+ }
120
+ return { id: cleaned };
121
+ }
122
+
123
+ /** Compute percentage of context window used (0–100), or undefined if unavailable. */
124
+ export function getUsedContextPercent(contextTokens?: number, contextWindow?: number): number | undefined {
125
+ if (!contextWindow || contextWindow <= 0) return undefined;
126
+ if (contextTokens === undefined || contextTokens === null || contextTokens < 0) return undefined;
127
+ return Math.max(0, Math.min(100, Math.round((contextTokens / contextWindow) * 100)));
128
+ }
129
+
130
+ /** Compute percentage of context window remaining (0–100). */
131
+ export function getRemainingContextPercent(usedPercent?: number): number | undefined {
132
+ if (usedPercent === undefined || usedPercent === null) return undefined;
133
+ return Math.max(0, Math.min(100, 100 - usedPercent));
134
+ }
135
+
136
+ /**
137
+ * Render a text-based context usage bar.
138
+ * Example: `[■■■■■□□□□□] 50%`
139
+ */
140
+ export function formatContextUsageBar(percent: number, width = 10): string {
141
+ const clamped = Math.max(0, Math.min(100, Math.round(percent)));
142
+ const barWidth = Math.max(4, width);
143
+ const filled = Math.round((clamped / 100) * barWidth);
144
+ return `[${"■".repeat(filled)}${"□".repeat(barWidth - filled)}] ${clamped}%`;
145
+ }
146
+
147
+ /** Map remaining context percentage to a color severity. */
148
+ export function getContextBarColorByRemaining(remainingPercent: number): "warning" | "error" | undefined {
149
+ if (remainingPercent <= 15) return "error";
150
+ if (remainingPercent <= 40) return "warning";
151
+ return undefined;
152
+ }
153
+
154
+ // ─── Agent Formatting ────────────────────────────────────────────────────────
155
+
156
+ /** Deterministic index into AGENT_NAME_PALETTE based on agent name hash. */
157
+ export function agentBgIndex(name: string): number {
158
+ let h = 0;
159
+ for (let i = 0; i < name.length; i++) {
160
+ h = ((h << 5) - h + name.charCodeAt(i)) | 0;
161
+ }
162
+ return Math.abs(h) % AGENT_NAME_PALETTE.length;
163
+ }
164
+
165
+ /** Truncate text to at most `maxLines` lines, appending "..." if truncated. */
166
+ export function truncateLines(text: string, maxLines = 2): string {
167
+ const lines = text.split("\n");
168
+ if (lines.length <= maxLines) return text;
169
+ return `${lines.slice(0, maxLines).join("\n")}\n...`;
170
+ }
171
+
172
+ function sliceToDisplayWidth(value: string, maxWidth: number): string {
173
+ if (maxWidth <= 0 || value.length === 0) return "";
174
+
175
+ let result = "";
176
+ let width = 0;
177
+
178
+ for (const { segment } of graphemeSegmenter.segment(value)) {
179
+ const segmentWidth = visibleWidth(segment);
180
+ if (segmentWidth <= 0) {
181
+ result += segment;
182
+ continue;
183
+ }
184
+ if (width + segmentWidth > maxWidth) break;
185
+ result += segment;
186
+ width += segmentWidth;
187
+ }
188
+
189
+ return result;
190
+ }
191
+
192
+ /**
193
+ * Truncate plain text to display width and append an ellipsis when truncation occurs.
194
+ * Apply terminal styles after calling this helper so the ellipsis inherits the intended style.
195
+ */
196
+ export function truncatePlainToWidth(value: string, maxWidth: number, ellipsis = "..."): string {
197
+ if (maxWidth <= 0) return "";
198
+ if (visibleWidth(value) <= maxWidth) return value;
199
+ if (!ellipsis) return sliceToDisplayWidth(value, maxWidth);
200
+
201
+ const ellipsisWidth = visibleWidth(ellipsis);
202
+ if (ellipsisWidth >= maxWidth) return sliceToDisplayWidth(ellipsis, maxWidth);
203
+ return `${sliceToDisplayWidth(value, maxWidth - ellipsisWidth)}${ellipsis}`;
204
+ }
205
+
206
+ /**
207
+ * Truncate a single line to display width and append "..." when truncation occurs.
208
+ * Uses terminal display width (CJK-aware) rather than string length.
209
+ */
210
+ export function truncateToWidthWithEllipsis(value: string, maxWidth: number): string {
211
+ if (maxWidth <= 0) return "";
212
+ if (visibleWidth(value) <= maxWidth) return value;
213
+ if (maxWidth <= 3) return sliceToDisplayWidth(value, maxWidth);
214
+ return truncatePlainToWidth(value, maxWidth, "...");
215
+ }
216
+
217
+ /**
218
+ * One-line summary of a command run.
219
+ *
220
+ * Format: `#<id> [<status>] <agent> ctx:<contextMode> turn:<turnCount> <elapsed>s tools:<toolCalls>`
221
+ */
222
+ export function formatCommandRunSummary(run: CommandRunSummaryInput): string {
223
+ const elapsedSec = Math.max(0, Math.round(run.elapsedMs / 1000));
224
+ const contextLabel = run.contextMode === "main" ? "main" : "isolated";
225
+ return `#${run.id} [${run.status}] ${run.agent} ctx:${contextLabel} turn:${run.turnCount ?? 1} ${elapsedSec}s tools:${run.toolCalls}`;
226
+ }
227
+
228
+ /** Format a list of agents for display. */
229
+ export function formatAgentList(agents: AgentConfigLike[], maxItems: number): { text: string; remaining: number } {
230
+ if (agents.length === 0) return { text: "none", remaining: 0 };
231
+ const listed = agents.slice(0, maxItems);
232
+ const remaining = agents.length - listed.length;
233
+ return {
234
+ text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join("; "),
235
+ remaining,
236
+ };
237
+ }
238
+
239
+ // ─── GitHub Formatting ───────────────────────────────────────────────────────
240
+
241
+ /** Format a state string for display (replace underscores, uppercase). */
242
+ export function formatStateLabel(value: string | null): string {
243
+ if (!value) return "UNKNOWN";
244
+ return value.replace(/_/g, " ").toUpperCase();
245
+ }
246
+
247
+ // ─── Purpose Formatting ──────────────────────────────────────────────────────
248
+
249
+ /** Format a purpose string for status bar display (normalizes whitespace, clips). */
250
+ export function formatPurposeStatus(purpose: string): string {
251
+ const singleLine = typeof purpose !== "string" ? "" : purpose.replace(/\s+/g, " ").trim();
252
+ const maxChars = 90;
253
+ const clipped = singleLine.length > maxChars ? `${singleLine.slice(0, maxChars - 1)}…` : singleLine;
254
+ return `🎯 ${clipped}`;
255
+ }
@@ -0,0 +1,78 @@
1
+ // Vendored from shared personal-extension utilities as a self-contained copy.
2
+ // Maintained independently within this package.
3
+ import { completeSimple } from "@earendil-works/pi-ai/compat";
4
+
5
+ type SummaryModel = Parameters<typeof completeSimple>[0];
6
+ type SummaryResult = Awaited<ReturnType<typeof completeSimple>>;
7
+
8
+ type AuthResult = {
9
+ ok: boolean;
10
+ apiKey?: string;
11
+ headers?: Record<string, string>;
12
+ };
13
+
14
+ export type ShortLabelContext = {
15
+ model?: SummaryModel;
16
+ modelRegistry?: {
17
+ getApiKeyAndHeaders: (model: SummaryModel) => Promise<AuthResult>;
18
+ };
19
+ };
20
+
21
+ export type GenerateShortLabelOptions = {
22
+ systemPrompt: string;
23
+ prompt: string;
24
+ maxTokens?: number;
25
+ timeoutMs?: number;
26
+ extractText?: (content: SummaryResult["content"]) => string;
27
+ };
28
+
29
+ function defaultExtractText(content: SummaryResult["content"]): string {
30
+ return content
31
+ .filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string")
32
+ .map((part) => part.text)
33
+ .join("")
34
+ .trim();
35
+ }
36
+
37
+ export async function generateShortLabel(ctx: ShortLabelContext, options: GenerateShortLabelOptions): Promise<string> {
38
+ const model = ctx.model;
39
+ if (!model || !ctx.modelRegistry) return "";
40
+
41
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
42
+ if (!auth.ok) return "";
43
+
44
+ const controller = new AbortController();
45
+ const timeoutMs = options.timeoutMs ?? 10000;
46
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
47
+
48
+ try {
49
+ const result = await completeSimple(
50
+ model,
51
+ {
52
+ systemPrompt: options.systemPrompt,
53
+ messages: [
54
+ {
55
+ role: "user",
56
+ content: [{ type: "text", text: options.prompt }],
57
+ timestamp: Date.now(),
58
+ },
59
+ ],
60
+ },
61
+ {
62
+ apiKey: auth.apiKey,
63
+ headers: auth.headers,
64
+ signal: controller.signal,
65
+ reasoning: "minimal",
66
+ maxTokens: options.maxTokens ?? 60,
67
+ },
68
+ );
69
+
70
+ if (result.stopReason !== "stop") return "";
71
+ const extractText = options.extractText ?? defaultExtractText;
72
+ return extractText(result.content);
73
+ } catch {
74
+ return "";
75
+ } finally {
76
+ clearTimeout(timer);
77
+ }
78
+ }
@@ -0,0 +1,229 @@
1
+ // Vendored from shared personal-extension utilities as a self-contained copy.
2
+ // Maintained independently within this package.
3
+ /**
4
+ * Pure string utility functions extracted from various extension modules.
5
+ * No side effects, no pi SDK dependencies.
6
+ */
7
+
8
+ import crypto from "node:crypto";
9
+ import { basename } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ // ── Topic / Text Sanitization ────────────────────────────────────────────────
13
+
14
+ /**
15
+ * Sanitize a topic name into a safe filesystem slug.
16
+ * Strips path traversal sequences, path separators, and non-slug characters.
17
+ * Throws on empty result.
18
+ *
19
+ * Source: memory-layer/storage.ts
20
+ */
21
+ export function sanitizeTopic(topic: string): string {
22
+ const slug = topic
23
+ .replace(/\.\./g, "") // strip traversal
24
+ .replace(/[/\\]/g, "") // strip path separators
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9\uAC00-\uD7AF\u3131-\u3163-]/g, "-")
27
+ .replace(/-+/g, "-")
28
+ .replace(/^-|-$/g, "")
29
+ .slice(0, 50);
30
+
31
+ if (!slug) {
32
+ throw new Error(`Invalid topic name: "${topic}"`);
33
+ }
34
+ return slug;
35
+ }
36
+
37
+ /**
38
+ * Normalize text for consistent key generation.
39
+ * Trims whitespace and canonicalizes line endings (\r\n → \n).
40
+ *
41
+ * Source: memory-layer/storage.ts
42
+ */
43
+ export function normalizeText(s: string): string {
44
+ return s.replace(/\r\n/g, "\n").trim();
45
+ }
46
+
47
+ /**
48
+ * Normalize a git remote URL to a slug.
49
+ * https://github.com/example/product.git → github-example-product
50
+ * git@github.com:example/product.git → github-example-product
51
+ *
52
+ * Source: memory-layer/project-id.ts
53
+ */
54
+ export function normalizeRemoteUrl(url: string): string {
55
+ let normalized = url.trim();
56
+
57
+ // SSH: git@github.com:org/repo.git → github.com/org/repo.git
58
+ const sshMatch = normalized.match(/^[\w-]+@([\w.-]+):(.*)/);
59
+ if (sshMatch) {
60
+ normalized = `${sshMatch[1]}/${sshMatch[2]}`;
61
+ }
62
+
63
+ // Strip protocol
64
+ normalized = normalized.replace(/^https?:\/\//, "");
65
+ normalized = normalized.replace(/^ssh:\/\//, "");
66
+
67
+ // Strip trailing .git
68
+ normalized = normalized.replace(/\.git$/, "");
69
+
70
+ // Strip trailing slashes
71
+ normalized = normalized.replace(/\/+$/, "");
72
+
73
+ // Strip user@ prefix (e.g. git@)
74
+ normalized = normalized.replace(/^[\w-]+@/, "");
75
+
76
+ // Replace non-alphanumeric with hyphens, collapse, trim
77
+ return normalized
78
+ .replace(/[^a-zA-Z0-9]+/g, "-")
79
+ .replace(/^-+|-+$/g, "")
80
+ .toLowerCase();
81
+ }
82
+
83
+ /**
84
+ * Generate a short SHA-256 hash (first 8 hex chars).
85
+ *
86
+ * Source: memory-layer/project-id.ts
87
+ */
88
+ export function shortHash(input: string): string {
89
+ return crypto.createHash("sha256").update(input).digest("hex").slice(0, 8);
90
+ }
91
+
92
+ /**
93
+ * Truncate content to its first line, clipped to maxLen characters.
94
+ *
95
+ * Source: memory-layer/index.ts
96
+ */
97
+ export function truncateTitle(content: string, maxLen = 60): string {
98
+ const firstLine = content.split("\n")[0]?.trim() ?? content.trim();
99
+ if (firstLine.length <= maxLen) return firstLine;
100
+ return `${firstLine.slice(0, maxLen - 1)}…`;
101
+ }
102
+
103
+ /**
104
+ * Convert a slug to a human-readable heading (Title Case).
105
+ *
106
+ * Source: memory-layer/index.ts
107
+ */
108
+ export function slugToHeading(slug: string): string {
109
+ return slug
110
+ .split("-")
111
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
112
+ .join(" ");
113
+ }
114
+
115
+ // ── String Splitting / Encoding ──────────────────────────────────────────────
116
+
117
+ /**
118
+ * Split a null-separated string into an array, filtering empty entries.
119
+ *
120
+ * Source: files.ts
121
+ */
122
+ export function splitNullSeparated(value: string): string[] {
123
+ return value.split("\0").filter(Boolean);
124
+ }
125
+
126
+ /**
127
+ * Encode text to base64 (UTF-8).
128
+ *
129
+ * Source: former clipboard extension
130
+ */
131
+ export function toBase64(text: string): string {
132
+ return Buffer.from(text, "utf-8").toString("base64");
133
+ }
134
+
135
+ // ── Name / Label Normalization ───────────────────────────────────────────────
136
+
137
+ /**
138
+ * Normalize a skill name by stripping the "skill:" prefix if present.
139
+ *
140
+ * Source: context.ts
141
+ */
142
+ export function normalizeSkillName(name: string): string {
143
+ return name.startsWith("skill:") ? name.slice("skill:".length) : name;
144
+ }
145
+
146
+ /**
147
+ * Join an array of strings with ", ".
148
+ *
149
+ * Source: context.ts
150
+ */
151
+ export function joinComma(items: string[]): string {
152
+ return items.join(", ");
153
+ }
154
+
155
+ /**
156
+ * Join items with a custom render function and separator.
157
+ *
158
+ * Source: context.ts
159
+ */
160
+ export function joinCommaStyled(items: string[], renderItem: (item: string) => string, sep: string): string {
161
+ return items.map(renderItem).join(sep);
162
+ }
163
+
164
+ // ── Whitespace / Tab ─────────────────────────────────────────────────────────
165
+
166
+ /**
167
+ * Expand tabs to spaces.
168
+ *
169
+ * Source: diff-overlay.ts
170
+ */
171
+ export function expandTabs(s: string, tabSize = 4): string {
172
+ return s.replace(/\t/g, " ".repeat(tabSize));
173
+ }
174
+
175
+ /**
176
+ * Derive the extension name (e.g. "minimal") from its import.meta.url or file path.
177
+ *
178
+ * Source: shared extension-name parsing helper
179
+ */
180
+ export function extensionName(fileUrl: string): string {
181
+ const filePath = fileUrl.startsWith("file://") ? fileURLToPath(fileUrl) : fileUrl;
182
+ return basename(filePath).replace(/\.[^.]+$/, "");
183
+ }
184
+
185
+ /**
186
+ * Normalize whitespace in a string: collapse consecutive spaces/tabs/newlines
187
+ * into a single space and trim.
188
+ *
189
+ * Unifies normalizeLine (former idle-screensaver extension) and
190
+ * normalizePurpose (former auto-name extension) under the same
191
+ * whitespace-normalization logic.
192
+ *
193
+ * Source: former idle-screensaver / auto-name extensions
194
+ */
195
+ export function normalizeWhitespace(raw: unknown): string {
196
+ if (typeof raw !== "string") return "";
197
+ return raw.replace(/\s+/g, " ").trim();
198
+ }
199
+
200
+ /**
201
+ * Sanitize status text: replace newlines/tabs with spaces, collapse, trim.
202
+ *
203
+ * Source: footer.ts
204
+ */
205
+ export function sanitizeStatusText(text: string): string {
206
+ return text
207
+ .replace(/[\r\n\t]/g, " ")
208
+ .replace(/ +/g, " ")
209
+ .trim();
210
+ }
211
+
212
+ // ── Session ID Detection ─────────────────────────────────────────────────────
213
+
214
+ /**
215
+ * Check if a string looks like an auto-generated session ID
216
+ * (hex-based or "session-NNN" pattern).
217
+ *
218
+ * Source: former idle-screensaver extension
219
+ */
220
+ export function isLikelySessionId(text: string): boolean {
221
+ const s = normalizeWhitespace(text);
222
+ if (!s) return true;
223
+
224
+ const compact = s.replace(/[-_]/g, "");
225
+ if (/^[0-9a-f]{16,}$/i.test(compact)) return true;
226
+ if (/^session[-_]?\d+$/i.test(s)) return true;
227
+ if (/^session[-_]?[0-9a-f-]{8,}$/i.test(s)) return true;
228
+ return false;
229
+ }
@@ -0,0 +1,105 @@
1
+ // Vendored from shared personal-extension utilities as a self-contained copy.
2
+ // Maintained independently within this package.
3
+ /**
4
+ * Shared time formatting helpers.
5
+ *
6
+ * Standard duration format: h/m/s (e.g. "1h 2m 3s", "4m 5s", "12s").
7
+ */
8
+
9
+ // ─── Internal Helpers ────────────────────────────────────────────────────────
10
+
11
+ function toSafeMs(value: number): number {
12
+ if (!Number.isFinite(value)) return 0;
13
+ return Math.max(0, Math.floor(value));
14
+ }
15
+
16
+ // ─── Existing Functions ──────────────────────────────────────────────────────
17
+
18
+ export function formatDuration(ms: number): string {
19
+ const totalSeconds = Math.floor(toSafeMs(ms) / 1000);
20
+ const hours = Math.floor(totalSeconds / 3600);
21
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
22
+ const seconds = totalSeconds % 60;
23
+
24
+ if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
25
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
26
+ return `${seconds}s`;
27
+ }
28
+
29
+ export function formatDurationBetween(start: Date | number, end: Date | number): string {
30
+ const startMs = start instanceof Date ? start.getTime() : start;
31
+ const endMs = end instanceof Date ? end.getTime() : end;
32
+ return formatDuration(toSafeMs(endMs - startMs));
33
+ }
34
+
35
+ export function formatElapsedSince(startedAt: number, now = Date.now()): string {
36
+ return formatDuration(toSafeMs(now - startedAt));
37
+ }
38
+
39
+ // ─── Delay / Duration Conversion ─────────────────────────────────────────────
40
+
41
+ /** Convert a numeric amount + Korean unit to milliseconds. */
42
+ export function toDelayMs(amount: number, unit: "초" | "분" | "시간"): number {
43
+ if (unit === "초") return amount * 1000;
44
+ if (unit === "시간") return amount * 60 * 60 * 1000;
45
+ return amount * 60 * 1000; // 분
46
+ }
47
+
48
+ /**
49
+ * Format milliseconds as a Korean duration string (shorter style).
50
+ *
51
+ * Unlike `formatDuration` which always shows 시간/분/초,
52
+ * this picks the most natural unit: "5초", "3분", "2시간 10분".
53
+ */
54
+ export function formatKoreanDuration(ms: number): string {
55
+ if (ms < 60_000) return `${Math.max(1, Math.round(ms / 1000))}초`;
56
+ if (ms < 3_600_000) return `${Math.max(1, Math.round(ms / 60_000))}분`;
57
+
58
+ const hours = Math.floor(ms / 3_600_000);
59
+ const minutes = Math.floor((ms % 3_600_000) / 60_000);
60
+ if (minutes === 0) return `${hours}시간`;
61
+ return `${hours}시간 ${minutes}분`;
62
+ }
63
+
64
+ /** Format a timestamp as a ko-KR 24-hour clock string, e.g. "14:05:30". */
65
+ export function formatClock(ts: number): string {
66
+ return new Date(ts).toLocaleTimeString("ko-KR", {
67
+ hour: "2-digit",
68
+ minute: "2-digit",
69
+ second: "2-digit",
70
+ hour12: false,
71
+ });
72
+ }
73
+
74
+ // ─── Session Replay Time (from subagent/replay.ts) ───────────────────────────
75
+
76
+ /** Safely parse a date value (number or string) into a Date, falling back to now. */
77
+ export function parseDateSafely(raw: unknown): Date {
78
+ if (typeof raw === "number") {
79
+ const d = new Date(raw);
80
+ if (!Number.isNaN(d.getTime())) return d;
81
+ }
82
+ if (typeof raw === "string") {
83
+ const d = new Date(raw);
84
+ if (!Number.isNaN(d.getTime())) return d;
85
+ }
86
+ return new Date();
87
+ }
88
+
89
+ /** Format a Date as a compact time string, e.g. "14:05:30". */
90
+ export function formatReplayTime(date: Date): string {
91
+ return date.toLocaleTimeString([], {
92
+ hour: "2-digit",
93
+ minute: "2-digit",
94
+ second: "2-digit",
95
+ });
96
+ }
97
+
98
+ // ─── Epoch Conversion (from github-overlay.ts) ───────────────────────────────
99
+
100
+ /** Parse an ISO date string to epoch milliseconds, returning 0 on failure. */
101
+ export function toEpochMs(value: string | null): number {
102
+ if (!value) return 0;
103
+ const parsed = Date.parse(value);
104
+ return Number.isFinite(parsed) ? parsed : 0;
105
+ }