shmakk 1.2.3 → 1.2.5
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/.env.example +11 -0
- package/README.md +75 -1
- package/docs/index.html +154 -16
- package/docs/mcp.md +78 -0
- package/docs/ssh.md +82 -0
- package/docs/vibedit-analysis.md +375 -0
- package/docs/vim.md +110 -0
- package/docs/voice.md +4 -0
- package/package.json +9 -5
- package/scripts/test-vibedit.js +45 -0
- package/scripts/vibedit-demo.sh +52 -0
- package/skills/shmakk-skill-creator.md +269 -0
- package/src/_check.js +7 -0
- package/src/_check_schema.js +5 -0
- package/src/_cleanup.js +18 -0
- package/src/_fix.js +9 -0
- package/src/_test_import.js +15 -0
- package/src/agent.js +11 -4
- package/src/browser-daemon.js +209 -0
- package/src/browser.js +10 -0
- package/src/cli/browserDaemon.js +60 -0
- package/src/cli/connectBrowser.js +137 -0
- package/src/cli.js +235 -8
- package/src/completions.js +8 -0
- package/src/control.js +273 -1
- package/src/core/browserConnector.js +523 -0
- package/src/correction.js +6 -0
- package/src/electron.js +305 -0
- package/src/endpoints.js +74 -9
- package/src/index.js +24 -1
- package/src/llm.js +501 -61
- package/src/mobile.js +307 -0
- package/src/notify.js +51 -3
- package/src/orchestrator.js +35 -1
- package/src/pty.js +11 -6
- package/src/review.js +45 -11
- package/src/self-commands.js +153 -0
- package/src/session-convert.js +508 -0
- package/src/session-search.js +31 -0
- package/src/session.js +392 -46
- package/src/skills/browserActions.ts +984 -0
- package/src/skills.js +451 -24
- package/src/system-prompt.js +31 -25
- package/src/tools.js +81 -0
- package/src/vibedit/control.js +534 -0
- package/src/vibedit/electron.js +108 -0
- package/src/vibedit/files.js +171 -0
- package/src/vibedit/index.js +298 -0
- package/src/vibedit/overlay.js +1482 -0
- package/src/vibedit/prompts.js +245 -0
- package/src/vibedit/state.js +32 -0
- package/src/vim.js +410 -0
package/src/mobile.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Mobile app automation via ADB (Android Debug Bridge).
|
|
2
|
+
// Manages a persistent ADB connection to an Android device/emulator
|
|
3
|
+
// across the session. The agent uses this through the `mobile` tool
|
|
4
|
+
// in tools.js.
|
|
5
|
+
//
|
|
6
|
+
// Requires: adb on PATH, device connected or emulator running.
|
|
7
|
+
|
|
8
|
+
const { execFileSync, execFile } = require('child_process');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
|
|
13
|
+
const SCREENSHOT_DIR = '/tmp/shmakk-screenshots';
|
|
14
|
+
|
|
15
|
+
let _deviceId = null; // cached device serial, set on first ensure
|
|
16
|
+
|
|
17
|
+
function isAvailable() {
|
|
18
|
+
try {
|
|
19
|
+
execFileSync('adb', ['version'], { timeout: 5000, stdio: 'ignore' });
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function adb(args, opts = {}) {
|
|
27
|
+
const fullArgs = _deviceId ? ['-s', _deviceId, ...args] : args;
|
|
28
|
+
const { stdout } = execFileSync('adb', fullArgs, {
|
|
29
|
+
timeout: opts.timeout || 15000,
|
|
30
|
+
maxBuffer: opts.maxBuffer || 2 * 1024 * 1024,
|
|
31
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
32
|
+
encoding: 'buffer',
|
|
33
|
+
});
|
|
34
|
+
return (stdout || '').toString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function adbAsync(args, opts = {}) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const fullArgs = _deviceId ? ['-s', _deviceId, ...args] : args;
|
|
40
|
+
execFile('adb', fullArgs, {
|
|
41
|
+
timeout: opts.timeout || 15000,
|
|
42
|
+
maxBuffer: opts.maxBuffer || 2 * 1024 * 1024,
|
|
43
|
+
}, (err, stdout) => {
|
|
44
|
+
if (err) reject(err);
|
|
45
|
+
else resolve((stdout || '').toString());
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ensureDevice() {
|
|
51
|
+
if (_deviceId) return _deviceId;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const devices = adb(['devices']);
|
|
55
|
+
const lines = devices.split('\n').slice(1).filter(l => l.trim() && !l.startsWith('*'));
|
|
56
|
+
const online = lines.filter(l => /\tdevice/.test(l)).map(l => l.split('\t')[0].trim());
|
|
57
|
+
|
|
58
|
+
if (online.length === 0) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
'No Android device/emulator found. Connect a device via USB or start an emulator.\n' +
|
|
61
|
+
'Run: adb devices to check available devices.'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_deviceId = online[0];
|
|
66
|
+
return _deviceId;
|
|
67
|
+
} catch (e) {
|
|
68
|
+
if (e.message.includes('No Android device')) throw e;
|
|
69
|
+
throw new Error(`ADB error: ${e.message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Commands ──────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
async function screenshot(args) {
|
|
76
|
+
try {
|
|
77
|
+
ensureDevice();
|
|
78
|
+
const ts = Date.now();
|
|
79
|
+
if (!fs.existsSync(SCREENSHOT_DIR)) fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
|
80
|
+
|
|
81
|
+
const remotePath = '/sdcard/shmakk_screen.png';
|
|
82
|
+
const localPath = path.join(SCREENSHOT_DIR, `mobile-${ts}.png`);
|
|
83
|
+
|
|
84
|
+
// Capture screenshot on device
|
|
85
|
+
adb(['shell', 'screencap', '-p', remotePath]);
|
|
86
|
+
|
|
87
|
+
// Pull to local
|
|
88
|
+
adb(['pull', remotePath, localPath]);
|
|
89
|
+
|
|
90
|
+
// Clean up remote file
|
|
91
|
+
try { adb(['shell', 'rm', remotePath]); } catch {}
|
|
92
|
+
|
|
93
|
+
// Return base64 for vision LLMs
|
|
94
|
+
const buf = fs.readFileSync(localPath);
|
|
95
|
+
const b64 = buf.toString('base64');
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
ok: true,
|
|
99
|
+
path: localPath,
|
|
100
|
+
mimeType: 'image/png',
|
|
101
|
+
images: [{
|
|
102
|
+
mimeType: 'image/png',
|
|
103
|
+
data: b64,
|
|
104
|
+
dataLength: b64.length,
|
|
105
|
+
truncated: false,
|
|
106
|
+
}],
|
|
107
|
+
};
|
|
108
|
+
} catch (e) {
|
|
109
|
+
return { error: `screenshot failed: ${e.message}` };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function click(args) {
|
|
114
|
+
const x = Number(args.x);
|
|
115
|
+
const y = Number(args.y);
|
|
116
|
+
if (isNaN(x) || isNaN(y)) return { error: 'x and y coordinates required' };
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
ensureDevice();
|
|
120
|
+
adb(['shell', 'input', 'tap', String(x), String(y)]);
|
|
121
|
+
return { ok: true, action: 'click', x, y };
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return { error: `click failed: ${e.message}` };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function type(args) {
|
|
128
|
+
const text = String(args.text || '');
|
|
129
|
+
if (!text) return { error: 'text required' };
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
ensureDevice();
|
|
133
|
+
// Escape special shell chars for input text
|
|
134
|
+
const escaped = text.replace(/(['"\\])/g, '\\$1');
|
|
135
|
+
adb(['shell', 'input', 'text', escaped]);
|
|
136
|
+
return { ok: true, action: 'type', length: text.length };
|
|
137
|
+
} catch (e) {
|
|
138
|
+
return { error: `type failed: ${e.message}` };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function swipe(args) {
|
|
143
|
+
const x1 = Number(args.x1);
|
|
144
|
+
const y1 = Number(args.y1);
|
|
145
|
+
const x2 = Number(args.x2);
|
|
146
|
+
const y2 = Number(args.y2);
|
|
147
|
+
const duration = Number(args.duration) || 300;
|
|
148
|
+
|
|
149
|
+
if ([x1, y1, x2, y2].some(isNaN)) {
|
|
150
|
+
return { error: 'x1, y1, x2, y2 coordinates required' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
ensureDevice();
|
|
155
|
+
adb(['shell', 'input', 'swipe', String(x1), String(y1), String(x2), String(y2), String(duration)]);
|
|
156
|
+
return { ok: true, action: 'swipe', from: [x1, y1], to: [x2, y2], duration };
|
|
157
|
+
} catch (e) {
|
|
158
|
+
return { error: `swipe failed: ${e.message}` };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function key(args) {
|
|
163
|
+
const code = String(args.code || '').toUpperCase();
|
|
164
|
+
if (!code) return { error: 'key code required (e.g. BACK, HOME, ENTER, DELETE)' };
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
ensureDevice();
|
|
168
|
+
adb(['shell', 'input', 'keyevent', `KEYCODE_${code}`]);
|
|
169
|
+
return { ok: true, action: 'key', code };
|
|
170
|
+
} catch (e) {
|
|
171
|
+
return { error: `key failed: ${e.message}` };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function readPage(args) {
|
|
176
|
+
try {
|
|
177
|
+
ensureDevice();
|
|
178
|
+
const remotePath = '/sdcard/shmakk_ui.xml';
|
|
179
|
+
const localPath = path.join(os.tmpdir(), `shmakk-ui-${Date.now()}.xml`);
|
|
180
|
+
|
|
181
|
+
adb(['shell', 'uiautomator', 'dump', remotePath]);
|
|
182
|
+
adb(['pull', remotePath, localPath]);
|
|
183
|
+
try { adb(['shell', 'rm', remotePath]); } catch {}
|
|
184
|
+
|
|
185
|
+
const xml = fs.readFileSync(localPath, 'utf8');
|
|
186
|
+
try { fs.unlinkSync(localPath); } catch {}
|
|
187
|
+
|
|
188
|
+
// Parse basic structure from accessibility tree
|
|
189
|
+
const elements = [];
|
|
190
|
+
const tagRe = /<node[^>]*>/g;
|
|
191
|
+
let match;
|
|
192
|
+
while ((match = tagRe.exec(xml)) !== null) {
|
|
193
|
+
const tag = match[0];
|
|
194
|
+
const textMatch = tag.match(/text="([^"]*)"/);
|
|
195
|
+
const classMatch = tag.match(/class="([^"]*)"/);
|
|
196
|
+
const boundsMatch = tag.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/);
|
|
197
|
+
const clickable = tag.includes('clickable="true"');
|
|
198
|
+
const checkable = tag.includes('checkable="true"');
|
|
199
|
+
const scrollable = tag.includes('scrollable="true"');
|
|
200
|
+
const focused = tag.includes('focused="true"');
|
|
201
|
+
const enabled = tag.includes('enabled="true"');
|
|
202
|
+
|
|
203
|
+
elements.push({
|
|
204
|
+
text: textMatch ? textMatch[1] : '',
|
|
205
|
+
className: classMatch ? classMatch[1].split('.').pop() : '',
|
|
206
|
+
bounds: boundsMatch ? {
|
|
207
|
+
x: parseInt(boundsMatch[1]),
|
|
208
|
+
y: parseInt(boundsMatch[2]),
|
|
209
|
+
w: parseInt(boundsMatch[3]) - parseInt(boundsMatch[1]),
|
|
210
|
+
h: parseInt(boundsMatch[4]) - parseInt(boundsMatch[2]),
|
|
211
|
+
} : null,
|
|
212
|
+
clickable,
|
|
213
|
+
checkable,
|
|
214
|
+
scrollable,
|
|
215
|
+
focused,
|
|
216
|
+
enabled,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
ok: true,
|
|
222
|
+
elements: elements.slice(0, 200), // Cap for size
|
|
223
|
+
totalElements: elements.length,
|
|
224
|
+
truncated: elements.length > 200,
|
|
225
|
+
};
|
|
226
|
+
} catch (e) {
|
|
227
|
+
return { error: `read_page failed: ${e.message}` };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function listApps(args) {
|
|
232
|
+
try {
|
|
233
|
+
ensureDevice();
|
|
234
|
+
const filter = String(args.filter || '').trim();
|
|
235
|
+
const output = adb(['shell', 'pm', 'list', 'packages', ...(filter ? [filter] : [])]);
|
|
236
|
+
const packages = output.split('\n')
|
|
237
|
+
.filter(l => l.startsWith('package:'))
|
|
238
|
+
.map(l => l.replace('package:', '').trim());
|
|
239
|
+
|
|
240
|
+
return { ok: true, packages, count: packages.length };
|
|
241
|
+
} catch (e) {
|
|
242
|
+
return { error: `list_apps failed: ${e.message}` };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function launch(args) {
|
|
247
|
+
const pkg = String(args.package || '').trim();
|
|
248
|
+
if (!pkg) return { error: 'package name required' };
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
ensureDevice();
|
|
252
|
+
// Try launching the main activity first
|
|
253
|
+
const output = adb(['shell', 'monkey', '-p', pkg, '-c', 'android.intent.category.LAUNCHER', '1'], { timeout: 10000 });
|
|
254
|
+
return { ok: true, action: 'launch', package: pkg };
|
|
255
|
+
} catch (e) {
|
|
256
|
+
return { error: `launch failed: ${e.message}` };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function close(args) {
|
|
261
|
+
const pkg = String(args.package || '').trim();
|
|
262
|
+
if (!pkg) return { error: 'package name required' };
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
ensureDevice();
|
|
266
|
+
adb(['shell', 'am', 'force-stop', pkg]);
|
|
267
|
+
return { ok: true, action: 'close', package: pkg };
|
|
268
|
+
} catch (e) {
|
|
269
|
+
return { error: `close failed: ${e.message}` };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function wait(args) {
|
|
274
|
+
const ms = Number(args.ms) || 1000;
|
|
275
|
+
await new Promise(r => setTimeout(r, Math.min(ms, 30000)));
|
|
276
|
+
return { ok: true, action: 'wait', ms };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Dispatch ──────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
const COMMANDS = { screenshot, click, type, swipe, key, read_page: readPage, list_apps: listApps, launch, close, wait };
|
|
282
|
+
|
|
283
|
+
function classifyMobileCommand(args) {
|
|
284
|
+
const cmd = String(args.command || '');
|
|
285
|
+
// All mobile commands are potentially destructive (interact with a real device)
|
|
286
|
+
if (cmd === 'screenshot' || cmd === 'read_page' || cmd === 'list_apps' || cmd === 'wait') return 'safe';
|
|
287
|
+
if (cmd === 'click' || cmd === 'type' || cmd === 'swipe' || cmd === 'key') return 'uncertain';
|
|
288
|
+
if (cmd === 'launch' || cmd === 'close') return 'unsafe';
|
|
289
|
+
return 'uncertain';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function dispatchMobile(args, signal) {
|
|
293
|
+
if (!isAvailable()) {
|
|
294
|
+
return { error: 'adb not found. Install Android SDK platform tools.' };
|
|
295
|
+
}
|
|
296
|
+
const cmd = String(args.command || '');
|
|
297
|
+
const fn = COMMANDS[cmd];
|
|
298
|
+
if (!fn) return { error: `unknown mobile command: ${cmd}. Available: ${Object.keys(COMMANDS).join(', ')}` };
|
|
299
|
+
try {
|
|
300
|
+
const result = await fn(args);
|
|
301
|
+
return result;
|
|
302
|
+
} catch (e) {
|
|
303
|
+
return { error: `mobile ${cmd} failed: ${e.message}` };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
module.exports = { dispatchMobile, classifyMobileCommand, isAvailable };
|
package/src/notify.js
CHANGED
|
@@ -9,7 +9,6 @@ const NOTIFY_BIN = 'notify-send';
|
|
|
9
9
|
|
|
10
10
|
function available() {
|
|
11
11
|
try {
|
|
12
|
-
// Prefer direct path check; fall back to `command -v` if not at known paths
|
|
13
12
|
if (existsSync('/usr/bin/notify-send')) return true;
|
|
14
13
|
if (existsSync('/usr/local/bin/notify-send')) return true;
|
|
15
14
|
execFileSync('command', ['-v', NOTIFY_BIN], { stdio: 'ignore' });
|
|
@@ -29,9 +28,58 @@ function notify(summary, body, urgency) {
|
|
|
29
28
|
if (urgency === 'critical') args.push('--urgency=critical');
|
|
30
29
|
if (urgency === 'low') args.push('--urgency=low');
|
|
31
30
|
execFile(NOTIFY_BIN, args, (err) => {
|
|
32
|
-
// Silently ignore — notification daemon may not be running.
|
|
33
31
|
void err;
|
|
34
32
|
});
|
|
35
33
|
}
|
|
36
34
|
|
|
37
|
-
|
|
35
|
+
// Interactive notification with Yes/No action buttons.
|
|
36
|
+
// Returns { promise, proc } where:
|
|
37
|
+
// promise resolves to 'yes', 'no', or null (dismissed/error)
|
|
38
|
+
// proc is the child process (call .kill() to cancel)
|
|
39
|
+
function notifyAsk(summary, body) {
|
|
40
|
+
if (!available()) return { promise: Promise.resolve(null), proc: null };
|
|
41
|
+
|
|
42
|
+
const args = [
|
|
43
|
+
summary || 'shmakk',
|
|
44
|
+
body || '',
|
|
45
|
+
'--app-name=shmakk',
|
|
46
|
+
'--category=im.received',
|
|
47
|
+
'--urgency=critical',
|
|
48
|
+
'--expire-time=30000',
|
|
49
|
+
'--action=yes=Yes',
|
|
50
|
+
'--action=no=No',
|
|
51
|
+
'--wait',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
let settled = false;
|
|
55
|
+
let proc;
|
|
56
|
+
|
|
57
|
+
const promise = new Promise((resolve) => {
|
|
58
|
+
proc = execFile(NOTIFY_BIN, args, { timeout: 60000, encoding: 'utf8' }, (err, stdout) => {
|
|
59
|
+
if (settled) return;
|
|
60
|
+
settled = true;
|
|
61
|
+
if (err) {
|
|
62
|
+
resolve(null);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const action = (stdout || '').trim().toLowerCase();
|
|
66
|
+
if (action === 'yes' || action === 'no') {
|
|
67
|
+
resolve(action);
|
|
68
|
+
} else {
|
|
69
|
+
resolve(null);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
promise,
|
|
76
|
+
get proc() { return proc; },
|
|
77
|
+
cancel() {
|
|
78
|
+
if (settled) return;
|
|
79
|
+
settled = true;
|
|
80
|
+
try { proc.kill(); } catch {}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { notify, notifyAsk, available };
|
package/src/orchestrator.js
CHANGED
|
@@ -1,11 +1,45 @@
|
|
|
1
1
|
// shmakk orchestrator entry point.
|
|
2
2
|
// Manages signal-driven lifecycle (restart, exit, profile changes) and
|
|
3
3
|
// delegates each session to ./session.js.
|
|
4
|
+
//
|
|
5
|
+
// Browser CDP connector — loaded here so tools.js can discover it
|
|
6
|
+
// through the orchestrator without requiring a direct dependency on
|
|
7
|
+
// ./core/browserConnector from every call site.
|
|
4
8
|
|
|
5
9
|
const { runOneSession } = require('./session');
|
|
6
10
|
const { normalizeProfile } = require('./profiles');
|
|
7
11
|
const { profileSignalPath } = require('./control');
|
|
8
12
|
|
|
13
|
+
// Lazy-load the browser CDP connector (Playwright optional dep).
|
|
14
|
+
let _browserConnector = null;
|
|
15
|
+
function _getBrowserConnector() {
|
|
16
|
+
if (_browserConnector) return _browserConnector;
|
|
17
|
+
try {
|
|
18
|
+
_browserConnector = require('./core/browserConnector');
|
|
19
|
+
} catch (e) {
|
|
20
|
+
_browserConnector = null;
|
|
21
|
+
}
|
|
22
|
+
return _browserConnector;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Execute a browser action against a user-connected Chrome instance via CDP.
|
|
26
|
+
// This is the primary entry point for the agent's 'browser' tool when
|
|
27
|
+
// the user has run `shmakk connect-browser` to attach to their own Chrome.
|
|
28
|
+
async function executeBrowserAction(args) {
|
|
29
|
+
const bc = _getBrowserConnector();
|
|
30
|
+
if (!bc) {
|
|
31
|
+
return { error: 'Browser connector unavailable. playwright must be installed as an optional dependency.' };
|
|
32
|
+
}
|
|
33
|
+
return await bc.executeBrowserAction(args);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Classify a browser action for safety gating.
|
|
37
|
+
function classifyBrowserAction(args) {
|
|
38
|
+
const bc = _getBrowserConnector();
|
|
39
|
+
if (!bc) return 'uncertain';
|
|
40
|
+
return bc.classifyBrowserAction(args);
|
|
41
|
+
}
|
|
42
|
+
|
|
9
43
|
function isAbortError(e) {
|
|
10
44
|
return e && (e.name === 'AbortError' || /aborted/i.test(String(e.message || '')));
|
|
11
45
|
}
|
|
@@ -58,4 +92,4 @@ async function start(opts) {
|
|
|
58
92
|
return lastExit;
|
|
59
93
|
}
|
|
60
94
|
|
|
61
|
-
module.exports = { start };
|
|
95
|
+
module.exports = { start, executeBrowserAction, classifyBrowserAction };
|
package/src/pty.js
CHANGED
|
@@ -13,7 +13,7 @@ function getSize() {
|
|
|
13
13
|
|
|
14
14
|
const VOICE_HOTKEY = 0x0f; // Ctrl+O — triggers voice recording
|
|
15
15
|
|
|
16
|
-
function startSession({ debug = false, voiceEnabled = false, shellOverride = null } = {}) {
|
|
16
|
+
function startSession({ debug = false, voiceEnabled = false, stsEnabled = false, shellOverride = null, extraEnv = {}, cleanup = null } = {}) {
|
|
17
17
|
const shell = detectShell(shellOverride);
|
|
18
18
|
const cfg = configureForShell(shell.name);
|
|
19
19
|
const { cols, rows } = getSize();
|
|
@@ -28,10 +28,11 @@ function startSession({ debug = false, voiceEnabled = false, shellOverride = nul
|
|
|
28
28
|
cwd: process.cwd(),
|
|
29
29
|
// SHMAKK_PID lets `shmakk --status/--exit/--restart` find us from
|
|
30
30
|
// inside the inner shell.
|
|
31
|
-
env: { ...process.env, SHMAKK: '1', SHMAKK_PID: String(process.pid), ...cfg.env },
|
|
31
|
+
env: { ...process.env, SHMAKK: '1', SHMAKK_PID: String(process.pid), ...cfg.env, ...extraEnv },
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
const ev = new EventEmitter();
|
|
35
|
+
let voiceHotkeyEnabled = !!(voiceEnabled || stsEnabled);
|
|
35
36
|
// Stack of stdin handlers: top of stack receives data. Null at bottom
|
|
36
37
|
// means "relay to child PTY".
|
|
37
38
|
const stdinStack = [];
|
|
@@ -47,11 +48,8 @@ function startSession({ debug = false, voiceEnabled = false, shellOverride = nul
|
|
|
47
48
|
stdin.resume();
|
|
48
49
|
|
|
49
50
|
const onStdin = (data) => {
|
|
50
|
-
const h = topHandler();
|
|
51
|
-
if (h) return h(data);
|
|
52
|
-
|
|
53
51
|
// Voice hotkey detection — only when voice is enabled
|
|
54
|
-
if (
|
|
52
|
+
if (voiceHotkeyEnabled) {
|
|
55
53
|
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
56
54
|
if (buf.length === 1 && buf[0] === VOICE_HOTKEY) {
|
|
57
55
|
ev.emit('voice');
|
|
@@ -59,6 +57,9 @@ function startSession({ debug = false, voiceEnabled = false, shellOverride = nul
|
|
|
59
57
|
}
|
|
60
58
|
}
|
|
61
59
|
|
|
60
|
+
const h = topHandler();
|
|
61
|
+
if (h) return h(data);
|
|
62
|
+
|
|
62
63
|
const cleaned = filterStdin(Buffer.isBuffer(data) ? data : Buffer.from(data));
|
|
63
64
|
if (cleaned.length) child.write(cleaned);
|
|
64
65
|
};
|
|
@@ -82,6 +83,7 @@ function startSession({ debug = false, voiceEnabled = false, shellOverride = nul
|
|
|
82
83
|
if (stdin.isTTY) { try { stdin.setRawMode(wasRaw); } catch {} }
|
|
83
84
|
stdin.pause();
|
|
84
85
|
cfg.cleanup();
|
|
86
|
+
if (cleanup) cleanup();
|
|
85
87
|
resolve({ exitCode: exitCode ?? 0, signal });
|
|
86
88
|
});
|
|
87
89
|
});
|
|
@@ -90,6 +92,9 @@ function startSession({ debug = false, voiceEnabled = false, shellOverride = nul
|
|
|
90
92
|
ev,
|
|
91
93
|
stdoutWrite: (s) => stdout.write(s),
|
|
92
94
|
childWrite: (s) => child.write(s),
|
|
95
|
+
setVoiceEnabled(v) {
|
|
96
|
+
voiceHotkeyEnabled = !!v;
|
|
97
|
+
},
|
|
93
98
|
// Push a handler; returns a release fn that pops it. Stacked so nested
|
|
94
99
|
// captures (e.g. ask() inside an AI tap) don't wipe the outer handler.
|
|
95
100
|
captureStdin(handler) {
|
package/src/review.js
CHANGED
|
@@ -1,29 +1,61 @@
|
|
|
1
1
|
// Y/n prompt with cooperative cancellation. The returned `ask` accepts an
|
|
2
|
-
// optional `{ onCancel }`
|
|
2
|
+
// optional `{ onCancel, onWhy, notifyBody }` object. When `notifyBody` is
|
|
3
|
+
// provided it is used as the desktop notification text instead of the
|
|
4
|
+
// terminal-only `question` string.
|
|
3
5
|
|
|
4
6
|
function makePrompter(pty, write, opts = {}) {
|
|
5
|
-
return function ask(question, defaultYes, { onCancel, onWhy } = {}) {
|
|
7
|
+
return function ask(question, defaultYes, { onCancel, onWhy, notifyBody } = {}) {
|
|
6
8
|
return new Promise((resolve) => {
|
|
7
|
-
if (opts.notify) {
|
|
8
|
-
try {
|
|
9
|
-
const { notify } = require('./notify');
|
|
10
|
-
const body = typeof question === 'string' ? question.replace(/\x1b\[[0-9;]*m/g, '') : String(question || '');
|
|
11
|
-
notify('shmakk needs your attention', body.slice(0, 120));
|
|
12
|
-
} catch {}
|
|
13
|
-
}
|
|
14
9
|
const tag = defaultYes ? '[Y/n/?]' : '[y/N/?]';
|
|
15
10
|
write(`${question} ${tag} `);
|
|
16
11
|
let buf = '';
|
|
12
|
+
|
|
17
13
|
function finishYesNo(ans) {
|
|
18
14
|
write('\n');
|
|
19
15
|
release();
|
|
20
16
|
return resolve(ans);
|
|
21
17
|
}
|
|
18
|
+
|
|
19
|
+
// Notification with Yes/No buttons races against terminal input
|
|
20
|
+
let notifResult = null;
|
|
21
|
+
if (opts.notify) {
|
|
22
|
+
try {
|
|
23
|
+
const { notifyAsk } = require('./notify');
|
|
24
|
+
const raw = notifyBody || question || '';
|
|
25
|
+
const clean = typeof raw === 'string'
|
|
26
|
+
? raw.replace(/\x1b\[[0-9;]*m/g, '').trim()
|
|
27
|
+
: String(raw || '').trim();
|
|
28
|
+
|
|
29
|
+
// Summary: short label. Body: the full command/description.
|
|
30
|
+
const summary = clean.length > 60 ? clean.slice(0, 57) + '…' : (clean || 'shmakk');
|
|
31
|
+
notifResult = notifyAsk(summary, clean.slice(0, 200));
|
|
32
|
+
|
|
33
|
+
notifResult.promise.then((action) => {
|
|
34
|
+
if (action === 'yes') {
|
|
35
|
+
release();
|
|
36
|
+
write('\n\x1b[2m[approved via notification]\x1b[0m\n');
|
|
37
|
+
resolve(true);
|
|
38
|
+
} else if (action === 'no') {
|
|
39
|
+
release();
|
|
40
|
+
write('\n\x1b[2m[declined via notification]\x1b[0m\n');
|
|
41
|
+
resolve(false);
|
|
42
|
+
}
|
|
43
|
+
// null = dismissed: keep waiting for terminal input
|
|
44
|
+
});
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
|
|
22
48
|
const release = pty.captureStdin((data) => {
|
|
23
49
|
for (const ch of data.toString('utf8')) {
|
|
24
50
|
const code = ch.charCodeAt(0);
|
|
25
|
-
if (!buf && (ch === 'y' || ch === 'Y'))
|
|
26
|
-
|
|
51
|
+
if (!buf && (ch === 'y' || ch === 'Y')) {
|
|
52
|
+
if (notifResult) notifResult.cancel();
|
|
53
|
+
return finishYesNo(true);
|
|
54
|
+
}
|
|
55
|
+
if (!buf && (ch === 'n' || ch === 'N')) {
|
|
56
|
+
if (notifResult) notifResult.cancel();
|
|
57
|
+
return finishYesNo(false);
|
|
58
|
+
}
|
|
27
59
|
if (!buf && ch === '?') {
|
|
28
60
|
write('\n');
|
|
29
61
|
if (onWhy) onWhy();
|
|
@@ -43,6 +75,7 @@ function makePrompter(pty, write, opts = {}) {
|
|
|
43
75
|
write(`${question} ${tag} `);
|
|
44
76
|
return;
|
|
45
77
|
}
|
|
78
|
+
if (notifResult) notifResult.cancel();
|
|
46
79
|
release();
|
|
47
80
|
return resolve(ans === 'y' || ans === 'yes');
|
|
48
81
|
}
|
|
@@ -50,6 +83,7 @@ function makePrompter(pty, write, opts = {}) {
|
|
|
50
83
|
if (buf.length) { buf = buf.slice(0, -1); write('\b \b'); }
|
|
51
84
|
} else if (code === 0x03) { // Ctrl-C
|
|
52
85
|
write('^C\n');
|
|
86
|
+
if (notifResult) notifResult.cancel();
|
|
53
87
|
release();
|
|
54
88
|
if (onCancel) onCancel();
|
|
55
89
|
return resolve(false);
|