reslop 0.3.1 → 0.3.3

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/README.md +30 -4
  3. package/lib/agents/acp.js +309 -0
  4. package/lib/agents/agents.js +274 -438
  5. package/lib/agents/allow.js +141 -52
  6. package/lib/agents/allow.json +16 -14
  7. package/lib/agents/models.js +250 -383
  8. package/lib/agents/registry.js +437 -0
  9. package/lib/agents/run.js +234 -0
  10. package/lib/agents/script.js +93 -0
  11. package/lib/agents/transcript.js +143 -0
  12. package/lib/cli.js +2 -1
  13. package/lib/common/process.js +26 -1
  14. package/lib/dashboard/git.js +11 -1
  15. package/lib/dashboard/reports.js +80 -0
  16. package/lib/dashboard/watch.js +43 -8
  17. package/lib/dashboard/workspace.js +19 -20
  18. package/lib/dashboard/worktrees.js +145 -0
  19. package/lib/git/dir.js +50 -0
  20. package/lib/git/linked.js +152 -0
  21. package/lib/git/local.js +179 -0
  22. package/lib/git/status.js +126 -0
  23. package/lib/git/worktree.js +113 -36
  24. package/lib/input/actions.js +83 -5
  25. package/lib/input/keys.js +30 -1
  26. package/lib/input/line-edit.js +5 -1
  27. package/lib/render/agents.js +84 -34
  28. package/lib/render/branches.js +55 -27
  29. package/lib/render/field.js +19 -5
  30. package/lib/render/footer.js +14 -6
  31. package/lib/render/frame.js +16 -2
  32. package/lib/render/menu.js +103 -6
  33. package/lib/render/name-edit.js +31 -0
  34. package/lib/render/repos.js +52 -8
  35. package/lib/render/status.js +22 -4
  36. package/lib/report/run.js +16 -23
  37. package/lib/review/agent-instructions.md +2 -2
  38. package/lib/runs/process.js +3 -3
  39. package/lib/runs/runs.js +27 -4
  40. package/lib/session/agents/agents.js +220 -205
  41. package/lib/session/agents/away.js +48 -0
  42. package/lib/session/agents/job.js +23 -86
  43. package/lib/session/agents/jobs.js +14 -27
  44. package/lib/session/agents/options.js +45 -101
  45. package/lib/session/agents/plans.js +53 -0
  46. package/lib/session/agents/view.js +34 -0
  47. package/lib/session/branches.js +18 -1
  48. package/lib/session/compose.js +13 -5
  49. package/lib/session/dispatch.js +43 -4
  50. package/lib/session/editor-pointer.js +98 -0
  51. package/lib/session/files-pane.js +3 -0
  52. package/lib/session/lifecycle.js +67 -10
  53. package/lib/session/navigation.js +5 -0
  54. package/lib/session/npm/history.js +2 -1
  55. package/lib/session/npm/npm.js +89 -12
  56. package/lib/session/ops.js +3 -0
  57. package/lib/session/pointer.js +304 -46
  58. package/lib/session/session.js +15 -2
  59. package/lib/session/terminal.js +2 -2
  60. package/lib/session/text-menu.js +50 -0
  61. package/lib/session/view.js +19 -2
  62. package/lib/session/workspace.js +179 -38
  63. package/lib/session/worktrees.js +280 -0
  64. package/lib/term/themes/dark.json +4 -0
  65. package/lib/term/themes/light.json +4 -0
  66. package/package.json +2 -2
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const { spawn } = require('node:child_process');
7
+
8
+ // Where the install scripts of agent CLIs put their command
9
+ const localBin = (env = process.env) => {
10
+ const home = env.HOME || env.USERPROFILE;
11
+ return home ? path.join(home, '.local', 'bin') : '';
12
+ };
13
+
14
+ const httpsUrl = (href) => {
15
+ if (!href) throw new Error('no install script for this platform');
16
+ const url = new URL(href);
17
+ if (url.protocol !== 'https:') {
18
+ throw new Error('install script must use https');
19
+ }
20
+ return url.href;
21
+ };
22
+
23
+ // The script a vendor publishes for this platform
24
+ const scriptUrl = (script, platform = process.platform) =>
25
+ httpsUrl(platform === 'win32' ? script.win : script.unix);
26
+
27
+ const shellOf = (file, platform) => {
28
+ if (platform !== 'win32') return ['bash', [file]];
29
+ const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', file];
30
+ return ['powershell.exe', args];
31
+ };
32
+
33
+ // The script finds the directory it installs to on PATH and does not fail
34
+ const scriptEnv = (env) => {
35
+ const bin = localBin(env);
36
+ const key = Object.keys(env).find((name) => name.toUpperCase() === 'PATH');
37
+ const name = key ?? 'PATH';
38
+ const dirs = `${env[name] ?? ''}`.split(path.delimiter).filter(Boolean);
39
+ if (!bin || dirs.includes(bin)) return { ...env };
40
+ return { ...env, [name]: [bin, ...dirs].join(path.delimiter) };
41
+ };
42
+
43
+ const runFile = (cmd, args, env, spawnImpl) =>
44
+ new Promise((resolve, reject) => {
45
+ const stdio = ['ignore', 'inherit', 'inherit'];
46
+ const child = spawnImpl(cmd, args, { env, stdio, windowsHide: true });
47
+ child.on('error', reject);
48
+ child.on('close', (code) => resolve(code ?? 1));
49
+ });
50
+
51
+ const runScript = async (url, options = {}) => {
52
+ const fetchImpl = options.fetch ?? globalThis.fetch;
53
+ if (typeof fetchImpl !== 'function') {
54
+ throw new Error('fetch is not available');
55
+ }
56
+ const env = options.env ?? process.env;
57
+ const platform = options.platform ?? process.platform;
58
+ const log = options.log ?? (() => {});
59
+ const href = httpsUrl(url);
60
+ log(`Downloading ${href}…`);
61
+ const response = await fetchImpl(href);
62
+ if (!response || !response.ok) {
63
+ const status = response ? ` (${response.status})` : '';
64
+ throw new Error(`download failed${status}`);
65
+ }
66
+ const text = await response.text();
67
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'reslop-'));
68
+ try {
69
+ const name = platform === 'win32' ? 'install.ps1' : 'install.sh';
70
+ const file = path.join(dir, name);
71
+ await fs.promises.writeFile(file, text, { mode: 0o600 });
72
+ const [cmd, args] = shellOf(file, platform);
73
+ const spawnImpl = options.spawn ?? spawn;
74
+ return await runFile(cmd, args, scriptEnv(env), spawnImpl);
75
+ } finally {
76
+ await fs.promises.rm(dir, { recursive: true, force: true });
77
+ }
78
+ };
79
+
80
+ const main = async () => {
81
+ try {
82
+ process.exitCode = await runScript(process.argv[2], {
83
+ log: (line) => process.stdout.write(`${line}\n`),
84
+ });
85
+ } catch (error) {
86
+ process.stderr.write(`${error.message}\n`);
87
+ process.exitCode = 1;
88
+ }
89
+ };
90
+
91
+ if (require.main === module) void main();
92
+
93
+ module.exports = { localBin, scriptUrl, scriptEnv, runScript };
@@ -0,0 +1,143 @@
1
+ 'use strict';
2
+
3
+ const { isHashObject } = require('metautil');
4
+
5
+ const TITLE_MAX = 200;
6
+ const TOOL_MARK = { completed: '✔', failed: '✖' };
7
+ const RUNNING_MARK = '▸';
8
+ const THOUGHT_MARK = '│ ';
9
+ const WRITE_KINDS = ['edit', 'delete', 'move'];
10
+ const CHUNK_KIND = new Map([
11
+ ['agent_message_chunk', 'text'],
12
+ ['agent_thought_chunk', 'thought'],
13
+ ]);
14
+
15
+ const oneLine = (text) => {
16
+ const line = `${text ?? ''}`.replace(/\s+/g, ' ').trim();
17
+ return line.length > TITLE_MAX ? `${line.slice(0, TITLE_MAX - 1)}…` : line;
18
+ };
19
+
20
+ const chunkText = (content) => {
21
+ if (!isHashObject(content)) return '';
22
+ return content.type === 'text' ? `${content.text ?? ''}` : '';
23
+ };
24
+
25
+ const quoted = (text) =>
26
+ text
27
+ .replace(/\n+$/, '')
28
+ .split('\n')
29
+ .map((line) => `${THOUGHT_MARK}${line}`)
30
+ .join('\n');
31
+
32
+ const rootPrefix = (root) => {
33
+ const slash = `${root ?? ''}`.replaceAll('\\', '/');
34
+ const trimmed = slash.replace(/\/+$/, '');
35
+ return trimmed ? `${trimmed}/` : '';
36
+ };
37
+
38
+ const FACE = {
39
+ text: (block) => block.text.replace(/\n+$/, ''),
40
+ thought: (block) => quoted(block.text),
41
+ note: (block) => `△ ${block.text}`,
42
+ tool: (block) => {
43
+ const title = block.title || block.tool || 'tool';
44
+ if (block.denied) return `${TOOL_MARK.failed} denied: ${title}`;
45
+ return `${TOOL_MARK[block.status] ?? RUNNING_MARK} ${title}`;
46
+ },
47
+ };
48
+
49
+ // The session updates of a run as the text its log shows
50
+ class Transcript {
51
+ constructor(root = '') {
52
+ // Agents name files by full path: inside the repository it is noise
53
+ this.prefix = rootPrefix(root);
54
+ this.blocks = [];
55
+ this.tools = new Map();
56
+ this.files = new Set();
57
+ this.tokens = null;
58
+ this.cache = null;
59
+ }
60
+
61
+ add(block) {
62
+ this.blocks.push(block);
63
+ this.cache = null;
64
+ return block;
65
+ }
66
+
67
+ note(text) {
68
+ const line = oneLine(text);
69
+ if (line) this.add({ kind: 'note', text: line });
70
+ }
71
+
72
+ fail(error) {
73
+ const text = `${(error && error.message) || error || 'failed'}`.trim();
74
+ this.add({ kind: 'text', text: `✖ ${text}` });
75
+ }
76
+
77
+ write(kind, text) {
78
+ if (!text) return;
79
+ const last = this.blocks.at(-1);
80
+ if (last && last.kind === kind) last.text += text;
81
+ else this.add({ kind, text });
82
+ this.cache = null;
83
+ }
84
+
85
+ title(text) {
86
+ const line = `${text ?? ''}`.replaceAll('\\', '/');
87
+ const stripped = this.prefix ? line.replaceAll(this.prefix, '') : line;
88
+ return oneLine(stripped);
89
+ }
90
+
91
+ deny(call) {
92
+ this.tool(call).denied = true;
93
+ }
94
+
95
+ tool(update) {
96
+ const id = `${update.toolCallId ?? ''}`;
97
+ const known = this.tools.get(id);
98
+ const block = known ?? this.add({ kind: 'tool', title: '', status: '' });
99
+ if (!known) this.tools.set(id, block);
100
+ const { title, status, kind } = update;
101
+ if (typeof title === 'string') block.title = this.title(title);
102
+ if (typeof status === 'string') block.status = status;
103
+ if (typeof kind === 'string') block.tool = kind;
104
+ this.cache = null;
105
+ if (!WRITE_KINDS.includes(block.tool)) return block;
106
+ const listed = Array.isArray(update.locations) ? update.locations : [];
107
+ for (const location of listed) {
108
+ const file = isHashObject(location) ? location.path : '';
109
+ if (file) this.files.add(file);
110
+ }
111
+ return block;
112
+ }
113
+
114
+ update(update) {
115
+ if (!isHashObject(update)) return;
116
+ const type = update.sessionUpdate;
117
+ const kind = CHUNK_KIND.get(type);
118
+ if (kind) return void this.write(kind, chunkText(update.content));
119
+ if (type === 'tool_call' || type === 'tool_call_update') {
120
+ return void this.tool(update);
121
+ }
122
+ if (type !== 'usage_update') return;
123
+ if (Number.isFinite(update.used)) this.tokens = update.used;
124
+ }
125
+
126
+ text() {
127
+ if (this.cache !== null) return this.cache;
128
+ let out = '';
129
+ let prev = '';
130
+ for (const block of this.blocks) {
131
+ const face = FACE[block.kind](block);
132
+ if (!face) continue;
133
+ const joined = prev === 'tool' && block.kind === 'tool';
134
+ if (out) out += joined ? '\n' : '\n\n';
135
+ out += face;
136
+ prev = block.kind;
137
+ }
138
+ this.cache = out ? `${out}\n` : '';
139
+ return this.cache;
140
+ }
141
+ }
142
+
143
+ module.exports = { Transcript };
package/lib/cli.js CHANGED
@@ -16,6 +16,7 @@ const {
16
16
  } = require('./source/capabilities.js');
17
17
  const { runCapture } = require('./report/run.js');
18
18
  const { workspaceFromArgs } = require('./dashboard/workspace.js');
19
+ const { expandWorkspace } = require('./dashboard/worktrees.js');
19
20
 
20
21
  const HEX_REV = /^[0-9a-f]{4,40}$/i;
21
22
  const HEAD_REV = /^HEAD([~^].*)?$/i;
@@ -72,7 +73,7 @@ const localOptions = async (deps, args, selected, cwd) => {
72
73
  rev: null,
73
74
  capabilities,
74
75
  startPane: 'repos',
75
- workspace: found,
76
+ workspace: await expandWorkspace(found),
76
77
  repoName: path.basename(found.root),
77
78
  };
78
79
  }
@@ -6,6 +6,7 @@ const IS_WIN = process.platform === 'win32';
6
6
  const INSTALL_MS = 120000;
7
7
  const DEFAULT_ENCODING = 'utf8';
8
8
  const DEFAULT_MAX_BUFFER = 32 * 1024 * 1024;
9
+ const CMD_SAFE = /^[\w./:=@+-]+$/;
9
10
 
10
11
  const npmBin = () => (IS_WIN ? 'npm.cmd' : 'npm');
11
12
 
@@ -14,6 +15,28 @@ const spawnBase = (extra = {}) => ({ windowsHide: true, ...extra });
14
15
  const npmOpts = (extra = {}, platform = process.platform) =>
15
16
  spawnBase({ shell: platform === 'win32', ...extra });
16
17
 
18
+ const quoteCmdArg = (arg) => {
19
+ const text = `${arg}`;
20
+ if (text && CMD_SAFE.test(text)) return text;
21
+ const escaped = text.replaceAll('"', '""').replaceAll('%', '"%"');
22
+ return `"${escaped}"`;
23
+ };
24
+
25
+ const winCommand = (program, args) => {
26
+ const list = args ?? [];
27
+ const parts = [program, ...list].map(quoteCmdArg);
28
+ return `"${parts.join(' ')}"`;
29
+ };
30
+
31
+ // cmd.exe quoting, not Node's shell:true join, which splits a quoted -e script
32
+ const spawnCmd = (cmd, args, options = {}) => {
33
+ if (!IS_WIN || options.shell !== true) return spawn(cmd, args, options);
34
+ const shell = process.env.comspec || 'cmd.exe';
35
+ const line = winCommand(cmd, args);
36
+ const opts = { ...options, windowsVerbatimArguments: true, shell: false };
37
+ return spawn(shell, ['/d', '/s', '/c', line], opts);
38
+ };
39
+
17
40
  const codeError = (message, code) =>
18
41
  Object.assign(new Error(message), { code });
19
42
 
@@ -98,7 +121,7 @@ const runProc = (cmd, args, options = {}) =>
98
121
  if (options.shell) spawnOpts.shell = true;
99
122
  let child = null;
100
123
  try {
101
- child = spawn(cmd, args, spawnBase(spawnOpts));
124
+ child = spawnCmd(cmd, args, spawnBase(spawnOpts));
102
125
  } catch (error) {
103
126
  resolve(procResult({ error }));
104
127
  }
@@ -112,6 +135,8 @@ module.exports = {
112
135
  npmBin,
113
136
  spawnBase,
114
137
  npmOpts,
138
+ winCommand,
139
+ spawnCmd,
115
140
  abortError,
116
141
  isAbort,
117
142
  runProc,
@@ -5,6 +5,7 @@ const path = require('node:path');
5
5
  const { readText } = require('../common/utilities.js');
6
6
 
7
7
  const { runGit } = require('../git/worktree.js');
8
+ const { commitCount, gitDirPath } = require('../git/local.js');
8
9
 
9
10
  const UNIT = '\x1f';
10
11
  const RECENT_COUNT = 8;
@@ -97,9 +98,18 @@ const rebaseState = (gitDir) => {
97
98
  return null;
98
99
  };
99
100
 
101
+ const probeText = (top, name) => {
102
+ if (name === 'dir') return gitDirPath(top);
103
+ if (name === 'total') {
104
+ const counted = commitCount(top);
105
+ if (counted !== '') return counted;
106
+ }
107
+ return gitText(top, PROBES[name]);
108
+ };
109
+
100
110
  const readGitSummary = async (top) => {
101
111
  const names = Object.keys(PROBES);
102
- const read = (name) => gitText(top, PROBES[name]);
112
+ const read = (name) => probeText(top, name);
103
113
  const texts = await Promise.all(names.map(read));
104
114
  const probes = {};
105
115
  for (let i = 0; i < names.length; i++) probes[names[i]] = texts[i];
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+
7
+ const { MANIFEST, LOCKFILE } = require('../deps/deps.js');
8
+ const { collectReport } = require('../git/deps.js');
9
+
10
+ const REPORT_TTL_MS = 10 * 60 * 1000;
11
+ const MODULES_LOCK = path.join('node_modules', '.package-lock.json');
12
+
13
+ const answers = new Map();
14
+
15
+ const hashOf = (file) => {
16
+ try {
17
+ const bytes = fs.readFileSync(file);
18
+ return crypto.createHash('sha1').update(bytes).digest('hex');
19
+ } catch {
20
+ return '';
21
+ }
22
+ };
23
+
24
+ const stampOf = (file) => {
25
+ try {
26
+ const stat = fs.statSync(file);
27
+ return `${stat.mtimeMs}:${stat.size}`;
28
+ } catch {
29
+ return '';
30
+ }
31
+ };
32
+
33
+ // `npm audit` reads the lockfile only, so equal lockfiles have equal answers
34
+ // in every worktree of a repository
35
+ const auditKey = (dir) => {
36
+ const hash = hashOf(path.join(dir, LOCKFILE));
37
+ return hash ? `audit:${hash}` : '';
38
+ };
39
+
40
+ // `npm outdated` reads what is installed, so it is kept for the directory
41
+ const outdatedKey = (dir) => {
42
+ const files = [MANIFEST, LOCKFILE, MODULES_LOCK];
43
+ const stamps = files.map((file) => stampOf(path.join(dir, file)));
44
+ return `outdated:${path.resolve(dir)}:${stamps.join(':')}`;
45
+ };
46
+
47
+ // The answer is asked of npm once while the files stay as they are, however
48
+ // many times the tiles are read: npm goes to the network
49
+ const remembered = (key, collect, now = Date.now()) => {
50
+ if (!key) return collect();
51
+ const hit = answers.get(key);
52
+ if (hit && now - hit.at < REPORT_TTL_MS) return hit.promise;
53
+ const promise = collect();
54
+ answers.set(key, { at: now, promise });
55
+ promise.catch(() => answers.delete(key));
56
+ return promise;
57
+ };
58
+
59
+ const sizeOf = async (collect, dir, kind) => {
60
+ const report = await collect(dir, kind);
61
+ return report ? report.size : 0;
62
+ };
63
+
64
+ const reportCounts = async (dir, collect = collectReport) => {
65
+ const [audit, outdated] = await Promise.all([
66
+ remembered(auditKey(dir), () => sizeOf(collect, dir, 'audit')),
67
+ remembered(outdatedKey(dir), () => sizeOf(collect, dir, 'outdated')),
68
+ ]);
69
+ return { audit, outdated };
70
+ };
71
+
72
+ const forgetReports = () => answers.clear();
73
+
74
+ module.exports = {
75
+ auditKey,
76
+ outdatedKey,
77
+ remembered,
78
+ reportCounts,
79
+ forgetReports,
80
+ };
@@ -6,6 +6,7 @@ const path = require('node:path');
6
6
  const runs = require('../runs/runs.js');
7
7
  const { isRunsRel } = runs;
8
8
  const { REVIEW_DIR, posixRel } = require('../common/files.js');
9
+ const { resolveGitDirs } = require('../git/dir.js');
9
10
 
10
11
  const DEBOUNCE_MS = 200;
11
12
  const UNKNOWN_PATH = '.';
@@ -42,7 +43,13 @@ const ignoredName = (name) => {
42
43
  return suffix || backup || store;
43
44
  };
44
45
 
45
- const ignoredGitRel = ([, name = '', sub]) => {
46
+ // Linked worktrees keep their HEAD and index in `.git/worktrees/<name>`
47
+ const ignoredLinkedRel = ([, , , leaf = '']) =>
48
+ leaf !== '' && !GIT_WATCH_NAMES.includes(leaf) && leaf !== 'logs';
49
+
50
+ const ignoredGitRel = (parts) => {
51
+ const [, name = '', sub] = parts;
52
+ if (name === 'worktrees') return ignoredLinkedRel(parts);
46
53
  if (name === 'logs' && sub === 'HEAD') return false;
47
54
  return name !== '' && !GIT_WATCH_NAMES.includes(name);
48
55
  };
@@ -53,7 +60,8 @@ const ignoredRel = (rel) => {
53
60
  if (norm === MODULES_STAMP) return false;
54
61
  const parts = norm.split('/');
55
62
  if (parts.some((part) => SKIP_DIRS.includes(part))) return true;
56
- if (parts[0] === '.git') return ignoredGitRel(parts);
63
+ const gitAt = parts.indexOf('.git');
64
+ if (gitAt >= 0) return ignoredGitRel(parts.slice(gitAt));
57
65
  return ignoredName(parts[parts.length - 1]);
58
66
  };
59
67
 
@@ -150,15 +158,35 @@ class DiskWatcher {
150
158
  this.closed = false;
151
159
  const scopes = options.paths?.length ? options.paths : ['.'];
152
160
  for (const spec of scopes) this.watchScope(spec);
153
- const gitDir = path.resolve(this.root, '.git');
154
- this.watchOne(gitDir);
155
- this.watchOne(path.join(gitDir, 'refs'));
156
- this.watchOne(path.join(gitDir, 'logs'));
161
+ this.watchGit();
157
162
  this.watchOne(path.resolve(this.root, runs.logDir('.')));
158
163
  this.watchOne(path.resolve(this.root, runs.runsDir('.')));
159
164
  this.watchOne(path.resolve(this.root, REVIEW_DIR));
160
165
  }
161
166
 
167
+ // `.git` of a linked worktree is a file, its state lives in other places
168
+ watchGit() {
169
+ const dirs = resolveGitDirs(this.root);
170
+ this.gitBases = dirs && dirs.linked ? [dirs.gitDir, dirs.commonDir] : [];
171
+ const { gitDir, commonDir } = dirs ?? {};
172
+ const dot = path.resolve(this.root, '.git');
173
+ for (const base of new Set([gitDir ?? dot, commonDir ?? dot])) {
174
+ this.watchOne(base);
175
+ this.watchOne(path.join(base, 'refs'));
176
+ this.watchOne(path.join(base, 'logs'));
177
+ }
178
+ }
179
+
180
+ // Events of the git directories are reported as paths of `.git`
181
+ relOf(full) {
182
+ for (const base of this.gitBases) {
183
+ const inside = path.relative(base, full);
184
+ if (inside.startsWith('..') || path.isAbsolute(inside)) continue;
185
+ return posixRel(path.join('.git', inside));
186
+ }
187
+ return posixRel(path.relative(this.root, full));
188
+ }
189
+
162
190
  schedule(key, fire) {
163
191
  if (this.closed || !fire) return;
164
192
  this.clearTimer(key);
@@ -212,11 +240,17 @@ class DiskWatcher {
212
240
 
213
241
  onEvent(dir, eventType, filename) {
214
242
  if (this.closed) return;
243
+ // Windows keeps reporting a directory after it was deleted, forever
244
+ if (!hasFile(dir)) {
245
+ const rel = this.relOf(dir) || UNKNOWN_PATH;
246
+ this.close();
247
+ if (this.onPaths) this.onPaths([rel]);
248
+ return;
249
+ }
215
250
  const name = filename ? `${filename}` : '';
216
251
  let rel = '';
217
252
  if (name) {
218
- const full = path.join(dir, name);
219
- rel = posixRel(path.relative(this.root, full));
253
+ rel = this.relOf(path.join(dir, name));
220
254
  }
221
255
  const wanted = this.files.has(rel);
222
256
  if (isRunsRel(rel)) {
@@ -253,6 +287,7 @@ class DiskWatcher {
253
287
  const watcher = fs.watch(target, options, (eventType, filename) => {
254
288
  this.onEvent(target, eventType, filename);
255
289
  });
290
+ watcher.unref();
256
291
  watcher.on('error', () => {});
257
292
  this.watched.add(target);
258
293
  this.watchers.push(watcher);
@@ -6,7 +6,8 @@ const path = require('node:path');
6
6
  const { REVIEW_DIR } = require('../common/files.js');
7
7
  const review = require('../review/review.js');
8
8
  const { listReviewNames, latestReviewName, loadReview, noteCounts } = review;
9
- const { countUntrackedAdded } = require('../git/worktree.js');
9
+ const { diffCounts } = require('../git/worktree.js');
10
+ const { branchOf, commitCount, worktreeRoot } = require('../git/local.js');
10
11
  const { gitText, countOf } = require('./git.js');
11
12
  const { runName } = require('./model.js');
12
13
  const { readRuns } = require('../runs/runs.js');
@@ -87,29 +88,26 @@ const sumNumstat = (text) => {
87
88
  return { added, removed };
88
89
  };
89
90
 
90
- const readNumstat = async (dir, cached) => {
91
- const stagedArgs = ['diff', '--cached', '--numstat'];
92
- const openArgs = ['diff', '--numstat'];
93
- const args = cached ? stagedArgs : openArgs;
94
- return sumNumstat(await gitText(dir, args));
91
+ const readDiffStat = async (dir) => {
92
+ try {
93
+ return await diffCounts(dir);
94
+ } catch {
95
+ return emptyDiff();
96
+ }
95
97
  };
96
98
 
97
- const readDiffStat = async (dir) => {
98
- const [staged, open, untracked] = await Promise.all([
99
- readNumstat(dir, true),
100
- readNumstat(dir, false),
101
- countUntrackedAdded(dir),
102
- ]);
103
- return {
104
- stagedAdded: staged.added,
105
- unstagedAdded: open.added + untracked,
106
- stagedRemoved: staged.removed,
107
- unstagedRemoved: open.removed,
108
- };
99
+ const countCommits = async (dir) => {
100
+ const counted = commitCount(dir);
101
+ if (counted !== '') return countOf(counted);
102
+ return countOf(await gitText(dir, ['rev-list', '--count', 'HEAD']));
109
103
  };
110
104
 
111
- const countCommits = async (dir) =>
112
- countOf(await gitText(dir, ['rev-list', '--count', 'HEAD']));
105
+ // Empty when HEAD is detached
106
+ const currentBranchName = async (dir) => {
107
+ const root = worktreeRoot(dir);
108
+ if (root) return branchOf(root);
109
+ return gitText(dir, ['symbolic-ref', '--short', '-q', 'HEAD']);
110
+ };
113
111
 
114
112
  const taskStats = (dir) => {
115
113
  const latest = latestReviewName(listReviewNames(dir));
@@ -146,6 +144,7 @@ module.exports = {
146
144
  sumNumstat,
147
145
  readDiffStat,
148
146
  countCommits,
147
+ currentBranchName,
149
148
  taskStats,
150
149
  pickRun,
151
150
  };