reslop 0.3.1 → 0.3.2

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 (65) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/README.md +21 -3
  3. package/lib/agents/acp.js +309 -0
  4. package/lib/agents/agents.js +163 -436
  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/transcript.js +143 -0
  11. package/lib/cli.js +2 -1
  12. package/lib/common/process.js +26 -1
  13. package/lib/dashboard/git.js +11 -1
  14. package/lib/dashboard/reports.js +80 -0
  15. package/lib/dashboard/watch.js +43 -8
  16. package/lib/dashboard/workspace.js +19 -20
  17. package/lib/dashboard/worktrees.js +145 -0
  18. package/lib/git/dir.js +50 -0
  19. package/lib/git/linked.js +152 -0
  20. package/lib/git/local.js +179 -0
  21. package/lib/git/status.js +126 -0
  22. package/lib/git/worktree.js +113 -36
  23. package/lib/input/actions.js +83 -5
  24. package/lib/input/keys.js +30 -1
  25. package/lib/input/line-edit.js +5 -1
  26. package/lib/render/agents.js +50 -24
  27. package/lib/render/branches.js +55 -27
  28. package/lib/render/field.js +19 -5
  29. package/lib/render/footer.js +14 -6
  30. package/lib/render/frame.js +16 -2
  31. package/lib/render/menu.js +103 -6
  32. package/lib/render/name-edit.js +31 -0
  33. package/lib/render/repos.js +52 -8
  34. package/lib/render/status.js +22 -4
  35. package/lib/report/run.js +16 -23
  36. package/lib/review/agent-instructions.md +2 -2
  37. package/lib/runs/process.js +3 -3
  38. package/lib/runs/runs.js +27 -4
  39. package/lib/session/agents/agents.js +220 -205
  40. package/lib/session/agents/away.js +48 -0
  41. package/lib/session/agents/job.js +23 -86
  42. package/lib/session/agents/jobs.js +14 -27
  43. package/lib/session/agents/options.js +45 -101
  44. package/lib/session/agents/plans.js +53 -0
  45. package/lib/session/agents/view.js +34 -0
  46. package/lib/session/branches.js +18 -1
  47. package/lib/session/compose.js +13 -5
  48. package/lib/session/dispatch.js +43 -4
  49. package/lib/session/editor-pointer.js +98 -0
  50. package/lib/session/files-pane.js +3 -0
  51. package/lib/session/lifecycle.js +67 -10
  52. package/lib/session/navigation.js +5 -0
  53. package/lib/session/npm/history.js +2 -1
  54. package/lib/session/npm/npm.js +89 -12
  55. package/lib/session/ops.js +3 -0
  56. package/lib/session/pointer.js +304 -46
  57. package/lib/session/session.js +15 -2
  58. package/lib/session/terminal.js +2 -2
  59. package/lib/session/text-menu.js +50 -0
  60. package/lib/session/view.js +19 -2
  61. package/lib/session/workspace.js +179 -38
  62. package/lib/session/worktrees.js +280 -0
  63. package/lib/term/themes/dark.json +4 -0
  64. package/lib/term/themes/light.json +4 -0
  65. package/package.json +2 -2
@@ -1,71 +1,160 @@
1
1
  'use strict';
2
2
 
3
- const fs = require('node:fs');
4
- const os = require('node:os');
5
3
  const path = require('node:path');
6
- const { jsonParse, isHashObject } = require('metautil');
7
- const { writeJson, trimText } = require('../common/utilities.js');
4
+ const { isHashObject } = require('metautil');
5
+ const { trimText } = require('../common/utilities.js');
8
6
 
9
- const { planAllows: PLAN_ALLOWS } = require('./allow.json');
7
+ const { commands: COMMANDS, filters: FILTERS } = require('./allow.json');
10
8
 
11
- const cursorConfigPath = (env = process.env) => {
12
- const override = trimText(env.RESLOP_CURSOR_CONFIG);
13
- if (override) return override;
14
- const root = trimText(env.CURSOR_CONFIG_DIR);
15
- const dir = root || path.join(os.homedir(), '.cursor');
16
- return path.join(dir, 'cli-config.json');
9
+ const ALLOWED = new Set([...COMMANDS, ...FILTERS]);
10
+ const READ_KINDS = ['read', 'search', 'think'];
11
+ const WRITE_KINDS = ['edit', 'delete', 'move'];
12
+ const SHELLS = ['sh', 'bash', 'zsh'];
13
+ const HEREDOC = /<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n[ \t]*\2(?=\s|\)|$)/g;
14
+ // Output to the terminal or nowhere is the only redirect that writes nothing
15
+ const QUIET_REDIRECT = /&?\d?>&\d|&?\d?>>?\s*\/dev\/null/g;
16
+ const SEPARATOR = /&&|\|\||\$\(|[;|&\n()`]/;
17
+ const QUOTE = String.fromCharCode(39);
18
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/;
19
+
20
+ const shellScript = (argv) => {
21
+ const parts = argv.map((part) => `${part ?? ''}`);
22
+ const isShell = SHELLS.includes(path.basename(parts[0] ?? ''));
23
+ const flag = parts.findIndex((part) => /^-[a-z]*c$/.test(part));
24
+ if (isShell && flag > 0 && parts[flag + 1]) return parts[flag + 1];
25
+ return parts.join(' ');
17
26
  };
18
27
 
19
- const readConfig = (file) => {
20
- let text = null;
21
- try {
22
- text = fs.readFileSync(file, 'utf8');
23
- } catch (error) {
24
- if (!error || error.code !== 'ENOENT') return null;
25
- }
26
- if (text === null) return {};
27
- const data = jsonParse(text);
28
- if (!isHashObject(data)) return null;
29
- return data;
28
+ const commandOf = (call) => {
29
+ const input = isHashObject(call.rawInput) ? call.rawInput : {};
30
+ const raw = input.command ?? input.cmd;
31
+ if (Array.isArray(raw)) return shellScript(raw);
32
+ if (typeof raw === 'string' && raw.trim()) return raw;
33
+ return trimText(call.title).replace(/^`+|`+$/g, '');
30
34
  };
31
35
 
32
- const allowList = (data) => {
33
- if (!data.permissions || typeof data.permissions !== 'object') {
34
- data.permissions = { allow: [], deny: [] };
36
+ // Quoted text is data, except a substitution inside double quotes
37
+ class ShellScan {
38
+ constructor(script) {
39
+ this.script = script;
40
+ this.at = 0;
41
+ this.code = '';
42
+ }
43
+
44
+ single() {
45
+ const end = this.script.indexOf(QUOTE, this.at + 1);
46
+ this.at = end < 0 ? this.script.length : end + 1;
47
+ this.code += '_';
48
+ }
49
+
50
+ substitution(close, open) {
51
+ const end = this.script.indexOf(close, this.at + open.length);
52
+ const stop = end < 0 ? this.script.length : end;
53
+ this.code += ` ${this.script.slice(this.at, stop)}${close} `;
54
+ this.at = stop + 1;
55
+ }
56
+
57
+ double() {
58
+ const { script } = this;
59
+ this.at += 1;
60
+ this.code += '_';
61
+ while (this.at < script.length && script[this.at] !== '"') {
62
+ const char = script[this.at];
63
+ if (char === '\\') this.at += 2;
64
+ else if (char === '`') this.substitution('`', '`');
65
+ else if (script.startsWith('$(', this.at)) this.substitution(')', '$(');
66
+ else this.at += 1;
67
+ }
68
+ this.at += 1;
69
+ }
70
+
71
+ plain(char) {
72
+ const escaped = char === '\\';
73
+ this.code += escaped ? '_' : char;
74
+ this.at += escaped ? 2 : 1;
75
+ }
76
+
77
+ run() {
78
+ const { script } = this;
79
+ while (this.at < script.length) {
80
+ const char = script[this.at];
81
+ if (char === QUOTE) this.single();
82
+ else if (char === '"') this.double();
83
+ else this.plain(char);
84
+ }
85
+ return this.code;
35
86
  }
36
- if (!Array.isArray(data.permissions.allow)) data.permissions.allow = [];
37
- if (!Array.isArray(data.permissions.deny)) data.permissions.deny = [];
38
- return data.permissions.allow;
87
+ }
88
+
89
+ const binaryOf = (segment) => {
90
+ let rest = segment.trim();
91
+ while (ASSIGNMENT.test(rest)) rest = rest.replace(ASSIGNMENT, '');
92
+ const word = rest.split(/\s+/)[0] ?? '';
93
+ return path.basename(word);
94
+ };
95
+
96
+ // An agent that may run node can run anything, so this is no sandbox: it stops
97
+ // a run from reaching for tools that executing a plan does not need
98
+ const commandAllowed = (command) => {
99
+ const script = `${command ?? ''}`.replace(HEREDOC, ' ');
100
+ const code = new ShellScan(script).run().replace(QUIET_REDIRECT, ' ');
101
+ if (code.includes('>')) return false;
102
+ const segments = code.split(SEPARATOR).map((part) => part.trim());
103
+ const named = segments.filter(Boolean);
104
+ return named.length > 0 && named.every((part) => ALLOWED.has(binaryOf(part)));
105
+ };
106
+
107
+ const inside = (root, file) => {
108
+ const rel = path.relative(root, path.resolve(root, file));
109
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
39
110
  };
40
111
 
41
- const ensureAllows = (file, entries) => {
42
- const data = readConfig(file);
43
- if (!data) return false;
44
- const allow = allowList(data);
45
- let changed = false;
46
- for (const entry of entries) {
47
- const token = trimText(entry);
48
- if (!token || allow.includes(token)) continue;
49
- allow.push(token);
50
- changed = true;
112
+ const pathsOf = (call) => {
113
+ const listed = Array.isArray(call.locations) ? call.locations : [];
114
+ const paths = [];
115
+ for (const location of listed) {
116
+ const file = isHashObject(location) ? location.path : '';
117
+ if (typeof file === 'string' && file) paths.push(file);
51
118
  }
52
- if (!changed) return true;
53
- if (!data.version) data.version = 1;
54
- if (!data.approvalMode) data.approvalMode = 'allowlist';
55
- try {
56
- writeJson(file, data);
57
- return true;
58
- } catch {
59
- return false;
119
+ return paths;
120
+ };
121
+
122
+ const callAllowed = (call, root) => {
123
+ const kind = `${call.kind ?? ''}`;
124
+ if (READ_KINDS.includes(kind)) return true;
125
+ if (kind === 'execute') return commandAllowed(commandOf(call));
126
+ if (!WRITE_KINDS.includes(kind)) return false;
127
+ const paths = pathsOf(call);
128
+ return paths.length > 0 && paths.every((file) => inside(root, file));
129
+ };
130
+
131
+ const optionId = (options, kinds) => {
132
+ for (const kind of kinds) {
133
+ const found = options.find((option) => option.kind === kind);
134
+ if (found) return found.optionId;
60
135
  }
136
+ return null;
61
137
  };
62
138
 
63
- const ensurePlanAllows = (env = process.env) =>
64
- ensureAllows(cursorConfigPath(env), PLAN_ALLOWS);
139
+ // A run has nobody to ask: edits in the repository and the commands a plan
140
+ // needs are approved for one call, everything else is refused
141
+ const decidePermission = (params, root) => {
142
+ const call = isHashObject(params.toolCall) ? params.toolCall : {};
143
+ const listed = Array.isArray(params.options) ? params.options : [];
144
+ const options = listed.filter(isHashObject);
145
+ const allowed = callAllowed(call, root);
146
+ const kinds = allowed
147
+ ? ['allow_once', 'allow_always']
148
+ : ['reject_once', 'reject_always'];
149
+ const id = optionId(options, kinds);
150
+ if (id === null) return { allowed: false, outcome: { outcome: 'cancelled' } };
151
+ return { allowed, outcome: { outcome: 'selected', optionId: id } };
152
+ };
65
153
 
66
154
  module.exports = {
67
- PLAN_ALLOWS,
68
- cursorConfigPath,
69
- ensureAllows,
70
- ensurePlanAllows,
155
+ COMMANDS,
156
+ commandOf,
157
+ commandAllowed,
158
+ callAllowed,
159
+ decidePermission,
71
160
  };
@@ -1,17 +1,19 @@
1
1
  {
2
- "planAllows": [
3
- "Write(.plan/**)",
4
- "Write(.plan/**/*.md)",
5
- "Write(**/.plan/**)",
6
- "Write(**/.plan/**/*.md)",
7
- "Shell(npm)",
8
- "Shell(npm test)",
9
- "Shell(npm t)",
10
- "Shell(npx)",
11
- "Shell(node)",
12
- "Shell(node --test)",
13
- "Shell(reslop)",
14
- "Shell(reslop t)",
15
- "Shell(git)"
2
+ "commands": ["npm", "npx", "node", "reslop", "git"],
3
+ "filters": [
4
+ "cd",
5
+ "cat",
6
+ "echo",
7
+ "grep",
8
+ "head",
9
+ "ls",
10
+ "printf",
11
+ "pwd",
12
+ "sort",
13
+ "tail",
14
+ "test",
15
+ "true",
16
+ "uniq",
17
+ "wc"
16
18
  ]
17
19
  }