@gavin-hu/my-pi-agent 0.13.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -41
- package/README.zh-CN.md +53 -36
- package/extensions/bytefly/README.md +321 -186
- package/extensions/bytefly/block/component.ts +7 -15
- package/extensions/bytefly/block/register.ts +6 -1
- package/extensions/bytefly/doctor/config.ts +4 -1
- package/extensions/bytefly/doctor/register.ts +24 -5
- package/extensions/bytefly/doctor/tools.ts +4 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
- package/extensions/bytefly/footer/config.ts +10 -6
- package/extensions/bytefly/footer/price/config.ts +3 -2
- package/extensions/bytefly/footer/price/price.ts +5 -3
- package/extensions/bytefly/footer/price/rates.ts +63 -26
- package/extensions/bytefly/footer/render/status.ts +3 -3
- package/extensions/bytefly/header/component.ts +33 -10
- package/extensions/bytefly/header/config.ts +22 -0
- package/extensions/bytefly/header/register.ts +2 -1
- package/extensions/bytefly/index.ts +6 -6
- package/extensions/bytefly/notice/component.ts +8 -3
- package/extensions/bytefly/notify/config.ts +7 -12
- package/extensions/bytefly/notify/dispatch.ts +13 -5
- package/extensions/bytefly/panel/commands.ts +22 -2
- package/extensions/bytefly/panel/format.ts +17 -21
- package/extensions/bytefly/panel/settings-runtime.ts +86 -22
- package/extensions/bytefly/panel/settings.ts +279 -46
- package/extensions/bytefly/panel/themes.ts +32 -0
- package/extensions/bytefly/panel/tui/editing.ts +77 -0
- package/extensions/bytefly/panel/tui/entries.ts +24 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
- package/extensions/bytefly/panel/tui/frame.ts +121 -4
- package/extensions/bytefly/panel/tui/settings-view.ts +98 -13
- package/extensions/bytefly/panel/tui.ts +176 -166
- package/extensions/bytefly/picker/discovery.ts +1 -0
- package/extensions/bytefly/picker/types.ts +2 -0
- package/extensions/bytefly/separator/component.ts +17 -6
- package/extensions/bytefly/separator/config.ts +15 -0
- package/extensions/bytefly/separator/format.ts +60 -6
- package/extensions/bytefly/separator/register.ts +68 -9
- package/extensions/bytefly/separator/types.ts +8 -0
- package/extensions/bytefly/update/cache.ts +58 -19
- package/extensions/bytefly/update/check.ts +35 -0
- package/extensions/bytefly/update/format.ts +76 -35
- package/extensions/bytefly/update/register.ts +144 -55
- package/extensions/bytefly/update/state.ts +4 -2
- package/extensions/bytefly/update/suppress.ts +66 -0
- package/extensions/bytefly/update/types.ts +15 -0
- package/extensions/bytefly/working/config.ts +4 -5
- package/extensions/bytefly/working/format.ts +24 -7
- package/extensions/bytefly/working/register.ts +34 -3
- package/extensions/check/README.md +253 -0
- package/extensions/check/commands.ts +128 -0
- package/extensions/check/config.ts +39 -0
- package/extensions/check/detect.ts +269 -0
- package/extensions/check/failures.ts +230 -0
- package/extensions/check/format.ts +135 -0
- package/extensions/check/index.ts +26 -0
- package/extensions/check/render.ts +185 -0
- package/extensions/check/runner.ts +176 -0
- package/extensions/check/runtime.ts +244 -0
- package/extensions/check/source.ts +80 -0
- package/extensions/check/tools.ts +173 -0
- package/extensions/check/tui.ts +195 -0
- package/extensions/check/types.ts +99 -0
- package/extensions/document/README.md +1 -1
- package/extensions/document/extract/pdf.ts +8 -16
- package/extensions/document/formats.ts +2 -2
- package/extensions/file-browse/README.md +66 -32
- package/extensions/file-browse/client.ts +95 -32
- package/extensions/file-browse/config.ts +9 -9
- package/extensions/file-browse/git.ts +37 -47
- package/extensions/file-browse/html.ts +39 -58
- package/extensions/file-browse/markdown.ts +110 -92
- package/extensions/file-browse/raw-html.ts +478 -0
- package/extensions/file-browse/router.ts +32 -70
- package/extensions/file-browse/server.ts +18 -56
- package/extensions/file-browse/urls.ts +80 -0
- package/extensions/goal/README.md +103 -47
- package/extensions/goal/clarify-parse.ts +4 -9
- package/extensions/goal/clarify.ts +27 -4
- package/extensions/goal/commands.ts +24 -11
- package/extensions/goal/format.ts +41 -10
- package/extensions/goal/index.ts +39 -16
- package/extensions/goal/plan-route.ts +51 -0
- package/extensions/goal/schema.ts +6 -23
- package/extensions/goal/state.ts +39 -57
- package/extensions/goal/tools.ts +26 -20
- package/extensions/goal/types.ts +11 -1
- package/extensions/job/README.md +20 -6
- package/extensions/job/format.ts +18 -68
- package/extensions/job/index.ts +26 -41
- package/extensions/job/log-writer.ts +208 -0
- package/extensions/job/logs.ts +3 -2
- package/extensions/job/process.ts +6 -99
- package/extensions/job/runtime.ts +42 -181
- package/extensions/job/schema.ts +1 -4
- package/extensions/job/tools.ts +1 -15
- package/extensions/job/tui.ts +117 -307
- package/extensions/job/ui.ts +7 -33
- package/extensions/memory/README.md +3 -1
- package/extensions/memory/errors.ts +5 -6
- package/extensions/memory/tui.ts +14 -24
- package/extensions/plan/README.md +106 -18
- package/extensions/plan/commands.ts +9 -5
- package/extensions/plan/format.ts +87 -0
- package/extensions/plan/index.ts +31 -15
- package/extensions/plan/plans.ts +2 -3
- package/extensions/plan/policy.ts +0 -6
- package/extensions/plan/runtime.ts +4 -2
- package/extensions/plan/steps.ts +37 -8
- package/extensions/plan/todos.ts +44 -3
- package/extensions/plan/tools/enter.ts +94 -0
- package/extensions/plan/tools/exit.ts +296 -0
- package/extensions/plan/tools/nested.ts +21 -0
- package/extensions/plan/tools/steps.ts +136 -0
- package/extensions/plan/tools/todo-seed.ts +76 -0
- package/extensions/plan/tools/write.ts +127 -0
- package/extensions/plan/tools.ts +18 -703
- package/extensions/plan/web-client.ts +6 -22
- package/extensions/plan/web-page.ts +19 -30
- package/extensions/plan/web-router.ts +34 -71
- package/extensions/plan/web.ts +13 -40
- package/extensions/question/answers.ts +3 -8
- package/extensions/question/tui.ts +6 -6
- package/extensions/rewind/README.md +95 -22
- package/extensions/rewind/checklist.ts +185 -0
- package/extensions/rewind/commands.ts +8 -0
- package/extensions/rewind/config.ts +3 -11
- package/extensions/rewind/format.ts +220 -2
- package/extensions/rewind/git.ts +74 -7
- package/extensions/rewind/hunk-picker.ts +114 -0
- package/extensions/rewind/patch.ts +88 -0
- package/extensions/rewind/path-picker.ts +67 -100
- package/extensions/rewind/paths.ts +93 -19
- package/extensions/rewind/restore.ts +53 -10
- package/extensions/rewind/review-tui.ts +320 -0
- package/extensions/rewind/review.ts +397 -0
- package/extensions/rewind/rewind-tui.ts +4 -3
- package/extensions/rewind/runtime.ts +94 -17
- package/extensions/subagent/README.md +118 -8
- package/extensions/subagent/background-tui.ts +208 -0
- package/extensions/subagent/background.ts +470 -0
- package/extensions/subagent/commands.ts +80 -0
- package/extensions/subagent/config.ts +62 -0
- package/extensions/subagent/format.ts +24 -1
- package/extensions/subagent/index.ts +116 -1
- package/extensions/subagent/orchestrate.ts +16 -3
- package/extensions/subagent/render.ts +25 -0
- package/extensions/subagent/run.ts +113 -49
- package/extensions/subagent/schema.ts +12 -0
- package/extensions/subagent/transcripts.ts +202 -0
- package/extensions/subagent/types.ts +23 -0
- package/extensions/subagent/ui.ts +50 -0
- package/extensions/todo/README.md +18 -13
- package/extensions/todo/format.ts +3 -2
- package/extensions/todo/runtime.ts +3 -2
- package/extensions/todo/schema.ts +8 -84
- package/extensions/todo/state.ts +5 -46
- package/extensions/todo/tools.ts +2 -1
- package/extensions/todo/types.ts +6 -15
- package/extensions/watch/README.md +16 -6
- package/extensions/watch/child.ts +150 -0
- package/extensions/watch/format.ts +8 -48
- package/extensions/watch/index.ts +42 -58
- package/extensions/watch/runtime.ts +49 -128
- package/extensions/watch/schema.ts +1 -4
- package/extensions/watch/tools.ts +1 -11
- package/extensions/watch/tui.ts +103 -273
- package/extensions/watch/ui.ts +5 -32
- package/extensions/web-access/README.md +1 -1
- package/extensions/web-access/fetch/page.ts +1 -1
- package/extensions/web-access/fetch/tool.ts +6 -2
- package/extensions/web-access/search/search.ts +2 -5
- package/extensions/wechat/README.md +3 -1
- package/extensions/wechat/client.ts +23 -0
- package/extensions/wechat/commands.ts +57 -42
- package/extensions/wechat/deliver.ts +5 -9
- package/extensions/wechat/errors.ts +3 -6
- package/extensions/wechat/inbound.ts +2 -5
- package/extensions/wechat/index.ts +2 -8
- package/extensions/wechat/outbound.ts +2 -4
- package/extensions/wechat/peer-worker.ts +3 -8
- package/extensions/wechat/poll.ts +10 -9
- package/extensions/wechat/presence.ts +35 -62
- package/extensions/wechat/reply-pipeline.ts +3 -1
- package/extensions/wechat/status-screen.ts +1 -1
- package/extensions/wechat/timer.ts +57 -0
- package/extensions/worktree/config.ts +8 -20
- package/extensions/worktree/enter.ts +7 -12
- package/extensions/worktree/git.ts +38 -69
- package/extensions/worktree/index.ts +5 -4
- package/extensions/worktree/registry.ts +7 -7
- package/extensions/worktree/root-tools.ts +2 -2
- package/extensions/worktree/status.ts +3 -2
- package/lib/README.md +22 -8
- package/lib/config.ts +83 -12
- package/lib/errors.ts +12 -0
- package/lib/format.ts +56 -0
- package/lib/git/read.ts +8 -0
- package/lib/goal-state.ts +23 -8
- package/lib/http/headers.ts +54 -0
- package/lib/http/index.ts +13 -0
- package/lib/http/response.ts +46 -0
- package/lib/http/router.ts +52 -0
- package/lib/http/server.ts +91 -0
- package/lib/lines.ts +77 -0
- package/lib/pdf.ts +82 -0
- package/lib/plan-entry.ts +71 -0
- package/lib/plan-state.ts +34 -0
- package/lib/report.ts +99 -0
- package/lib/requirements.ts +25 -19
- package/lib/spawn.ts +170 -0
- package/lib/todo-state.ts +110 -23
- package/lib/tool-names.ts +7 -0
- package/lib/tool-result.ts +33 -0
- package/lib/tui/README.md +68 -3
- package/lib/tui/chrome.ts +28 -4
- package/lib/tui/columns.ts +6 -1
- package/lib/tui/confirm.ts +65 -0
- package/lib/tui/dock-screen.ts +410 -0
- package/lib/tui/glyphs.ts +2 -1
- package/lib/tui/indent.ts +1 -1
- package/lib/tui/index.ts +2 -0
- package/lib/tui/rows.ts +21 -0
- package/lib/tui/status-controller.ts +35 -1
- package/lib/tui/tool.ts +59 -1
- package/lib/web/components.ts +20 -1
- package/lib/web/markup.ts +32 -0
- package/lib/web/runtime.ts +50 -0
- package/package.json +23 -5
- package/skills/init/SKILL.md +165 -0
- package/skills/init/references/agents-md-quality.md +103 -0
- package/skills/init/references/interview-questions.md +81 -0
- package/skills/refactor/SKILL.md +141 -0
- package/themes/bytefly-dark.json +2 -3
- package/themes/bytefly-light.json +2 -3
- package/extensions/watch/process.ts +0 -63
- package/extensions/web-access/fetch/pdf.ts +0 -45
package/lib/config.ts
CHANGED
|
@@ -54,6 +54,24 @@ export function cleanString(value: unknown, fallback: string): string {
|
|
|
54
54
|
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** A trimmed non-empty string, or `undefined` when the value is unset or blank. */
|
|
58
|
+
export function optionalString(value: unknown): string | undefined {
|
|
59
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A boolean, or the fallback when the value is anything else. */
|
|
63
|
+
export function asBoolean(value: unknown, fallback: boolean): boolean {
|
|
64
|
+
return typeof value === "boolean" ? value : fallback;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Kept trimmed, non-empty strings from an untrusted array, or the fallback. */
|
|
68
|
+
export function stringList(value: unknown, fallback: string[]): string[] {
|
|
69
|
+
if (!Array.isArray(value)) return fallback;
|
|
70
|
+
return value
|
|
71
|
+
.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
|
72
|
+
.map((item) => item.trim());
|
|
73
|
+
}
|
|
74
|
+
|
|
57
75
|
/**
|
|
58
76
|
* `value` when it is a plain object, otherwise `undefined`.
|
|
59
77
|
*
|
|
@@ -225,10 +243,39 @@ export function writeConfigValue(path: string, section: string, key: string, val
|
|
|
225
243
|
* malformed document and an empty path.
|
|
226
244
|
*/
|
|
227
245
|
export function writeConfigValueAt(path: string, section: string, keyPath: readonly string[], value: unknown): void {
|
|
228
|
-
|
|
246
|
+
writeConfigValuesAt(path, [{ section, path: keyPath, value }]);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** One leaf edit for {@link writeConfigValuesAt}. */
|
|
250
|
+
export interface ConfigLeafEdit {
|
|
251
|
+
section: string;
|
|
252
|
+
path: readonly string[];
|
|
253
|
+
value: unknown;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Set several leaves in one document pass, with the guarantees of
|
|
258
|
+
* {@link writeConfigValueAt}.
|
|
259
|
+
*
|
|
260
|
+
* A row that also writes a companion leaf (the currency writes its default
|
|
261
|
+
* precision) must not read, write, and re-read the file between the two: a
|
|
262
|
+
* failure in the second write would leave the first one on disk while the
|
|
263
|
+
* caller reports that nothing was saved. Every edit is applied to one parsed
|
|
264
|
+
* document and written once, so the file is either wholly updated or untouched.
|
|
265
|
+
* Later edits see the sections earlier ones built.
|
|
266
|
+
*/
|
|
267
|
+
export function writeConfigValuesAt(path: string, edits: readonly ConfigLeafEdit[]): void {
|
|
268
|
+
if (edits.length === 0) return;
|
|
269
|
+
for (const edit of edits) {
|
|
270
|
+
if (edit.path.length === 0) throw new Error(`Cannot update ${path}: empty key path`);
|
|
271
|
+
}
|
|
229
272
|
const document = readEditableDocument(path);
|
|
230
|
-
const
|
|
231
|
-
|
|
273
|
+
const sections = new Map<string, Record<string, unknown>>();
|
|
274
|
+
for (const edit of edits) {
|
|
275
|
+
const current = sections.get(edit.section) ?? configSection(document, edit.section) ?? {};
|
|
276
|
+
sections.set(edit.section, setPath(current, edit.path, edit.value));
|
|
277
|
+
}
|
|
278
|
+
for (const [section, value] of sections) document[section] = value;
|
|
232
279
|
writeConfigFile(path, document);
|
|
233
280
|
}
|
|
234
281
|
|
|
@@ -246,27 +293,51 @@ function setPath(source: Record<string, unknown>, path: readonly string[], value
|
|
|
246
293
|
}
|
|
247
294
|
|
|
248
295
|
/**
|
|
249
|
-
* Delete
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
296
|
+
* Delete the leaf at `keyPath` from one top-level section, preserving every
|
|
297
|
+
* sibling key, every other section, and document metadata. An object left empty
|
|
298
|
+
* by the deletion goes with it, the section included, so a file does not fill
|
|
299
|
+
* with `{}` shells. A no-op when the file, section, or leaf is absent; refuses a
|
|
300
|
+
* malformed document and an empty path.
|
|
253
301
|
*/
|
|
254
|
-
export function
|
|
302
|
+
export function unsetConfigValueAt(path: string, section: string, keyPath: readonly string[]): void {
|
|
303
|
+
if (keyPath.length === 0) throw new Error(`Cannot update ${path}: empty key path`);
|
|
255
304
|
const result = readJsonResult(path);
|
|
256
305
|
if (result.state === "absent") return;
|
|
257
306
|
if (result.state === "error") throw new Error(`Cannot update ${path}: ${result.error}`);
|
|
258
307
|
|
|
259
308
|
const document = result.value;
|
|
260
309
|
const current = configSection(document, section);
|
|
261
|
-
if (!current
|
|
310
|
+
if (!current) return;
|
|
262
311
|
|
|
263
|
-
const next =
|
|
264
|
-
|
|
265
|
-
if (
|
|
312
|
+
const next = withoutPath(current, keyPath);
|
|
313
|
+
if (next === current) return;
|
|
314
|
+
if (next === undefined) delete document[section];
|
|
266
315
|
else document[section] = next;
|
|
267
316
|
writeConfigFile(path, document);
|
|
268
317
|
}
|
|
269
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Copy `source` without the leaf at `path`, or `undefined` once nothing is left.
|
|
321
|
+
* Returns `source` itself when the leaf is absent or sits behind a non-object,
|
|
322
|
+
* so the caller can tell "nothing to delete" from "a deletion happened".
|
|
323
|
+
*/
|
|
324
|
+
function withoutPath(source: Record<string, unknown>, path: readonly string[]): Record<string, unknown> | undefined {
|
|
325
|
+
const [head, ...rest] = path;
|
|
326
|
+
if (head === undefined || !(head in source)) return source;
|
|
327
|
+
const next = { ...source };
|
|
328
|
+
if (rest.length === 0) {
|
|
329
|
+
delete next[head];
|
|
330
|
+
} else {
|
|
331
|
+
const child = asRecord(source[head]);
|
|
332
|
+
if (!child) return source;
|
|
333
|
+
const pruned = withoutPath(child, rest);
|
|
334
|
+
if (pruned === child) return source;
|
|
335
|
+
if (pruned === undefined) delete next[head];
|
|
336
|
+
else next[head] = pruned;
|
|
337
|
+
}
|
|
338
|
+
return Object.keys(next).length === 0 ? undefined : next;
|
|
339
|
+
}
|
|
340
|
+
|
|
270
341
|
/** Write a whole config document atomically (sibling temp file, then rename). */
|
|
271
342
|
export function writeConfigFile(path: string, document: Record<string, unknown>): void {
|
|
272
343
|
mkdirSync(dirname(path), { recursive: true });
|
package/lib/errors.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One error-to-line helper for an unknown thrown value.
|
|
3
|
+
*
|
|
4
|
+
* Extensions report a rejected promise, a failed child process, and a parser
|
|
5
|
+
* error the same way: an `Error` yields its message, and anything else is
|
|
6
|
+
* stringified. Kept value-only so every extension has one definition.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A user-facing message for an unknown thrown value. */
|
|
10
|
+
export function messageOf(error: unknown): string {
|
|
11
|
+
return error instanceof Error ? error.message : String(error);
|
|
12
|
+
}
|
package/lib/format.ts
CHANGED
|
@@ -49,6 +49,23 @@ export function formatDuration(ms: number): string {
|
|
|
49
49
|
return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** `1 tool`, `3 tools`: a tool count with the right word. */
|
|
53
|
+
export function formatToolCount(count: number): string {
|
|
54
|
+
const n = Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0;
|
|
55
|
+
return `${formatCount(n)} ${n === 1 ? "tool" : "tools"}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A tail bounded to `maxChars`: the end of the joined lines, with the truncation
|
|
60
|
+
* flag. The header and the truncation suffix belong to the caller, because each
|
|
61
|
+
* surface says something different about where the rest of the output lives.
|
|
62
|
+
*/
|
|
63
|
+
export function boundedTail(lines: readonly string[], maxChars: number): { text: string; truncated: boolean } {
|
|
64
|
+
const body = lines.join("\n");
|
|
65
|
+
if (body.length <= maxChars) return { text: body, truncated: false };
|
|
66
|
+
return { text: body.slice(body.length - maxChars), truncated: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
/**
|
|
53
70
|
* Replace terminal control characters (including ESC and the C1 block) with
|
|
54
71
|
* spaces, keeping `\n` and `\t` for callers that render multi-line text.
|
|
@@ -90,6 +107,45 @@ export function sanitizeLine(text: string): string {
|
|
|
90
107
|
return sanitize(stripControlChars(text));
|
|
91
108
|
}
|
|
92
109
|
|
|
110
|
+
/** A string read as one sanitized line; any other value reads empty. */
|
|
111
|
+
export function lineOf(value: unknown): string {
|
|
112
|
+
return typeof value === "string" ? sanitizeLine(value) : "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Longest untrusted line kept for display; longer lines are clipped. */
|
|
116
|
+
export const MAX_OUTPUT_LINE = 200;
|
|
117
|
+
|
|
118
|
+
/** ESC-introduced sequences: OSC, CSI, and single-character escapes. */
|
|
119
|
+
const OSC = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
|
|
120
|
+
const CSI = /\u001b\[[0-?]*[ -/]*[@-~]/g;
|
|
121
|
+
const OTHER_ESC = /\u001b[@-Z\\-_]/g;
|
|
122
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The boundary for one line of **program output**: a log file, a child's
|
|
126
|
+
* stdout, a tailed file.
|
|
127
|
+
*
|
|
128
|
+
* Stronger than {@link sanitizeLine}, which strips control characters but leaves
|
|
129
|
+
* the printable tail of an ANSI sequence behind. This removes whole OSC, CSI,
|
|
130
|
+
* and single-character escapes first, then resolves carriage returns the way a
|
|
131
|
+
* terminal would (keep the text after the last `\r`, so progress rewrites
|
|
132
|
+
* collapse to their final state), squeezes whitespace, and clips the result.
|
|
133
|
+
*
|
|
134
|
+
* The escape-stripping has to happen before the control-character pass: once
|
|
135
|
+
* ESC has become a space, the rest of the sequence is ordinary text.
|
|
136
|
+
*/
|
|
137
|
+
export function sanitizeUntrustedLine(raw: string, maxLength: number = MAX_OUTPUT_LINE): string {
|
|
138
|
+
const withoutAnsi = raw.replace(OSC, "").replace(CSI, "").replace(OTHER_ESC, "");
|
|
139
|
+
const afterCr = withoutAnsi.slice(withoutAnsi.lastIndexOf("\r") + 1);
|
|
140
|
+
const cleaned = afterCr.replace(CONTROL, " ").replace(/\s+/g, " ").trim();
|
|
141
|
+
return cleaned.length > maxLength ? `${cleaned.slice(0, maxLength - 1)}…` : cleaned;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** {@link sanitizeUntrustedLine} for a block of output, one entry per line. */
|
|
145
|
+
export function sanitizeUntrustedText(raw: string, maxLength: number = MAX_OUTPUT_LINE): string[] {
|
|
146
|
+
return raw.split("\n").map((line) => sanitizeUntrustedLine(line, maxLength));
|
|
147
|
+
}
|
|
148
|
+
|
|
93
149
|
/**
|
|
94
150
|
* Replace a home-directory prefix with `~`, matching at a path boundary so
|
|
95
151
|
* `/home/bob` does not shorten `/home/bobby`. Separators are normalized to `/`
|
package/lib/git/read.ts
CHANGED
|
@@ -54,6 +54,14 @@ export async function gitDir(runGit: RunGit, cwd: string): Promise<string | unde
|
|
|
54
54
|
return dir || undefined;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** Absolute path of the repository's common git directory, shared by its worktrees. */
|
|
58
|
+
export async function gitCommonDir(runGit: RunGit, cwd: string): Promise<string | undefined> {
|
|
59
|
+
const result = await runGit(["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd });
|
|
60
|
+
if (result.code !== 0) return undefined;
|
|
61
|
+
const dir = result.stdout.trim();
|
|
62
|
+
return dir || undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
57
65
|
/** Resolve a revision to a full object id, or undefined when it does not exist. */
|
|
58
66
|
export async function revParse(runGit: RunGit, cwd: string, rev: string): Promise<string | undefined> {
|
|
59
67
|
const result = await runGit(["rev-parse", "--verify", "--quiet", rev], { cwd });
|
package/lib/goal-state.ts
CHANGED
|
@@ -6,8 +6,13 @@
|
|
|
6
6
|
* `error`, which is not a write — and the `/goal` command appends a `goal` custom
|
|
7
7
|
* entry whose `data.goal` is `null` when the goal is cleared. Replaying the branch
|
|
8
8
|
* in order is how another extension reads the goal without importing goal's
|
|
9
|
-
* modules: `plan` restates
|
|
10
|
-
* record one when a plan is approved
|
|
9
|
+
* modules: `plan` restates a pursued goal in its planning context, promotes a
|
|
10
|
+
* `planning` goal at plan exit, and offers to record one when a plan is approved
|
|
11
|
+
* with none.
|
|
12
|
+
*
|
|
13
|
+
* The status is the goal's phase: `planning` while a plan is being designed,
|
|
14
|
+
* `active` once the objective is final, `achieved` when it is done. Only `goal`
|
|
15
|
+
* writes it; the promotion out of `planning` runs through the `goal` tool.
|
|
11
16
|
*
|
|
12
17
|
* Stored values are re-validated and re-sanitized here. Branch data can be
|
|
13
18
|
* tampered with, migrated, or written by an older version, and a reader must not
|
|
@@ -15,9 +20,10 @@
|
|
|
15
20
|
* ignored rather than clearing a goal that is still valid.
|
|
16
21
|
*
|
|
17
22
|
* The entry type and the objective limit live here and are imported by `goal`, so
|
|
18
|
-
* the publisher and the reader cannot drift. `goal`
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
23
|
+
* the publisher and the reader cannot drift. `goal` reads the branch through
|
|
24
|
+
* {@link lastGoal} as well — [`extensions/goal/state.ts`](../extensions/goal/state.ts)
|
|
25
|
+
* adds only the widget's active check and the open-todo count on top — so there
|
|
26
|
+
* is one replay, not two.
|
|
21
27
|
*/
|
|
22
28
|
|
|
23
29
|
import { sanitizeLine } from "./format.ts";
|
|
@@ -29,8 +35,8 @@ export const GOAL_ENTRY_TYPE = "goal";
|
|
|
29
35
|
/** Longest objective the goal tool accepts, and the most a reader will replay. */
|
|
30
36
|
export const MAX_GOAL_OBJECTIVE = 2000;
|
|
31
37
|
|
|
32
|
-
/**
|
|
33
|
-
export type PublishedGoalStatus = "active" | "achieved";
|
|
38
|
+
/** The goal's phase: being designed, final and pursued, or done. */
|
|
39
|
+
export type PublishedGoalStatus = "planning" | "active" | "achieved";
|
|
34
40
|
|
|
35
41
|
/** The goal as another extension may read it. */
|
|
36
42
|
export interface PublishedGoal {
|
|
@@ -60,12 +66,21 @@ function readGoal(value: unknown): PublishedGoal | null | undefined {
|
|
|
60
66
|
if (!value || typeof value !== "object") return undefined;
|
|
61
67
|
const goal = value as { objective?: unknown; status?: unknown };
|
|
62
68
|
if (typeof goal.objective !== "string") return undefined;
|
|
63
|
-
if (goal.status !== "active" && goal.status !== "achieved") return undefined;
|
|
69
|
+
if (goal.status !== "planning" && goal.status !== "active" && goal.status !== "achieved") return undefined;
|
|
64
70
|
const objective = sanitizeLine(goal.objective);
|
|
65
71
|
if (!objective || objective.length > MAX_GOAL_OBJECTIVE) return undefined;
|
|
66
72
|
return { objective, status: goal.status };
|
|
67
73
|
}
|
|
68
74
|
|
|
75
|
+
/**
|
|
76
|
+
* True while the goal steers the session: `planning` or `active`, never
|
|
77
|
+
* `achieved`. Both the reminder and the plan-mode context read this, so a goal
|
|
78
|
+
* being designed is restated like one being executed.
|
|
79
|
+
*/
|
|
80
|
+
export function isPursuedGoal(goal: PublishedGoal | null): goal is PublishedGoal {
|
|
81
|
+
return goal?.status === "planning" || goal?.status === "active";
|
|
82
|
+
}
|
|
83
|
+
|
|
69
84
|
/** The last goal written on the branch, or `null` when the session has none. */
|
|
70
85
|
export function lastGoal(entries: Iterable<unknown>): PublishedGoal | null {
|
|
71
86
|
let goal: PublishedGoal | null = null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The response header sets both loopback servers answer with.
|
|
3
|
+
*
|
|
4
|
+
* One policy, declared once: the pages `file-browse` and `plan` serve are the
|
|
5
|
+
* same kind of document — a same-origin script, inline styles, and no network
|
|
6
|
+
* — so they must not drift into different envelopes, and a page that refuses to
|
|
7
|
+
* be framed cannot quietly stop doing so on one surface only.
|
|
8
|
+
*
|
|
9
|
+
* Every builder returns a fresh object, so a caller may add to a response
|
|
10
|
+
* without writing into another one. The policy itself is a code-authored
|
|
11
|
+
* constant; nothing here is model- or file-derived.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The page policy. `default-src 'none'` makes every fetch opt-in; the inline
|
|
16
|
+
* style allowance is the page's own `<style>` block, `script-src 'self'` the
|
|
17
|
+
* same-origin client, and `connect-src 'self'` the page's own API routes.
|
|
18
|
+
*
|
|
19
|
+
* The last three directives are the ones that make a local page safe to leave
|
|
20
|
+
* open: `frame-ancestors 'none'` (with the legacy `X-Frame-Options`) stops a
|
|
21
|
+
* local page from framing the surface and clickjacking a control with a side
|
|
22
|
+
* effect, `base-uri 'self'` stops an injected `<base>` from re-pointing every
|
|
23
|
+
* relative URL, and `form-action 'none'` stops a submission anywhere.
|
|
24
|
+
*/
|
|
25
|
+
export const PAGE_CSP =
|
|
26
|
+
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'self'; " +
|
|
27
|
+
"connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'";
|
|
28
|
+
|
|
29
|
+
/** The security and caching headers every response carries, whatever its body. */
|
|
30
|
+
function secured(contentType: string): Record<string, string> {
|
|
31
|
+
return {
|
|
32
|
+
"Content-Type": contentType,
|
|
33
|
+
"Content-Security-Policy": PAGE_CSP,
|
|
34
|
+
"X-Frame-Options": "DENY",
|
|
35
|
+
"X-Content-Type-Options": "nosniff",
|
|
36
|
+
"Referrer-Policy": "no-referrer",
|
|
37
|
+
"Cache-Control": "no-store",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Headers for a served HTML page. */
|
|
42
|
+
export function pageHeaders(): Record<string, string> {
|
|
43
|
+
return secured("text/html; charset=utf-8");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Headers for a same-origin script: the page policy, with the script's own type. */
|
|
47
|
+
export function scriptHeaders(): Record<string, string> {
|
|
48
|
+
return secured("text/javascript; charset=utf-8");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Headers for a JSON API reply: the same envelope, a JSON type. */
|
|
52
|
+
export function jsonHeaders(): Record<string, string> {
|
|
53
|
+
return secured("application/json; charset=utf-8");
|
|
54
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loopback HTTP layer shared by `file-browse` and `plan`: the response
|
|
3
|
+
* shape, the header policy, the request-envelope helpers, and the socket seam.
|
|
4
|
+
*
|
|
5
|
+
* Import from here rather than a leaf module, so the layer reads as one kit.
|
|
6
|
+
* Value-only, like the rest of `lib/`: the only state is what a returned closure
|
|
7
|
+
* carries (`createCloser`).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export * from "./headers.ts";
|
|
11
|
+
export * from "./response.ts";
|
|
12
|
+
export * from "./router.ts";
|
|
13
|
+
export * from "./server.ts";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The response shape both loopback servers answer with, and the builders that
|
|
3
|
+
* pair a body with the header set it needs.
|
|
4
|
+
*
|
|
5
|
+
* A router returns one of these instead of touching a socket, so every route is
|
|
6
|
+
* testable without a server, and `server.ts` in this directory is the only code
|
|
7
|
+
* that writes one out. The shape carries the two ways a response is not a
|
|
8
|
+
* buffered body — a file to stream and a stream to hold open — because those are
|
|
9
|
+
* properties of the answer, not of the server that serves it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { jsonHeaders, pageHeaders } from "./headers.ts";
|
|
13
|
+
|
|
14
|
+
/** One HTTP response, before it reaches a socket. */
|
|
15
|
+
export interface ServeResponse {
|
|
16
|
+
/** HTTP status code. */
|
|
17
|
+
status: number;
|
|
18
|
+
/** Headers to send verbatim. */
|
|
19
|
+
headers: Record<string, string>;
|
|
20
|
+
/** The buffered body; omitted for an empty response or a streamed file. */
|
|
21
|
+
body?: string | Buffer;
|
|
22
|
+
/** When set, the server streams this file instead of `body`. */
|
|
23
|
+
filePath?: string;
|
|
24
|
+
/** When true, the server keeps the connection open as a Server-Sent Events stream. */
|
|
25
|
+
stream?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** An HTML page, with any headers the route needs to add to the page policy. */
|
|
29
|
+
export function htmlResponse(status: number, body: string, extra?: Record<string, string>): ServeResponse {
|
|
30
|
+
return { status, headers: { ...pageHeaders(), ...extra }, body };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A JSON body, serialized once. */
|
|
34
|
+
export function jsonResponse(status: number, value: unknown): ServeResponse {
|
|
35
|
+
return { status, headers: jsonHeaders(), body: JSON.stringify(value) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The JSON error envelope both API surfaces share: `{ error }`. */
|
|
39
|
+
export function jsonError(status: number, message: string): ServeResponse {
|
|
40
|
+
return jsonResponse(status, { error: message });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A plain-text body, for the internal-error fallback a server sends itself. */
|
|
44
|
+
export function textResponse(status: number, body: string): ServeResponse {
|
|
45
|
+
return { status, headers: { ...pageHeaders(), "Content-Type": "text/plain; charset=utf-8" }, body };
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request-envelope helpers both loopback routers apply before a route runs:
|
|
3
|
+
* the request target, and the `Host` allowlist.
|
|
4
|
+
*
|
|
5
|
+
* Both surfaces bind `127.0.0.1`, so a `Host` naming anything else is a
|
|
6
|
+
* DNS-rebinding attempt against a local page. How strictly a missing `Host` is
|
|
7
|
+
* treated is the caller's decision: the browse server accepts one, because its
|
|
8
|
+
* router is also driven directly from tests and a plain `GET` with no header is
|
|
9
|
+
* not a rebinding, while the review server fails closed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The `Host` names a loopback interface, with or without a port. */
|
|
13
|
+
const LOCAL_HOST = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
|
|
14
|
+
|
|
15
|
+
/** A request target split into the parts a router matches on. */
|
|
16
|
+
export interface ParsedRequestUrl {
|
|
17
|
+
/** The path, with the leading slash and no query. */
|
|
18
|
+
pathname: string;
|
|
19
|
+
/** The parsed query string. */
|
|
20
|
+
search: URLSearchParams;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse a raw request target (`path?query`) against a loopback base. Returns
|
|
25
|
+
* `undefined` for a target that cannot be parsed, which a router answers with
|
|
26
|
+
* `400` rather than a route.
|
|
27
|
+
*/
|
|
28
|
+
export function parseRequestUrl(rawUrl: string): ParsedRequestUrl | undefined {
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(rawUrl, "http://127.0.0.1");
|
|
31
|
+
return { pathname: url.pathname, search: url.searchParams };
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether the request's `Host` names this loopback server.
|
|
39
|
+
*
|
|
40
|
+
* With `required: false`, only a present, non-empty foreign name is refused, so
|
|
41
|
+
* a header-less request from another caller still routes. With `required: true`,
|
|
42
|
+
* a missing or non-string `Host` is refused too, so the guard cannot be skipped
|
|
43
|
+
* by omitting the header.
|
|
44
|
+
*/
|
|
45
|
+
export function isLocalHostHeader(
|
|
46
|
+
headers: Record<string, string | string[] | undefined>,
|
|
47
|
+
options: { required: boolean },
|
|
48
|
+
): boolean {
|
|
49
|
+
const host = headers.host;
|
|
50
|
+
if (typeof host !== "string" || host === "") return !options.required;
|
|
51
|
+
return LOCAL_HOST.test(host);
|
|
52
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The socket seam shared by the two loopback servers: bind, write a response
|
|
3
|
+
* out, and close.
|
|
4
|
+
*
|
|
5
|
+
* Everything here is behavior a caller cannot get right by accident: a bind on
|
|
6
|
+
* `127.0.0.1` only, an accept-level `error` listener so a transient failure
|
|
7
|
+
* cannot take the host down, a `HEAD` that sends headers and no body, a `filePath`
|
|
8
|
+
* response streamed instead of buffered, and a close that is safe to call twice
|
|
9
|
+
* and drops keep-alive connections. What each server does *around* this — SSE
|
|
10
|
+
* streams, request bodies, session settling — stays with that server.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createReadStream } from "node:fs";
|
|
14
|
+
import type { IncomingMessage, Server, ServerResponse } from "node:http";
|
|
15
|
+
import type { ServeResponse } from "./response.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Bind `server` to `127.0.0.1:port` and resolve the port it actually bound
|
|
19
|
+
* (`0` asks the OS to pick one). Rejects when the bind fails, so a caller that
|
|
20
|
+
* wants a fallback can retry with another port.
|
|
21
|
+
*/
|
|
22
|
+
export async function bindLoopback(server: Server, port: number): Promise<number> {
|
|
23
|
+
await new Promise<void>((resolve, reject) => {
|
|
24
|
+
const onError = (error: Error) => reject(error);
|
|
25
|
+
server.once("error", onError);
|
|
26
|
+
server.listen(port, "127.0.0.1", () => {
|
|
27
|
+
server.off("error", onError);
|
|
28
|
+
resolve();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
// An accept-level failure (EMFILE and friends) is emitted on the server, and an
|
|
32
|
+
// `error` event with no listener would take the host down mid-session. The
|
|
33
|
+
// affected request fails on its own and is answered as an error by the caller.
|
|
34
|
+
server.on("error", () => {});
|
|
35
|
+
const address = server.address();
|
|
36
|
+
return typeof address === "object" && address ? address.port : port;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Write one routed response: headers first, then no body for `HEAD`, the file
|
|
41
|
+
* for a `filePath` response, or the buffered body.
|
|
42
|
+
*
|
|
43
|
+
* A `stream` response is deliberately not handled here: keeping a connection
|
|
44
|
+
* open is the owning server's lifecycle, so it must check `stream` before
|
|
45
|
+
* calling this.
|
|
46
|
+
*
|
|
47
|
+
* `afterFlush` runs once a buffered body is on the wire, which is the point a
|
|
48
|
+
* server may resolve a session without the client seeing a dropped connection.
|
|
49
|
+
* A streamed file has no such single point, so `respond` never calls it for one.
|
|
50
|
+
*/
|
|
51
|
+
export function respond(
|
|
52
|
+
request: IncomingMessage,
|
|
53
|
+
response: ServerResponse,
|
|
54
|
+
result: ServeResponse,
|
|
55
|
+
afterFlush?: () => void,
|
|
56
|
+
): void {
|
|
57
|
+
response.writeHead(result.status, result.headers);
|
|
58
|
+
if ((request.method ?? "GET").toUpperCase() === "HEAD") {
|
|
59
|
+
response.end(afterFlush);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (result.filePath) {
|
|
63
|
+
const stream = createReadStream(result.filePath);
|
|
64
|
+
stream.on("error", () => response.destroy());
|
|
65
|
+
stream.pipe(response);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
response.end(result.body ?? "", afterFlush);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* An idempotent `close()` for `server`.
|
|
73
|
+
*
|
|
74
|
+
* The second call resolves at once, so a shutdown path and a stop path can both
|
|
75
|
+
* await it. `closeAllConnections` is not on the `Server` type of every supported
|
|
76
|
+
* Node version, so it is probed: without it a keep-alive connection could hold
|
|
77
|
+
* the close open.
|
|
78
|
+
*/
|
|
79
|
+
export function createCloser(server: Server): () => Promise<void> {
|
|
80
|
+
let closed = false;
|
|
81
|
+
return () =>
|
|
82
|
+
new Promise<void>((resolve) => {
|
|
83
|
+
if (closed) {
|
|
84
|
+
resolve();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
closed = true;
|
|
88
|
+
server.close(() => resolve());
|
|
89
|
+
(server as { closeAllConnections?: () => void }).closeAllConnections?.();
|
|
90
|
+
});
|
|
91
|
+
}
|
package/lib/lines.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line splitting for a stream of untrusted output.
|
|
3
|
+
*
|
|
4
|
+
* A child's stdout, or a file read, arrives in arbitrary byte chunks: a chunk
|
|
5
|
+
* boundary can fall in the middle of a line, and — the case a naive
|
|
6
|
+
* `chunk.toString()` gets wrong — in the middle of a multi-byte UTF-8
|
|
7
|
+
* character. This buffers the bytes and decodes only at a newline, so a split
|
|
8
|
+
* character survives intact.
|
|
9
|
+
*
|
|
10
|
+
* `\r` is not treated as a break: a progress rewrite belongs to the sanitizer
|
|
11
|
+
* (`sanitizeUntrustedLine` in `lib/format.ts`, which keeps the text after the
|
|
12
|
+
* last carriage return), and breaking on it here would turn one rewritten line
|
|
13
|
+
* into several.
|
|
14
|
+
*
|
|
15
|
+
* Value-only: the buffer lives on the instance, so each reader owns one.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Bytes buffered for one incomplete line before it is emitted anyway. */
|
|
19
|
+
export const MAX_LINE_BYTES = 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
export interface LineSplitterOptions {
|
|
22
|
+
/** Emit an unfinished line once it grows past this many bytes. Default 1 MiB. */
|
|
23
|
+
maxLineBytes?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class LineSplitter {
|
|
27
|
+
private pending: Buffer = Buffer.alloc(0);
|
|
28
|
+
private readonly maxLineBytes: number;
|
|
29
|
+
|
|
30
|
+
constructor(options: LineSplitterOptions = {}) {
|
|
31
|
+
this.maxLineBytes = options.maxLineBytes ?? MAX_LINE_BYTES;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Bytes held for the unfinished line. */
|
|
35
|
+
get pendingBytes(): number {
|
|
36
|
+
return this.pending.length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Feed one chunk and take every complete line it finishes.
|
|
41
|
+
*
|
|
42
|
+
* A line far longer than any display can show is emitted once it reaches the
|
|
43
|
+
* cap instead of being dropped, so a program that never prints a newline still
|
|
44
|
+
* produces output.
|
|
45
|
+
*/
|
|
46
|
+
push(chunk: Uint8Array | string): string[] {
|
|
47
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk);
|
|
48
|
+
const combined = this.pending.length > 0 ? Buffer.concat([this.pending, bytes]) : bytes;
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
let start = 0;
|
|
51
|
+
for (let newline = combined.indexOf(0x0a); newline >= 0; newline = combined.indexOf(0x0a, start)) {
|
|
52
|
+
lines.push(combined.subarray(start, newline).toString("utf8"));
|
|
53
|
+
start = newline + 1;
|
|
54
|
+
}
|
|
55
|
+
let rest = combined.subarray(start);
|
|
56
|
+
if (rest.length > this.maxLineBytes) {
|
|
57
|
+
lines.push(rest.toString("utf8"));
|
|
58
|
+
rest = combined.subarray(0, 0);
|
|
59
|
+
}
|
|
60
|
+
// Copy, so a caller that reuses its read buffer cannot rewrite the tail.
|
|
61
|
+
this.pending = Buffer.from(rest);
|
|
62
|
+
return lines;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Take the unfinished line, for a stream that has ended. */
|
|
66
|
+
flush(): string[] {
|
|
67
|
+
if (this.pending.length === 0) return [];
|
|
68
|
+
const line = this.pending.toString("utf8");
|
|
69
|
+
this.pending = Buffer.alloc(0);
|
|
70
|
+
return [line];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Drop the unfinished line, for a source that was truncated or replaced. */
|
|
74
|
+
reset(): void {
|
|
75
|
+
this.pending = Buffer.alloc(0);
|
|
76
|
+
}
|
|
77
|
+
}
|