@stablekernel/pi-background-run 0.4.0 → 0.5.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.
@@ -19,10 +19,12 @@ import {
19
19
  readFileSync,
20
20
  writeFileSync,
21
21
  existsSync,
22
+ mkdirSync,
23
+ appendFileSync,
22
24
  readdirSync,
23
25
  } from "node:fs";
24
26
  import { join } from "node:path";
25
- import { tmpdir } from "node:os";
27
+ import { homedir, tmpdir } from "node:os";
26
28
  import { pathToFileURL } from "node:url";
27
29
 
28
30
  interface CapturedWake {
@@ -30,7 +32,13 @@ interface CapturedWake {
30
32
  options?: Record<string, unknown>;
31
33
  }
32
34
 
33
- function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
35
+ function makeFakePi(
36
+ opts: {
37
+ idle?: boolean;
38
+ priorEntries?: any[];
39
+ ctxFields?: Record<string, unknown>;
40
+ } = {},
41
+ ): {
34
42
  pi: any;
35
43
  wakes: CapturedWake[];
36
44
  entries: any[];
@@ -60,6 +68,7 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
60
68
  hasUI: false,
61
69
  ui: { notify() {}, setWidget() {}, setStatus() {} },
62
70
  sessionManager: { getEntries: () => entries },
71
+ ...(opts.ctxFields as Record<string, unknown> | undefined),
63
72
  };
64
73
  const pi = {
65
74
  sendUserMessage(text: string, options?: Record<string, unknown>) {
@@ -1770,3 +1779,791 @@ test("formatSince: same-day shows time only; older days include the date", async
1770
1779
  assert.match(prevYearStr, /Dec 30/);
1771
1780
  assert.match(prevYearStr, /08:00:00/);
1772
1781
  });
1782
+
1783
+ // ── Project-local jobs dir ──────────────────────────────────────────────────
1784
+
1785
+ test("resolveJobsDirPath: relative resolves against a project root; absolute and no-root fall back", async () => {
1786
+ const mod = await import(
1787
+ pathToFileURL(join(process.cwd(), "extension/index.ts")).href
1788
+ );
1789
+ const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-"));
1790
+ const scratch = mkdtempSync(join(tmpdir(), "pi-bgrun-scratch-"));
1791
+ try {
1792
+ mkdirSync(join(proj, ".git"), { recursive: true });
1793
+
1794
+ // absolute → used as-is, never flagged project-local (older configs keep
1795
+ // working unchanged — the migration guarantee)
1796
+ const absPath = join(proj, "abs-jobs");
1797
+ const abs = mod.resolveJobsDirPath(absPath, { cwd: proj });
1798
+ assert.equal(abs.dir, absPath);
1799
+ assert.equal(abs.projectLocal, false);
1800
+
1801
+ // relative + project root → resolved against the root, flagged project-local
1802
+ const rel = mod.resolveJobsDirPath(".pi-bgrun/jobs", { cwd: proj });
1803
+ assert.equal(rel.dir, join(proj, ".pi-bgrun", "jobs"));
1804
+ assert.equal(rel.projectLocal, true);
1805
+
1806
+ // unset → global default
1807
+ const none = mod.resolveJobsDirPath(undefined, { cwd: proj });
1808
+ assert.equal(none.dir, join(homedir(), ".pi-bgrun", "jobs"));
1809
+ assert.equal(none.projectLocal, false);
1810
+
1811
+ // relative + cwd that is not a project → global fallback, never cwd-relative
1812
+ const fb = mod.resolveJobsDirPath(".pi-bgrun/jobs", { cwd: scratch });
1813
+ assert.equal(fb.dir, join(homedir(), ".pi-bgrun", "jobs"));
1814
+ assert.equal(fb.projectLocal, false);
1815
+ } finally {
1816
+ rmSync(proj, { recursive: true, force: true });
1817
+ rmSync(scratch, { recursive: true, force: true });
1818
+ }
1819
+ });
1820
+
1821
+ test("ensureGitExcluded: appends the jobs dir pattern to .git/info/exclude once per dir", async () => {
1822
+ const mod = await import(
1823
+ pathToFileURL(join(process.cwd(), "extension/index.ts")).href
1824
+ );
1825
+ const repo = mkdtempSync(join(tmpdir(), "pi-bgrun-repo-"));
1826
+ try {
1827
+ mkdirSync(join(repo, ".git", "info"), { recursive: true });
1828
+ mod.ensureGitExcluded(join(repo, ".pi-bgrun", "jobs"));
1829
+ mod.ensureGitExcluded(join(repo, ".pi-bgrun", "jobs"));
1830
+ // a second, different jobs dir under the same repo adds its own pattern
1831
+ mod.ensureGitExcluded(join(repo, ".pi-bgrun", "other"));
1832
+ const exclude = readFileSync(join(repo, ".git", "info", "exclude"), "utf8");
1833
+ assert.match(exclude, /# pi-bgrun job logs/);
1834
+ assert.equal(
1835
+ exclude.split("\n").filter((l) => l.trim() === ".pi-bgrun/jobs/").length,
1836
+ 1,
1837
+ "pattern appears exactly once",
1838
+ );
1839
+ assert.ok(exclude.split("\n").includes(".pi-bgrun/other/"));
1840
+ } finally {
1841
+ rmSync(repo, { recursive: true, force: true });
1842
+ }
1843
+ });
1844
+
1845
+ test("ensureGitExcluded: linked worktree (.git file) writes to the pointed git dir", async () => {
1846
+ const mod = await import(
1847
+ pathToFileURL(join(process.cwd(), "extension/index.ts")).href
1848
+ );
1849
+ const wt = mkdtempSync(join(tmpdir(), "pi-bgrun-wt-"));
1850
+ const gd = mkdtempSync(join(tmpdir(), "pi-bgrun-gitdir-"));
1851
+ try {
1852
+ writeFileSync(join(wt, ".git"), `gitdir: ${gd}\n`);
1853
+ mod.ensureGitExcluded(join(wt, ".pi-bgrun", "jobs"));
1854
+ const exclude = readFileSync(join(gd, "info", "exclude"), "utf8");
1855
+ assert.match(exclude, /^\.pi-bgrun\/jobs\/$/m);
1856
+ // nothing was created inside the worktree's own .git (it's a file)
1857
+ assert.ok(!existsSync(join(wt, ".git", "info")));
1858
+ } finally {
1859
+ rmSync(wt, { recursive: true, force: true });
1860
+ rmSync(gd, { recursive: true, force: true });
1861
+ }
1862
+ });
1863
+
1864
+ test("ensureGitExcluded: gitdir pointer with spaces in the path", async () => {
1865
+ const mod = await import(
1866
+ pathToFileURL(join(process.cwd(), "extension/index.ts")).href
1867
+ );
1868
+ const wt = mkdtempSync(join(tmpdir(), "pi-bgrun-wt-"));
1869
+ const gd = join(tmpdir(), "pi-bgrun git dir with spaces");
1870
+ mkdirSync(gd, { recursive: true });
1871
+ try {
1872
+ writeFileSync(join(wt, ".git"), `gitdir: ${gd}\n`);
1873
+ assert.equal(mod.ensureGitExcluded(join(wt, ".pi-bgrun", "jobs")), true);
1874
+ const exclude = readFileSync(join(gd, "info", "exclude"), "utf8");
1875
+ assert.match(exclude, /^\.pi-bgrun\/jobs\/$/m);
1876
+ } finally {
1877
+ rmSync(wt, { recursive: true, force: true });
1878
+ rmSync(gd, { recursive: true, force: true });
1879
+ }
1880
+ });
1881
+
1882
+ test("ensureGitExcluded: retries after a transient failure — memoizes only on success", async () => {
1883
+ const mod = await import(
1884
+ pathToFileURL(join(process.cwd(), "extension/index.ts")).href
1885
+ );
1886
+ const repo = mkdtempSync(join(tmpdir(), "pi-bgrun-repo-"));
1887
+ try {
1888
+ mkdirSync(join(repo, ".git", "info"), { recursive: true });
1889
+ // Block the exclude path with a directory → the append fails (EISDIR)
1890
+ mkdirSync(join(repo, ".git", "info", "exclude"));
1891
+ const jobsDir = join(repo, ".pi-bgrun", "jobs");
1892
+ assert.equal(mod.ensureGitExcluded(jobsDir), false);
1893
+
1894
+ // Unblock: the next call must retry (failure was not memoized) and succeed
1895
+ rmSync(join(repo, ".git", "info", "exclude"), { recursive: true });
1896
+ assert.equal(mod.ensureGitExcluded(jobsDir), true);
1897
+ const exclude = readFileSync(join(repo, ".git", "info", "exclude"), "utf8");
1898
+ assert.match(exclude, /^\.pi-bgrun\/jobs\/$/m);
1899
+ } finally {
1900
+ rmSync(repo, { recursive: true, force: true });
1901
+ }
1902
+ });
1903
+
1904
+ test("bgrun: relative jobsDir in project config → project-local log + auto git-exclude", async () => {
1905
+ const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-"));
1906
+ delete process.env.PI_BGRUN_DIR;
1907
+ try {
1908
+ mkdirSync(join(proj, ".git"), { recursive: true });
1909
+ mkdirSync(join(proj, ".pi"), { recursive: true });
1910
+ writeFileSync(
1911
+ join(proj, ".pi", "pi-bgrun.json"),
1912
+ JSON.stringify({ jobsDir: ".pi-bgrun/jobs" }),
1913
+ );
1914
+ const { pi, wakes, tools, ctx } = makeFakePi({
1915
+ ctxFields: { cwd: proj, isProjectTrusted: () => true },
1916
+ });
1917
+ await loadExtension(pi);
1918
+ const bgrun = tools.get("bgrun")!;
1919
+
1920
+ const res = await bgrun.execute(
1921
+ "call-1",
1922
+ { command: "echo project-local" },
1923
+ undefined,
1924
+ undefined,
1925
+ ctx,
1926
+ );
1927
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
1928
+ [])[1];
1929
+ assert.ok(id, "got a job id");
1930
+
1931
+ await waitForWakes(wakes, 1);
1932
+
1933
+ const logPath = join(proj, ".pi-bgrun", "jobs", `${id}.log`);
1934
+ assert.ok(existsSync(logPath), "log written inside the project");
1935
+ assert.match(readFileSync(logPath, "utf8"), /project-local/);
1936
+
1937
+ const exclude = join(proj, ".git", "info", "exclude");
1938
+ assert.ok(existsSync(exclude), "exclude file created");
1939
+ assert.match(readFileSync(exclude, "utf8"), /^\.pi-bgrun\/jobs\/$/m);
1940
+ } finally {
1941
+ delete process.env.PI_BGRUN_DIR;
1942
+ rmSync(proj, { recursive: true, force: true });
1943
+ }
1944
+ });
1945
+
1946
+ test("bgtail: prefers the session record's logPath when the jobsDir config changes", async () => {
1947
+ const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-"));
1948
+ delete process.env.PI_BGRUN_DIR;
1949
+ try {
1950
+ mkdirSync(join(proj, ".git"), { recursive: true });
1951
+ mkdirSync(join(proj, ".pi"), { recursive: true });
1952
+ writeFileSync(
1953
+ join(proj, ".pi", "pi-bgrun.json"),
1954
+ JSON.stringify({ jobsDir: ".pi-bgrun/jobs" }),
1955
+ );
1956
+ const { pi, wakes, tools, ctx } = makeFakePi({
1957
+ ctxFields: { cwd: proj, isProjectTrusted: () => true },
1958
+ });
1959
+ await loadExtension(pi);
1960
+ const bgrun = tools.get("bgrun")!;
1961
+ const bgtail = tools.get("bgtail")!;
1962
+
1963
+ const res = await bgrun.execute(
1964
+ "call-1",
1965
+ { command: "echo migrated-log" },
1966
+ undefined,
1967
+ undefined,
1968
+ ctx,
1969
+ );
1970
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
1971
+ [])[1];
1972
+ assert.ok(id, "got a job id");
1973
+ await waitForWakes(wakes, 1);
1974
+
1975
+ // A ctx with no project config/trust now resolves the jobs dir to the
1976
+ // GLOBAL default — only the session record's logPath can still find the
1977
+ // log (the mid-upgrade config-change scenario).
1978
+ const plainCtx = { ...ctx, cwd: undefined, isProjectTrusted: undefined };
1979
+ const tail = await bgtail.execute(
1980
+ "call-2",
1981
+ { id, lines: 10 },
1982
+ undefined,
1983
+ undefined,
1984
+ plainCtx,
1985
+ );
1986
+ assert.equal(tail.details.notFound, false);
1987
+ assert.match(tail.content[0].text as string, /migrated-log/);
1988
+ } finally {
1989
+ delete process.env.PI_BGRUN_DIR;
1990
+ rmSync(proj, { recursive: true, force: true });
1991
+ }
1992
+ });
1993
+
1994
+ // ── bggrep ──────────────────────────────────────────────────────────────────
1995
+
1996
+ test("bggrep: line-numbered matches; explicit pattern wins; default pattern; no-match case", async () => {
1997
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1998
+ process.env.PI_BGRUN_DIR = dir;
1999
+ try {
2000
+ const { pi, wakes, tools, ctx } = makeFakePi();
2001
+ await loadExtension(pi);
2002
+ const bgrun = tools.get("bgrun")!;
2003
+ const bggrep = tools.get("bggrep")!;
2004
+
2005
+ const res = await bgrun.execute(
2006
+ "c1",
2007
+ { command: "printf 'alpha\\nerror: boom BANANA\\nomega\\n'" },
2008
+ undefined,
2009
+ undefined,
2010
+ ctx,
2011
+ );
2012
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2013
+ [])[1];
2014
+ assert.ok(id, "got a job id");
2015
+ await waitForWakes(wakes, 1);
2016
+
2017
+ // explicit pattern → only matching lines, with line numbers
2018
+ const g = await bggrep.execute(
2019
+ "c2",
2020
+ { id, pattern: "BANANA" },
2021
+ undefined,
2022
+ undefined,
2023
+ ctx,
2024
+ );
2025
+ assert.equal(g.details.matches, 1);
2026
+ assert.equal(g.details.notFound, false);
2027
+ assert.match(g.content[0].text as string, /L2: error: boom BANANA/);
2028
+ assert.doesNotMatch(g.content[0].text as string, /alpha|omega/);
2029
+
2030
+ // default pattern (no pattern passed) catches the failure signature
2031
+ const g2 = await bggrep.execute("c3", { id }, undefined, undefined, ctx);
2032
+ assert.equal(g2.details.matches, 1);
2033
+ assert.match(g2.content[0].text as string, /1 match for \//);
2034
+ assert.equal(
2035
+ g2.details.pattern,
2036
+ "--- FAIL:|^FAIL\\b|^panic:|fatal error:|AssertionError|Error:|error:|make: \\*\\*\\*.*Error|✗|✖",
2037
+ );
2038
+
2039
+ // a log with no failure signatures → clean no-match (not an error)
2040
+ const res2 = await bgrun.execute(
2041
+ "c4",
2042
+ { command: "echo all clear, nothing to see" },
2043
+ undefined,
2044
+ undefined,
2045
+ ctx,
2046
+ );
2047
+ const id2 = ((res2.content[0].text as string).match(/^started: ([^\n]+)/) ||
2048
+ [])[1];
2049
+ await waitForWakes(wakes, 2);
2050
+ const g3 = await bggrep.execute(
2051
+ "c5",
2052
+ { id: id2 },
2053
+ undefined,
2054
+ undefined,
2055
+ ctx,
2056
+ );
2057
+ assert.equal(g3.details.matches, 0);
2058
+ assert.equal(g3.isError, undefined);
2059
+ assert.match(g3.content[0].text as string, /— none/);
2060
+ } finally {
2061
+ delete process.env.PI_BGRUN_DIR;
2062
+ rmSync(dir, { recursive: true, force: true });
2063
+ }
2064
+ });
2065
+
2066
+ test("bggrep: context lines with gap markers between distant matches", async () => {
2067
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2068
+ process.env.PI_BGRUN_DIR = dir;
2069
+ try {
2070
+ const { pi, wakes, tools, ctx } = makeFakePi();
2071
+ await loadExtension(pi);
2072
+ const bgrun = tools.get("bgrun")!;
2073
+ const bggrep = tools.get("bggrep")!;
2074
+
2075
+ const res = await bgrun.execute(
2076
+ "c1",
2077
+ {
2078
+ command:
2079
+ "printf 'l1\\nMATCH one\\nl3\\nl4\\nl5\\nl6\\nl7\\nMATCH two\\nl9\\n'",
2080
+ },
2081
+ undefined,
2082
+ undefined,
2083
+ ctx,
2084
+ );
2085
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2086
+ [])[1];
2087
+ assert.ok(id, "got a job id");
2088
+ await waitForWakes(wakes, 1);
2089
+
2090
+ const g = await bggrep.execute(
2091
+ "c2",
2092
+ { id, pattern: "MATCH", context: 1 },
2093
+ undefined,
2094
+ undefined,
2095
+ ctx,
2096
+ );
2097
+ assert.equal(g.details.matches, 2);
2098
+ const text = g.content[0].text as string;
2099
+ assert.match(text, /L2: MATCH one/);
2100
+ assert.match(text, /L1: l1/); // context before
2101
+ assert.match(text, /L8: MATCH two/);
2102
+ assert.match(text, /L9: l9/); // context after
2103
+ assert.match(text, /…\[3 lines skipped\]…/); // l4-l6 between the windows
2104
+ } finally {
2105
+ delete process.env.PI_BGRUN_DIR;
2106
+ rmSync(dir, { recursive: true, force: true });
2107
+ }
2108
+ });
2109
+
2110
+ test("bggrep: invalid pattern errors clearly", async () => {
2111
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2112
+ process.env.PI_BGRUN_DIR = dir;
2113
+ try {
2114
+ const { pi, wakes, tools, ctx } = makeFakePi();
2115
+ await loadExtension(pi);
2116
+ const bgrun = tools.get("bgrun")!;
2117
+ const bggrep = tools.get("bggrep")!;
2118
+ const res = await bgrun.execute(
2119
+ "c1",
2120
+ { command: "echo hi" },
2121
+ undefined,
2122
+ undefined,
2123
+ ctx,
2124
+ );
2125
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2126
+ [])[1];
2127
+ await waitForWakes(wakes, 1);
2128
+ await assert.rejects(
2129
+ bggrep.execute(
2130
+ "c2",
2131
+ { id, pattern: "([unclosed" },
2132
+ undefined,
2133
+ undefined,
2134
+ ctx,
2135
+ ),
2136
+ /bggrep: invalid pattern/,
2137
+ );
2138
+ } finally {
2139
+ delete process.env.PI_BGRUN_DIR;
2140
+ rmSync(dir, { recursive: true, force: true });
2141
+ }
2142
+ });
2143
+
2144
+ test("bggrep: caps at 50 matches with a not-shown note", async () => {
2145
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2146
+ process.env.PI_BGRUN_DIR = dir;
2147
+ try {
2148
+ const { pi, wakes, tools, ctx } = makeFakePi();
2149
+ await loadExtension(pi);
2150
+ const bgrun = tools.get("bgrun")!;
2151
+ const bggrep = tools.get("bggrep")!;
2152
+ const res = await bgrun.execute(
2153
+ "c1",
2154
+ { command: 'for i in $(seq 1 60); do echo "boom $i"; done' },
2155
+ undefined,
2156
+ undefined,
2157
+ ctx,
2158
+ );
2159
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2160
+ [])[1];
2161
+ await waitForWakes(wakes, 1);
2162
+ const g = await bggrep.execute(
2163
+ "c2",
2164
+ { id, pattern: "boom" },
2165
+ undefined,
2166
+ undefined,
2167
+ ctx,
2168
+ );
2169
+ assert.equal(g.details.matches, 60);
2170
+ assert.equal(g.details.capped, true);
2171
+ assert.match(
2172
+ g.content[0].text as string,
2173
+ /showing first 50; 10 more not shown/,
2174
+ );
2175
+ assert.match(g.content[0].text as string, /L50: boom 50/);
2176
+ assert.doesNotMatch(g.content[0].text as string, /L51: boom 51/);
2177
+ } finally {
2178
+ delete process.env.PI_BGRUN_DIR;
2179
+ rmSync(dir, { recursive: true, force: true });
2180
+ }
2181
+ });
2182
+
2183
+ test("bggrep: prefers the session record's logPath when the jobsDir config changes", async () => {
2184
+ const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-"));
2185
+ delete process.env.PI_BGRUN_DIR;
2186
+ try {
2187
+ mkdirSync(join(proj, ".git"), { recursive: true });
2188
+ mkdirSync(join(proj, ".pi"), { recursive: true });
2189
+ writeFileSync(
2190
+ join(proj, ".pi", "pi-bgrun.json"),
2191
+ JSON.stringify({ jobsDir: ".pi-bgrun/jobs" }),
2192
+ );
2193
+ const { pi, wakes, tools, ctx } = makeFakePi({
2194
+ ctxFields: { cwd: proj, isProjectTrusted: () => true },
2195
+ });
2196
+ await loadExtension(pi);
2197
+ const bgrun = tools.get("bgrun")!;
2198
+ const bggrep = tools.get("bggrep")!;
2199
+ const res = await bgrun.execute(
2200
+ "c1",
2201
+ { command: "echo pattern-target-line" },
2202
+ undefined,
2203
+ undefined,
2204
+ ctx,
2205
+ );
2206
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2207
+ [])[1];
2208
+ await waitForWakes(wakes, 1);
2209
+
2210
+ const plainCtx = { ...ctx, cwd: undefined, isProjectTrusted: undefined };
2211
+ const g = await bggrep.execute(
2212
+ "c2",
2213
+ { id, pattern: "pattern-target" },
2214
+ undefined,
2215
+ undefined,
2216
+ plainCtx,
2217
+ );
2218
+ assert.equal(g.details.notFound, false);
2219
+ assert.equal(g.details.matches, 1);
2220
+ } finally {
2221
+ delete process.env.PI_BGRUN_DIR;
2222
+ rmSync(proj, { recursive: true, force: true });
2223
+ }
2224
+ });
2225
+
2226
+ // ── bgtail delta tailing ────────────────────────────────────────────────────
2227
+
2228
+ test("bgtail: delta tailing — first read full tail, then only new lines, then none", async () => {
2229
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2230
+ process.env.PI_BGRUN_DIR = dir;
2231
+ try {
2232
+ const { pi, wakes, tools, ctx } = makeFakePi();
2233
+ await loadExtension(pi);
2234
+ const bgrun = tools.get("bgrun")!;
2235
+ const bgtail = tools.get("bgtail")!;
2236
+ const res = await bgrun.execute(
2237
+ "c1",
2238
+ { command: "echo first line" },
2239
+ undefined,
2240
+ undefined,
2241
+ ctx,
2242
+ );
2243
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2244
+ [])[1];
2245
+ assert.ok(id, "got a job id");
2246
+ await waitForWakes(wakes, 1);
2247
+ const logPath = join(dir, `${id}.log`);
2248
+
2249
+ // First read: full tail, no delta header
2250
+ const t1 = await bgtail.execute("c2", { id }, undefined, undefined, ctx);
2251
+ assert.match(t1.content[0].text as string, /first line/);
2252
+ assert.equal(t1.details.newLines, undefined);
2253
+ assert.doesNotMatch(
2254
+ t1.content[0].text as string,
2255
+ /new lines since last read/,
2256
+ );
2257
+
2258
+ // Log grows: only the new lines come back, with a +N header
2259
+ appendFileSync(logPath, "appended-A\nappended-B\n");
2260
+ const t2 = await bgtail.execute("c3", { id }, undefined, undefined, ctx);
2261
+ const text2 = t2.content[0].text as string;
2262
+ assert.match(text2, /\+2 new lines since last read/);
2263
+ assert.match(text2, /appended-A/);
2264
+ assert.match(text2, /appended-B/);
2265
+ assert.doesNotMatch(text2, /first line/);
2266
+ assert.equal(t2.details.newLines, 2);
2267
+
2268
+ // Nothing new: a tiny no-new-lines response (cheap polling)
2269
+ const t3 = await bgtail.execute("c4", { id }, undefined, undefined, ctx);
2270
+ assert.match(t3.content[0].text as string, /no new lines since last read/);
2271
+ assert.equal(t3.details.linesShown, 0);
2272
+ } finally {
2273
+ delete process.env.PI_BGRUN_DIR;
2274
+ rmSync(dir, { recursive: true, force: true });
2275
+ }
2276
+ });
2277
+
2278
+ test("bgtail: raw:true keeps the verbatim window but still advances the bookmark", async () => {
2279
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2280
+ process.env.PI_BGRUN_DIR = dir;
2281
+ try {
2282
+ const { pi, wakes, tools, ctx } = makeFakePi();
2283
+ await loadExtension(pi);
2284
+ const bgrun = tools.get("bgrun")!;
2285
+ const bgtail = tools.get("bgtail")!;
2286
+ const res = await bgrun.execute(
2287
+ "c1",
2288
+ { command: "echo baseline" },
2289
+ undefined,
2290
+ undefined,
2291
+ ctx,
2292
+ );
2293
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2294
+ [])[1];
2295
+ await waitForWakes(wakes, 1);
2296
+ const logPath = join(dir, `${id}.log`);
2297
+
2298
+ appendFileSync(logPath, "post-raw line\n");
2299
+ const r = await bgtail.execute(
2300
+ "c2",
2301
+ { id, lines: 3, raw: true },
2302
+ undefined,
2303
+ undefined,
2304
+ ctx,
2305
+ );
2306
+ assert.match(r.content[0].text as string, /post-raw line/);
2307
+ assert.equal(r.details.condensed, false);
2308
+
2309
+ // The raw read advanced the bookmark → the next condensed read is empty
2310
+ const t = await bgtail.execute("c3", { id }, undefined, undefined, ctx);
2311
+ assert.match(t.content[0].text as string, /no new lines since last read/);
2312
+ } finally {
2313
+ delete process.env.PI_BGRUN_DIR;
2314
+ rmSync(dir, { recursive: true, force: true });
2315
+ }
2316
+ });
2317
+
2318
+ test("bgtail: a shrunken log resets to a full tail with a note", async () => {
2319
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2320
+ process.env.PI_BGRUN_DIR = dir;
2321
+ try {
2322
+ const { pi, wakes, tools, ctx } = makeFakePi();
2323
+ await loadExtension(pi);
2324
+ const bgrun = tools.get("bgrun")!;
2325
+ const bgtail = tools.get("bgtail")!;
2326
+ const res = await bgrun.execute(
2327
+ "c1",
2328
+ { command: "echo long original content line" },
2329
+ undefined,
2330
+ undefined,
2331
+ ctx,
2332
+ );
2333
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2334
+ [])[1];
2335
+ await waitForWakes(wakes, 1);
2336
+ const logPath = join(dir, `${id}.log`);
2337
+
2338
+ // First read sets the bookmark; then the log is replaced by a shorter one
2339
+ await bgtail.execute("c2", { id }, undefined, undefined, ctx);
2340
+ writeFileSync(logPath, "tiny replacement\n");
2341
+ const t = await bgtail.execute("c3", { id }, undefined, undefined, ctx);
2342
+ const text = t.content[0].text as string;
2343
+ assert.match(text, /log shrank since last read — showing full tail/);
2344
+ assert.match(text, /tiny replacement/);
2345
+ } finally {
2346
+ delete process.env.PI_BGRUN_DIR;
2347
+ rmSync(dir, { recursive: true, force: true });
2348
+ }
2349
+ });
2350
+
2351
+ test("bgtail: a replaced log with the same line count resets to a full tail", async () => {
2352
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2353
+ process.env.PI_BGRUN_DIR = dir;
2354
+ try {
2355
+ const { pi, wakes, tools, ctx } = makeFakePi();
2356
+ await loadExtension(pi);
2357
+ const bgrun = tools.get("bgrun")!;
2358
+ const bgtail = tools.get("bgtail")!;
2359
+ const res = await bgrun.execute(
2360
+ "c1",
2361
+ { command: "printf 'aaaa\\nbbbb\\ncccc\\n'" },
2362
+ undefined,
2363
+ undefined,
2364
+ ctx,
2365
+ );
2366
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2367
+ [])[1];
2368
+ await waitForWakes(wakes, 1);
2369
+ const logPath = join(dir, `${id}.log`);
2370
+
2371
+ // First read sets the bookmark (3 content lines, first line "aaaa")
2372
+ await bgtail.execute("c2", { id }, undefined, undefined, ctx);
2373
+ // Replacement: SAME line count, LARGER byte size (so the shrink checks
2374
+ // cannot fire), different first line — only the first-line detector
2375
+ // (append-only logs never mutate line 0) can catch this.
2376
+ writeFileSync(
2377
+ logPath,
2378
+ "xxxxxxxxxxxxxxxxxx\nyyyyyyyyyyyyyyyyyy\nzzzzzzzzzzzzzzzzzz\n",
2379
+ );
2380
+ const t = await bgtail.execute("c3", { id }, undefined, undefined, ctx);
2381
+ const text = t.content[0].text as string;
2382
+ assert.match(text, /log was replaced since last read — showing full tail/);
2383
+ assert.match(text, /xxxxxxxxxxxxxxxxxx/);
2384
+ } finally {
2385
+ delete process.env.PI_BGRUN_DIR;
2386
+ rmSync(dir, { recursive: true, force: true });
2387
+ }
2388
+ });
2389
+
2390
+ test("bggrep and bgtail normalize CRLF logs", async () => {
2391
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2392
+ process.env.PI_BGRUN_DIR = dir;
2393
+ try {
2394
+ const { pi, wakes, tools, ctx } = makeFakePi();
2395
+ await loadExtension(pi);
2396
+ const bgrun = tools.get("bgrun")!;
2397
+ const bgtail = tools.get("bgtail")!;
2398
+ const bggrep = tools.get("bggrep")!;
2399
+ const res = await bgrun.execute(
2400
+ "c1",
2401
+ { command: "echo something" },
2402
+ undefined,
2403
+ undefined,
2404
+ ctx,
2405
+ );
2406
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2407
+ [])[1];
2408
+ await waitForWakes(wakes, 1);
2409
+ const logPath = join(dir, `${id}.log`);
2410
+
2411
+ writeFileSync(logPath, "alpha\r\nerror: boom\r\nomega\r\n");
2412
+ // A $-anchored pattern must match despite the CRLF source
2413
+ const g = await bggrep.execute(
2414
+ "c2",
2415
+ { id, pattern: "boom$" },
2416
+ undefined,
2417
+ undefined,
2418
+ ctx,
2419
+ );
2420
+ assert.match(g.content[0].text as string, /L2: error: boom/);
2421
+ // And no stray \r leaks into either tool's output
2422
+ assert.ok(!(g.content[0].text as string).includes("\r"));
2423
+ const t = await bgtail.execute("c3", { id }, undefined, undefined, ctx);
2424
+ assert.ok(!(t.content[0].text as string).includes("\r"));
2425
+ assert.match(t.content[0].text as string, /error: boom/);
2426
+ } finally {
2427
+ delete process.env.PI_BGRUN_DIR;
2428
+ rmSync(dir, { recursive: true, force: true });
2429
+ }
2430
+ });
2431
+
2432
+ test("bggrep: empty log reports zero lines, and a missing log is notFound", async () => {
2433
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2434
+ process.env.PI_BGRUN_DIR = dir;
2435
+ try {
2436
+ const { pi, wakes, tools, ctx } = makeFakePi();
2437
+ await loadExtension(pi);
2438
+ const bgrun = tools.get("bgrun")!;
2439
+ const bggrep = tools.get("bggrep")!;
2440
+ const res = await bgrun.execute(
2441
+ "c1",
2442
+ { command: "echo x" },
2443
+ undefined,
2444
+ undefined,
2445
+ ctx,
2446
+ );
2447
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2448
+ [])[1];
2449
+ await waitForWakes(wakes, 1);
2450
+
2451
+ writeFileSync(join(dir, `${id}.log`), "");
2452
+ const g = await bggrep.execute(
2453
+ "c2",
2454
+ { id, pattern: "Error:" },
2455
+ undefined,
2456
+ undefined,
2457
+ ctx,
2458
+ );
2459
+ assert.match(
2460
+ g.content[0].text as string,
2461
+ /0 matches for \/Error:\/ in 0 lines — none/,
2462
+ );
2463
+
2464
+ const missing = await bggrep.execute(
2465
+ "c3",
2466
+ { id: "no-such-job-123", pattern: "x" },
2467
+ undefined,
2468
+ undefined,
2469
+ ctx,
2470
+ );
2471
+ assert.equal(missing.isError, true);
2472
+ assert.equal(missing.details.notFound, true);
2473
+ } finally {
2474
+ delete process.env.PI_BGRUN_DIR;
2475
+ rmSync(dir, { recursive: true, force: true });
2476
+ }
2477
+ });
2478
+
2479
+ test("bggrep: context windows combine with the 50-match cap", async () => {
2480
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2481
+ process.env.PI_BGRUN_DIR = dir;
2482
+ try {
2483
+ const { pi, wakes, tools, ctx } = makeFakePi();
2484
+ await loadExtension(pi);
2485
+ const bgrun = tools.get("bgrun")!;
2486
+ const bggrep = tools.get("bggrep")!;
2487
+ const res = await bgrun.execute(
2488
+ "c1",
2489
+ { command: "echo x" },
2490
+ undefined,
2491
+ undefined,
2492
+ ctx,
2493
+ );
2494
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2495
+ [])[1];
2496
+ await waitForWakes(wakes, 1);
2497
+
2498
+ // 240 lines, a hit every 4th line → 60 matches (cap 50); with context: 1
2499
+ // each window is [i-1, i+1] and consecutive windows leave a 1-line gap.
2500
+ const lines: string[] = [];
2501
+ for (let i = 1; i <= 240; i++) {
2502
+ lines.push(i % 4 === 0 ? `hit ${i}` : `filler ${i}`);
2503
+ }
2504
+ writeFileSync(join(dir, `${id}.log`), lines.join("\n") + "\n");
2505
+ const r = await bggrep.execute(
2506
+ "c2",
2507
+ { id, pattern: "^hit", context: 1 },
2508
+ undefined,
2509
+ undefined,
2510
+ ctx,
2511
+ );
2512
+ const text = r.content[0].text as string;
2513
+ assert.equal(r.details.matches, 60);
2514
+ assert.equal(r.details.capped, true);
2515
+ assert.match(text, /showing first 50; 10 more not shown/);
2516
+ assert.match(text, /L4: hit 4/);
2517
+ assert.match(text, /…\[1 line skipped\]…/);
2518
+ } finally {
2519
+ delete process.env.PI_BGRUN_DIR;
2520
+ rmSync(dir, { recursive: true, force: true });
2521
+ }
2522
+ });
2523
+
2524
+ test("bgtail and bggrep clamp nonsensical numeric params", async () => {
2525
+ const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2526
+ process.env.PI_BGRUN_DIR = dir;
2527
+ try {
2528
+ const { pi, wakes, tools, ctx } = makeFakePi();
2529
+ await loadExtension(pi);
2530
+ const bgrun = tools.get("bgrun")!;
2531
+ const bgtail = tools.get("bgtail")!;
2532
+ const bggrep = tools.get("bggrep")!;
2533
+ const res = await bgrun.execute(
2534
+ "c1",
2535
+ { command: "printf 'one\\ntwo\\nthree\\nfour\\nfive\\n'" },
2536
+ undefined,
2537
+ undefined,
2538
+ ctx,
2539
+ );
2540
+ const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2541
+ [])[1];
2542
+ await waitForWakes(wakes, 1);
2543
+
2544
+ // lines: 0 must not mean "everything" (slice(-0) pitfall) — clamps to 1
2545
+ const t = await bgtail.execute(
2546
+ "c2",
2547
+ { id, lines: 0 },
2548
+ undefined,
2549
+ undefined,
2550
+ ctx,
2551
+ );
2552
+ assert.equal(t.details.linesShown, 1);
2553
+ assert.match(t.content[0].text as string, /five/);
2554
+ assert.ok(!(t.content[0].text as string).includes("four"));
2555
+
2556
+ // negative context must not drop the match lines themselves — clamps to 0
2557
+ const g = await bggrep.execute(
2558
+ "c3",
2559
+ { id, pattern: "^three", context: -1 },
2560
+ undefined,
2561
+ undefined,
2562
+ ctx,
2563
+ );
2564
+ assert.match(g.content[0].text as string, /L3: three/);
2565
+ } finally {
2566
+ delete process.env.PI_BGRUN_DIR;
2567
+ rmSync(dir, { recursive: true, force: true });
2568
+ }
2569
+ });