From 0da2f03a99339660177418eb43ca13f5ae117fe6 Mon Sep 17 00:00:00 2001 From: sparkg Date: Thu, 30 Jul 2026 17:44:44 +0800 Subject: [PATCH 01/14] add agent profile model --- lib/agent-profiles.js | 234 ++++++++++++++++++++++++++++++++++++ test/agent-profiles.test.js | 151 +++++++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 lib/agent-profiles.js create mode 100644 test/agent-profiles.test.js diff --git a/lib/agent-profiles.js b/lib/agent-profiles.js new file mode 100644 index 0000000..14b6cc9 --- /dev/null +++ b/lib/agent-profiles.js @@ -0,0 +1,234 @@ +const DEFAULT_PROFILE_ID = 'claude-code'; + +const MESSAGE_TEXTS = Object.freeze([ + 'FASTER', + 'FASTER', + 'FASTER', + 'GO FASTER', + 'Faster CLANKER', + 'Work FASTER', + 'Speed it up clanker', +]); + +const VALID_PLATFORMS = new Set(['default', 'win32', 'darwin', 'linux']); +const VALID_KEYS = new Set([ + 'enter', + 'escape', + 'tab', + 'space', + 'backspace', + 'up', + 'down', + 'left', + 'right', +]); +const VALID_MODIFIERS = new Set(['control', 'alt', 'shift', 'meta']); + +function deepFreeze(value) { + Object.freeze(value); + for (const child of Object.values(value)) { + if (child && typeof child === 'object' && !Object.isFrozen(child)) { + deepFreeze(child); + } + } + return value; +} + +const BUILT_IN_PROFILES = deepFreeze([ + { + id: 'claude-code', + label: 'Claude Code', + messages: MESSAGE_TEXTS, + steps: { + default: [ + { type: 'keystroke', key: 'c', modifiers: ['control'] }, + { type: 'message' }, + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], + darwin: [ + { type: 'keystroke', key: 'c', modifiers: ['control'] }, + { type: 'delay', ms: 300 }, + { type: 'message' }, + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], + }, + }, + { + id: 'codex', + label: 'Codex', + messages: MESSAGE_TEXTS, + steps: { + default: [ + { type: 'message' }, + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], + }, + }, +]); + +function fail(path, reason) { + throw new Error(`${path}: ${reason}`); +} + +function validateName(value, path) { + if (typeof value !== 'string' || value.trim().length === 0 || value.length > 64) { + fail(path, 'must be a non-empty string of at most 64 characters'); + } + return value; +} + +function validateMessages(messages, path) { + if (!Array.isArray(messages) || messages.length < 1 || messages.length > 20) { + fail(path, 'must contain 1-20 messages'); + } + + return messages.map((message, index) => { + if ( + typeof message !== 'string' + || message.length < 1 + || message.length > 500 + || !/^[\x20-\x7e]+$/.test(message) + ) { + fail(`${path}[${index}]`, 'message must be 1-500 printable ASCII characters'); + } + return message; + }); +} + +function rejectUnknownFields(step, allowed, path) { + for (const field of Object.keys(step)) { + if (!allowed.includes(field)) { + fail(path, `unknown field "${field}"`); + } + } +} + +function validateStep(step, path) { + if (!step || typeof step !== 'object' || Array.isArray(step)) { + fail(path, 'step must be an object'); + } + + if (step.type === 'message') { + rejectUnknownFields(step, ['type'], path); + return { type: 'message' }; + } + + if (step.type === 'keystroke') { + rejectUnknownFields(step, ['type', 'key', 'modifiers'], path); + const validKey = typeof step.key === 'string' + && (/^[a-z0-9]$/i.test(step.key) || VALID_KEYS.has(step.key)); + if (!validKey) { + fail(`${path}.key`, 'must be one ASCII alphanumeric character or a supported named key'); + } + if (!Array.isArray(step.modifiers)) { + fail(`${path}.modifiers`, 'must be an array'); + } + const uniqueModifiers = new Set(step.modifiers); + if ( + uniqueModifiers.size !== step.modifiers.length + || step.modifiers.some(modifier => !VALID_MODIFIERS.has(modifier)) + ) { + fail(`${path}.modifiers`, 'must contain unique supported modifier names'); + } + return { type: 'keystroke', key: step.key, modifiers: [...step.modifiers] }; + } + + if (step.type === 'delay') { + rejectUnknownFields(step, ['type', 'ms'], path); + if (!Number.isInteger(step.ms) || step.ms < 0 || step.ms > 2000) { + fail(path, 'delay ms must be an integer from 0 through 2000'); + } + return { type: 'delay', ms: step.ms }; + } + + fail(path, `unknown step type "${step.type}"`); +} + +function validateSequence(sequence, path) { + if (!Array.isArray(sequence) || sequence.length < 1 || sequence.length > 16) { + fail(path, 'steps must contain 1-16 entries'); + } + + const steps = sequence.map((step, index) => validateStep(step, `${path}[${index}]`)); + if (steps.filter(step => step.type === 'message').length !== 1) { + fail(path, 'must contain exactly one message step'); + } + return steps; +} + +function validateSteps(steps, path) { + if (!steps || typeof steps !== 'object' || Array.isArray(steps)) { + fail(path, 'must be an object with a default step sequence'); + } + for (const platform of Object.keys(steps)) { + if (!VALID_PLATFORMS.has(platform)) { + fail(`${path}.${platform}`, 'unknown platform override'); + } + } + if (!Object.hasOwn(steps, 'default')) { + fail(`${path}.default`, 'is required'); + } + + return Object.fromEntries( + Object.entries(steps).map(([platform, sequence]) => [ + platform, + validateSequence(sequence, `${path}.${platform}`), + ]), + ); +} + +function validateProfile(profile, index) { + const path = `profiles[${index}]`; + if (!profile || typeof profile !== 'object' || Array.isArray(profile)) { + fail(path, 'profile must be an object'); + } + return { + id: validateName(profile.id, `${path}.id`), + label: validateName(profile.label, `${path}.label`), + messages: validateMessages(profile.messages, `${path}.messages`), + steps: validateSteps(profile.steps, `${path}.steps`), + }; +} + +function mergeProfiles(configObject) { + if (!configObject || typeof configObject !== 'object' || Array.isArray(configObject)) { + fail('config', 'must be an object'); + } + if (configObject.version !== 1) { + fail('config.version', 'must be 1'); + } + if (!Array.isArray(configObject.profiles)) { + fail('config.profiles', 'must be an array'); + } + if (configObject.profiles.length > 50) { + fail('config.profiles', 'must contain at most 50 custom profiles'); + } + + const seenIds = new Set(BUILT_IN_PROFILES.map(profile => profile.id)); + const customProfiles = configObject.profiles.map((profile, index) => { + const validated = validateProfile(profile, index); + if (seenIds.has(validated.id)) { + fail(`profiles[${index}].id`, 'duplicate ID or collision with a built-in profile'); + } + seenIds.add(validated.id); + return deepFreeze(validated); + }); + + return Object.freeze([...BUILT_IN_PROFILES, ...customProfiles]); +} + +function resolveSteps(profile, platform) { + return profile.steps[platform] || profile.steps.default; +} + +function selectMessage(profile, randomFn = Math.random) { + return profile.messages[Math.floor(randomFn() * profile.messages.length)]; +} + +module.exports = { + DEFAULT_PROFILE_ID, + BUILT_IN_PROFILES, + mergeProfiles, + resolveSteps, + selectMessage, +}; diff --git a/test/agent-profiles.test.js b/test/agent-profiles.test.js new file mode 100644 index 0000000..60a21d1 --- /dev/null +++ b/test/agent-profiles.test.js @@ -0,0 +1,151 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + DEFAULT_PROFILE_ID, + BUILT_IN_PROFILES, + mergeProfiles, + resolveSteps, + selectMessage, +} = require('../lib/agent-profiles'); + +function message() { + return { type: 'message' }; +} + +function customProfile(overrides = {}) { + return { + id: 'custom-agent', + label: 'Custom Agent', + messages: ['Keep going'], + steps: { default: [message()] }, + ...overrides, + }; +} + +function config(profiles) { + return { version: 1, profiles }; +} + +test('exports immutable Claude Code and Codex built-in profiles with their platform actions', () => { + assert.equal(DEFAULT_PROFILE_ID, 'claude-code'); + assert.equal(BUILT_IN_PROFILES.length, 2); + assert.equal(Object.isFrozen(BUILT_IN_PROFILES), true); + + const claude = BUILT_IN_PROFILES[0]; + const codex = BUILT_IN_PROFILES[1]; + + assert.deepEqual(claude, { + id: 'claude-code', + label: 'Claude Code', + messages: [ + 'FASTER', + 'FASTER', + 'FASTER', + 'GO FASTER', + 'Faster CLANKER', + 'Work FASTER', + 'Speed it up clanker', + ], + steps: { + default: [ + { type: 'keystroke', key: 'c', modifiers: ['control'] }, + message(), + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], + darwin: [ + { type: 'keystroke', key: 'c', modifiers: ['control'] }, + { type: 'delay', ms: 300 }, + message(), + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], + }, + }); + assert.deepEqual(codex, { + id: 'codex', + label: 'Codex', + messages: claude.messages, + steps: { + default: [message(), { type: 'keystroke', key: 'enter', modifiers: [] }], + }, + }); + assert.equal(Object.isFrozen(claude.steps.default), true); +}); + +test('resolves platform overrides and falls back to default steps', () => { + const profile = customProfile({ + steps: { + default: [message()], + win32: [{ type: 'keystroke', key: 'tab', modifiers: [] }, message()], + }, + }); + + assert.deepEqual(resolveSteps(profile, 'win32'), [ + { type: 'keystroke', key: 'tab', modifiers: [] }, + message(), + ]); + assert.deepEqual(resolveSteps(profile, 'linux'), [message()]); +}); + +test('selects a message deterministically from the supplied random function', () => { + const profile = customProfile({ messages: ['First', 'Second', 'Third'] }); + + assert.equal(selectMessage(profile, () => 0), 'First'); + assert.equal(selectMessage(profile, () => 0.999), 'Third'); +}); + +test('merges valid custom profiles after built-ins without mutating inputs', () => { + const supplied = config([customProfile({ id: 'neutral-tool', label: 'Neutral Tool' })]); + const before = structuredClone(supplied); + + const profiles = mergeProfiles(supplied); + + assert.deepEqual(profiles.map(profile => profile.id), ['claude-code', 'codex', 'neutral-tool']); + assert.deepEqual(supplied, before); + assert.notEqual(profiles[2], supplied.profiles[0]); +}); + +test('rejects invalid top-level configuration, custom count, IDs, labels, and duplicate IDs', () => { + assert.throws(() => mergeProfiles({ version: 2, profiles: [] }), /version/i); + assert.throws(() => mergeProfiles({ version: 1, profiles: 'not-an-array' }), /profiles/i); + assert.throws(() => mergeProfiles(config(Array.from({ length: 51 }, (_, index) => customProfile({ id: `neutral-${index}` })))), /50/i); + assert.throws(() => mergeProfiles(config([customProfile({ id: '' })])), /id/i); + assert.throws(() => mergeProfiles(config([customProfile({ id: 'x'.repeat(65) })])), /id/i); + assert.throws(() => mergeProfiles(config([customProfile({ label: '' })])), /label/i); + assert.throws(() => mergeProfiles(config([customProfile({ label: 'x'.repeat(65) })])), /label/i); + assert.throws(() => mergeProfiles(config([customProfile({ id: 'codex' })])), /built-in|duplicate/i); + assert.throws(() => mergeProfiles(config([customProfile(), customProfile()])), /duplicate/i); +}); + +test('rejects invalid message collections and message text', () => { + assert.throws(() => mergeProfiles(config([customProfile({ messages: [] })])), /messages/i); + assert.throws(() => mergeProfiles(config([customProfile({ messages: Array(21).fill('Keep going') })])), /messages/i); + assert.throws(() => mergeProfiles(config([customProfile({ messages: [''] })])), /message/i); + assert.throws(() => mergeProfiles(config([customProfile({ messages: ['x'.repeat(501)] })])), /message/i); + assert.throws(() => mergeProfiles(config([customProfile({ messages: ['Keep going \u2713'] })])), /ASCII/i); +}); + +test('rejects missing or invalid platform step sequences', () => { + assert.throws(() => mergeProfiles(config([customProfile({ steps: {} })])), /default/i); + assert.throws(() => mergeProfiles(config([customProfile({ steps: { default: [] } })])), /steps/i); + assert.throws(() => mergeProfiles(config([customProfile({ steps: { default: Array(17).fill(message()) } })])), /steps/i); + assert.throws(() => mergeProfiles(config([customProfile({ steps: { default: [{ type: 'delay', ms: 1 }] } })])), /exactly one message/i); + assert.throws(() => mergeProfiles(config([customProfile({ steps: { default: [message(), message()] } })])), /exactly one message/i); + assert.throws(() => mergeProfiles(config([customProfile({ steps: { default: [message()], android: [message()] } })])), /platform/i); +}); + +test('rejects invalid keystroke, delay, and step fields', () => { + const invalid = (step, pattern) => assert.throws( + () => mergeProfiles(config([customProfile({ steps: { default: [step, message()] } })])), + pattern, + ); + + invalid({ type: 'keystroke', key: 'f1', modifiers: [] }, /key/i); + invalid({ type: 'keystroke', key: 'a', modifiers: ['control', 'control'] }, /modifier/i); + invalid({ type: 'keystroke', key: 'a', modifiers: ['super'] }, /modifier/i); + invalid({ type: 'delay', ms: 1.5 }, /delay/i); + invalid({ type: 'delay', ms: -1 }, /delay/i); + invalid({ type: 'delay', ms: 2001 }, /delay/i); + invalid({ type: 'keystroke', key: 'a', modifiers: [], command: 'run this' }, /unknown/i); + invalid({ type: 'shell', command: 'run this' }, /unknown|type/i); +}); From 3b6041ce30d9de24fb504c0f73fb0911fc205f13 Mon Sep 17 00:00:00 2001 From: sparkg Date: Thu, 30 Jul 2026 17:51:36 +0800 Subject: [PATCH 02/14] complete agent profile validation tests --- test/agent-profiles.test.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/agent-profiles.test.js b/test/agent-profiles.test.js index 60a21d1..0be1288 100644 --- a/test/agent-profiles.test.js +++ b/test/agent-profiles.test.js @@ -105,10 +105,30 @@ test('merges valid custom profiles after built-ins without mutating inputs', () assert.notEqual(profiles[2], supplied.profiles[0]); }); +test('rejects an invalid later profile atomically without mutating input or retaining partial state', () => { + const supplied = config([ + customProfile({ id: 'first-valid' }), + customProfile({ id: 'later-invalid', messages: [] }), + ]); + const before = structuredClone(supplied); + + assert.throws(() => mergeProfiles(supplied), /messages/i); + assert.deepEqual(supplied, before); + + const retried = mergeProfiles(config([customProfile({ id: 'first-valid' })])); + assert.deepEqual(retried.map(profile => profile.id), [ + 'claude-code', + 'codex', + 'first-valid', + ]); +}); + test('rejects invalid top-level configuration, custom count, IDs, labels, and duplicate IDs', () => { + assert.throws(() => mergeProfiles(null), /config/i); assert.throws(() => mergeProfiles({ version: 2, profiles: [] }), /version/i); assert.throws(() => mergeProfiles({ version: 1, profiles: 'not-an-array' }), /profiles/i); assert.throws(() => mergeProfiles(config(Array.from({ length: 51 }, (_, index) => customProfile({ id: `neutral-${index}` })))), /50/i); + assert.throws(() => mergeProfiles(config([null])), /profile/i); assert.throws(() => mergeProfiles(config([customProfile({ id: '' })])), /id/i); assert.throws(() => mergeProfiles(config([customProfile({ id: 'x'.repeat(65) })])), /id/i); assert.throws(() => mergeProfiles(config([customProfile({ label: '' })])), /label/i); @@ -118,16 +138,20 @@ test('rejects invalid top-level configuration, custom count, IDs, labels, and du }); test('rejects invalid message collections and message text', () => { + assert.throws(() => mergeProfiles(config([customProfile({ messages: 'Keep going' })])), /messages/i); assert.throws(() => mergeProfiles(config([customProfile({ messages: [] })])), /messages/i); assert.throws(() => mergeProfiles(config([customProfile({ messages: Array(21).fill('Keep going') })])), /messages/i); + assert.throws(() => mergeProfiles(config([customProfile({ messages: [42] })])), /message/i); assert.throws(() => mergeProfiles(config([customProfile({ messages: [''] })])), /message/i); assert.throws(() => mergeProfiles(config([customProfile({ messages: ['x'.repeat(501)] })])), /message/i); assert.throws(() => mergeProfiles(config([customProfile({ messages: ['Keep going \u2713'] })])), /ASCII/i); }); test('rejects missing or invalid platform step sequences', () => { + assert.throws(() => mergeProfiles(config([customProfile({ steps: null })])), /steps/i); assert.throws(() => mergeProfiles(config([customProfile({ steps: {} })])), /default/i); assert.throws(() => mergeProfiles(config([customProfile({ steps: { default: [] } })])), /steps/i); + assert.throws(() => mergeProfiles(config([customProfile({ steps: { default: [null] } })])), /step/i); assert.throws(() => mergeProfiles(config([customProfile({ steps: { default: Array(17).fill(message()) } })])), /steps/i); assert.throws(() => mergeProfiles(config([customProfile({ steps: { default: [{ type: 'delay', ms: 1 }] } })])), /exactly one message/i); assert.throws(() => mergeProfiles(config([customProfile({ steps: { default: [message(), message()] } })])), /exactly one message/i); @@ -141,11 +165,14 @@ test('rejects invalid keystroke, delay, and step fields', () => { ); invalid({ type: 'keystroke', key: 'f1', modifiers: [] }, /key/i); + invalid({ type: 'keystroke', key: 'a', modifiers: 'control' }, /modifier/i); invalid({ type: 'keystroke', key: 'a', modifiers: ['control', 'control'] }, /modifier/i); invalid({ type: 'keystroke', key: 'a', modifiers: ['super'] }, /modifier/i); invalid({ type: 'delay', ms: 1.5 }, /delay/i); invalid({ type: 'delay', ms: -1 }, /delay/i); invalid({ type: 'delay', ms: 2001 }, /delay/i); invalid({ type: 'keystroke', key: 'a', modifiers: [], command: 'run this' }, /unknown/i); + invalid({ type: 'message', command: 'run this' }, /unknown/i); + invalid({ type: 'delay', ms: 1, command: 'run this' }, /unknown/i); invalid({ type: 'shell', command: 'run this' }, /unknown|type/i); }); From b10ae10bd24ceaa584b0886af53c96573a08a5db Mon Sep 17 00:00:00 2001 From: sparkg Date: Thu, 30 Jul 2026 18:17:57 +0800 Subject: [PATCH 03/14] add portable input executor --- lib/input-executor.js | 227 ++++++++++++++++++++++++++++++++++++ test/input-executor.test.js | 192 ++++++++++++++++++++++++++++++ 2 files changed, 419 insertions(+) create mode 100644 lib/input-executor.js create mode 100644 test/input-executor.test.js diff --git a/lib/input-executor.js b/lib/input-executor.js new file mode 100644 index 0000000..bfd655b --- /dev/null +++ b/lib/input-executor.js @@ -0,0 +1,227 @@ +const { execFile: nodeExecFile } = require('child_process'); + +const KEYUP = 0x0002; + +const WINDOWS_KEYS = Object.freeze({ + enter: 0x0d, + escape: 0x1b, + tab: 0x09, + space: 0x20, + backspace: 0x08, + up: 0x26, + down: 0x28, + left: 0x25, + right: 0x27, +}); + +const WINDOWS_MODIFIERS = Object.freeze({ + control: 0x11, + alt: 0x12, + shift: 0x10, + meta: 0x5b, +}); + +const MAC_KEY_CODES = Object.freeze({ + enter: 36, + escape: 53, + tab: 48, + space: 49, + backspace: 51, + up: 126, + down: 125, + left: 123, + right: 124, +}); + +const MAC_MODIFIERS = Object.freeze({ + control: 'control down', + alt: 'option down', + shift: 'shift down', + meta: 'command down', +}); + +const LINUX_KEYS = Object.freeze({ + enter: 'Return', + escape: 'Escape', + tab: 'Tab', + space: 'space', + backspace: 'BackSpace', + up: 'Up', + down: 'Down', + left: 'Left', + right: 'Right', +}); + +const LINUX_MODIFIERS = Object.freeze({ + control: 'ctrl', + alt: 'alt', + shift: 'shift', + meta: 'super', +}); + +function defaultSleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function runExecFile(execFile, file, args) { + return new Promise((resolve, reject) => { + execFile(file, args, error => { + if (error) reject(error); + else resolve(); + }); + }); +} + +function tapWindowsKey(keybdEvent, vk) { + keybdEvent(vk, 0, 0, 0); + keybdEvent(vk, 0, KEYUP, 0); +} + +function windowsKeyCode(key) { + if (/^[a-z0-9]$/i.test(key)) return key.toUpperCase().charCodeAt(0); + return WINDOWS_KEYS[key]; +} + +function pressWindowsKey(keybdEvent, key, modifiers) { + const modifierCodes = modifiers.map(modifier => WINDOWS_MODIFIERS[modifier]); + for (const vk of modifierCodes) keybdEvent(vk, 0, 0, 0); + tapWindowsKey(keybdEvent, windowsKeyCode(key)); + for (const vk of [...modifierCodes].reverse()) keybdEvent(vk, 0, KEYUP, 0); +} + +function typeWindowsMessage(keybdEvent, vkKeyScanA, message) { + for (const character of message) { + const packed = vkKeyScanA(character.charCodeAt(0)); + if (packed === -1 || (packed & 0xffff) === 0xffff) { + throw new Error(`Unable to map ASCII character ${JSON.stringify(character)}`); + } + + const vk = packed & 0xff; + const shiftState = (packed >> 8) & 0xff; + const modifiers = []; + if (shiftState & 1) modifiers.push(WINDOWS_MODIFIERS.shift); + if (shiftState & 2) modifiers.push(WINDOWS_MODIFIERS.control); + if (shiftState & 4) modifiers.push(WINDOWS_MODIFIERS.alt); + for (const modifier of modifiers) keybdEvent(modifier, 0, 0, 0); + tapWindowsKey(keybdEvent, vk); + for (const modifier of [...modifiers].reverse()) { + keybdEvent(modifier, 0, KEYUP, 0); + } + } +} + +function createWindowsDriver(deps) { + const { keybdEvent, vkKeyScanA, sleep = defaultSleep } = deps; + if (typeof keybdEvent !== 'function') { + throw new Error('Windows input requires keybdEvent'); + } + if (typeof vkKeyScanA !== 'function') { + throw new Error('Windows input requires vkKeyScanA'); + } + + return { + async execute(steps, message) { + for (const step of steps) { + if (step.type === 'keystroke') { + pressWindowsKey(keybdEvent, step.key, step.modifiers); + } else if (step.type === 'message') { + typeWindowsMessage(keybdEvent, vkKeyScanA, message); + } else if (step.type === 'delay') { + await sleep(step.ms); + } else { + throw new Error(`Unsupported input step type: ${step.type}`); + } + } + }, + }; +} + +function escapeAppleScriptText(message) { + return message.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function macUsingClause(modifiers) { + if (modifiers.length === 0) return ''; + return ` using {${modifiers.map(modifier => MAC_MODIFIERS[modifier]).join(', ')}}`; +} + +function buildAppleScript(steps, message) { + const lines = ['tell application "System Events"']; + for (const step of steps) { + if (step.type === 'message') { + lines.push(` keystroke "${escapeAppleScriptText(message)}"`); + } else if (step.type === 'delay') { + lines.push(` delay ${step.ms / 1000}`); + } else if (step.type === 'keystroke') { + const using = macUsingClause(step.modifiers); + if (Object.hasOwn(MAC_KEY_CODES, step.key)) { + lines.push(` key code ${MAC_KEY_CODES[step.key]}${using}`); + } else { + lines.push(` keystroke "${step.key}"${using}`); + } + } else { + throw new Error(`Unsupported input step type: ${step.type}`); + } + } + lines.push('end tell'); + return lines.join('\n'); +} + +function createMacDriver(deps) { + const execFile = deps.execFile || nodeExecFile; + return { + execute(steps, message) { + return runExecFile(execFile, 'osascript', ['-e', buildAppleScript(steps, message)]); + }, + }; +} + +function linuxKeyName(key) { + return LINUX_KEYS[key] || key; +} + +function buildXdotoolArgs(steps, message) { + const args = []; + for (const step of steps) { + if (step.type === 'message') { + args.push('type', '--delay', '1', '--clearmodifiers', '--', message); + } else if (step.type === 'delay') { + args.push('sleep', String(step.ms / 1000)); + } else if (step.type === 'keystroke') { + const modifiers = step.modifiers.map(modifier => LINUX_MODIFIERS[modifier]); + const chord = [...modifiers, linuxKeyName(step.key)].join('+'); + args.push('key', '--clearmodifiers', chord); + } else { + throw new Error(`Unsupported input step type: ${step.type}`); + } + } + return args; +} + +function createLinuxDriver(deps) { + const execFile = deps.execFile || nodeExecFile; + return { + execute(steps, message) { + return runExecFile(execFile, 'xdotool', buildXdotoolArgs(steps, message)); + }, + }; +} + +function createInputDriver(platform, deps = {}) { + if (platform === 'win32') return createWindowsDriver(deps); + if (platform === 'darwin') return createMacDriver(deps); + if (platform === 'linux') return createLinuxDriver(deps); + throw new Error(`Unsupported platform: ${platform}`); +} + +async function executeSteps(driver, steps, message) { + if (!driver || typeof driver.execute !== 'function') { + throw new Error('Input driver must provide an execute function'); + } + return driver.execute(steps, message); +} + +module.exports = { + createInputDriver, + executeSteps, +}; diff --git a/test/input-executor.test.js b/test/input-executor.test.js new file mode 100644 index 0000000..890519d --- /dev/null +++ b/test/input-executor.test.js @@ -0,0 +1,192 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + createInputDriver, + executeSteps, +} = require('../lib/input-executor'); + +const KEYUP = 0x0002; + +test('Windows executes portable steps in order and types ASCII through VkKeyScanA', async () => { + const calls = []; + const packedKeys = new Map([ + ['A', 0x0141], + ['!', 0x0131], + ]); + const driver = createInputDriver('win32', { + keybdEvent(vk, scan, flags, extraInfo) { + calls.push(['key', vk, scan, flags, extraInfo]); + }, + vkKeyScanA(code) { + const character = String.fromCharCode(code); + calls.push(['scan', character]); + return packedKeys.get(character) ?? -1; + }, + async sleep(ms) { + calls.push(['sleep', ms]); + }, + }); + + await executeSteps(driver, [ + { type: 'keystroke', key: 'c', modifiers: ['control'] }, + { type: 'delay', ms: 25 }, + { type: 'message' }, + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], 'A!'); + + assert.deepEqual(calls, [ + ['key', 0x11, 0, 0, 0], + ['key', 0x43, 0, 0, 0], + ['key', 0x43, 0, KEYUP, 0], + ['key', 0x11, 0, KEYUP, 0], + ['sleep', 25], + ['scan', 'A'], + ['key', 0x10, 0, 0, 0], + ['key', 0x41, 0, 0, 0], + ['key', 0x41, 0, KEYUP, 0], + ['key', 0x10, 0, KEYUP, 0], + ['scan', '!'], + ['key', 0x10, 0, 0, 0], + ['key', 0x31, 0, 0, 0], + ['key', 0x31, 0, KEYUP, 0], + ['key', 0x10, 0, KEYUP, 0], + ['key', 0x0d, 0, 0, 0], + ['key', 0x0d, 0, KEYUP, 0], + ]); +}); + +test('Windows maps portable modifiers and releases them in reverse order', async () => { + const calls = []; + const driver = createInputDriver('win32', { + keybdEvent(vk, scan, flags) { + calls.push([vk, flags]); + }, + vkKeyScanA() { + return 0x41; + }, + sleep: async () => {}, + }); + + await driver.execute([ + { type: 'keystroke', key: 'left', modifiers: ['meta', 'alt', 'shift'] }, + ], 'unused'); + + assert.deepEqual(calls, [ + [0x5b, 0], + [0x12, 0], + [0x10, 0], + [0x25, 0], + [0x25, KEYUP], + [0x10, KEYUP], + [0x12, KEYUP], + [0x5b, KEYUP], + ]); +}); + +test('Windows rejects missing injection functions and unmappable ASCII', async () => { + assert.throws( + () => createInputDriver('win32', { keybdEvent() {} }), + /vkKeyScanA/i, + ); + + const driver = createInputDriver('win32', { + keybdEvent() {}, + vkKeyScanA() { return -1; }, + sleep: async () => {}, + }); + await assert.rejects( + driver.execute([{ type: 'message' }], 'A'), + /map ASCII character/i, + ); +}); + +test('macOS executes one ordered AppleScript with escaped text, named keys, modifiers, and delay seconds', async () => { + const calls = []; + const driver = createInputDriver('darwin', { + execFile(file, args, callback) { + calls.push([file, args]); + callback(null); + }, + }); + + await driver.execute([ + { type: 'keystroke', key: 'c', modifiers: ['control'] }, + { type: 'delay', ms: 300 }, + { type: 'message' }, + { type: 'keystroke', key: 'tab', modifiers: ['meta', 'shift'] }, + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], 'A"\\B'); + + assert.deepEqual(calls, [[ + 'osascript', + ['-e', [ + 'tell application "System Events"', + ' keystroke "c" using {control down}', + ' delay 0.3', + ' keystroke "A\\"\\\\B"', + ' key code 48 using {command down, shift down}', + ' key code 36', + 'end tell', + ].join('\n')], + ]]); +}); + +test('Linux executes one ordered xdotool argv plan', async () => { + const calls = []; + const driver = createInputDriver('linux', { + execFile(file, args, callback) { + calls.push([file, args]); + callback(null); + }, + }); + + await executeSteps(driver, [ + { type: 'keystroke', key: 'c', modifiers: ['control'] }, + { type: 'delay', ms: 250 }, + { type: 'message' }, + { type: 'keystroke', key: 'left', modifiers: ['meta', 'alt'] }, + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], 'GO FASTER'); + + assert.deepEqual(calls, [[ + 'xdotool', + [ + 'key', '--clearmodifiers', 'ctrl+c', + 'sleep', '0.25', + 'type', '--delay', '1', '--clearmodifiers', '--', 'GO FASTER', + 'key', '--clearmodifiers', 'super+alt+Left', + 'key', '--clearmodifiers', 'Return', + ], + ]]); +}); + +test('macOS and Linux execution errors propagate', async (t) => { + for (const [platform, expected] of [['darwin', 'osascript failed'], ['linux', 'xdotool failed']]) { + await t.test(platform, async () => { + const driver = createInputDriver(platform, { + execFile(file, args, callback) { + callback(new Error(expected)); + }, + }); + await assert.rejects( + driver.execute([{ type: 'message' }], 'FASTER'), + new RegExp(expected), + ); + }); + } +}); + +test('unsupported platforms fail explicitly', () => { + assert.throws( + () => createInputDriver('freebsd', {}), + /unsupported platform.*freebsd/i, + ); +}); + +test('executeSteps requires a driver execute function', async () => { + await assert.rejects( + executeSteps({}, [{ type: 'message' }], 'FASTER'), + /driver.*execute/i, + ); +}); From eecb675ea1f4c9ea4014deb5a3469c13fdebc102 Mon Sep 17 00:00:00 2001 From: sparkg Date: Thu, 30 Jul 2026 18:21:39 +0800 Subject: [PATCH 04/14] complete input executor validation tests --- test/input-executor.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/input-executor.test.js b/test/input-executor.test.js index 890519d..e38ca41 100644 --- a/test/input-executor.test.js +++ b/test/input-executor.test.js @@ -85,6 +85,10 @@ test('Windows maps portable modifiers and releases them in reverse order', async }); test('Windows rejects missing injection functions and unmappable ASCII', async () => { + assert.throws( + () => createInputDriver('win32', { vkKeyScanA() {} }), + /keybdEvent/i, + ); assert.throws( () => createInputDriver('win32', { keybdEvent() {} }), /vkKeyScanA/i, From 5a356501ef5e77b04bfc622c1016e77802789bb2 Mon Sep 17 00:00:00 2001 From: sparkg Date: Thu, 30 Jul 2026 18:31:41 +0800 Subject: [PATCH 05/14] wire target agent profiles --- lib/agent-runtime.js | 172 ++++++++++++++++++++++++ main.js | 180 ++++++++++++------------- test/agent-runtime.test.js | 261 +++++++++++++++++++++++++++++++++++++ 3 files changed, 513 insertions(+), 100 deletions(-) create mode 100644 lib/agent-runtime.js create mode 100644 test/agent-runtime.test.js diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js new file mode 100644 index 0000000..73dd3a0 --- /dev/null +++ b/lib/agent-runtime.js @@ -0,0 +1,172 @@ +const fs = require('node:fs/promises'); +const path = require('node:path'); + +const { + BUILT_IN_PROFILES, + DEFAULT_PROFILE_ID, + mergeProfiles, + resolveSteps, + selectMessage, +} = require('./agent-profiles'); +const { executeSteps } = require('./input-executor'); + +const CONFIG_FILENAME = 'agent-profiles.json'; +const STATE_FILENAME = 'agent-profile-state.json'; +const EMPTY_CONFIG = Object.freeze({ version: 1, profiles: Object.freeze([]) }); + +function isMissingFile(error) { + return error && error.code === 'ENOENT'; +} + +async function readOptionalFile(fileSystem, filePath) { + try { + return await fileSystem.readFile(filePath, 'utf8'); + } catch (error) { + if (isMissingFile(error)) return null; + throw error; + } +} + +async function loadRegistry(fileSystem, configPath) { + const contents = await readOptionalFile(fileSystem, configPath); + return contents === null + ? BUILT_IN_PROFILES + : mergeProfiles(JSON.parse(contents)); +} + +async function loadActiveProfileId(fileSystem, statePath, registry) { + const contents = await readOptionalFile(fileSystem, statePath); + if (contents === null) return DEFAULT_PROFILE_ID; + + try { + const state = JSON.parse(contents); + const requestedId = state && state.activeProfileId; + return registry.some(profile => profile.id === requestedId) + ? requestedId + : DEFAULT_PROFILE_ID; + } catch { + return DEFAULT_PROFILE_ID; + } +} + +async function writeActiveProfileState( + fileSystem, + statePath, + activeProfileId, + tempToken = `${process.pid}-${Date.now()}`, +) { + const tempPath = `${statePath}.${tempToken}.tmp`; + const contents = `${JSON.stringify({ activeProfileId }, null, 2)}\n`; + await fileSystem.writeFile(tempPath, contents, 'utf8'); + await fileSystem.rename(tempPath, statePath); +} + +async function createConfigIfMissing(fileSystem, configPath) { + const contents = `${JSON.stringify(EMPTY_CONFIG, null, 2)}\n`; + try { + await fileSystem.writeFile(configPath, contents, { encoding: 'utf8', flag: 'wx' }); + } catch (error) { + if (error && error.code === 'EEXIST') return; + throw error; + } +} + +function createAgentRuntime({ + userDataPath, + platform, + driver, + randomFn = Math.random, + fileSystem = fs, + openPath, +}) { + if (typeof userDataPath !== 'string' || userDataPath.length === 0) { + throw new Error('userDataPath is required'); + } + + const configPath = path.join(userDataPath, CONFIG_FILENAME); + const statePath = path.join(userDataPath, STATE_FILENAME); + let registry = BUILT_IN_PROFILES; + let activeProfileId = DEFAULT_PROFILE_ID; + + async function persistSelection(profileId) { + await writeActiveProfileState(fileSystem, statePath, profileId); + } + + return { + async initialize() { + const nextRegistry = await loadRegistry(fileSystem, configPath); + const nextActiveProfileId = await loadActiveProfileId(fileSystem, statePath, nextRegistry); + registry = nextRegistry; + activeProfileId = nextActiveProfileId; + }, + + getProfiles() { + return registry; + }, + + getActiveProfileId() { + return activeProfileId; + }, + + async selectProfile(profileId) { + if (!registry.some(profile => profile.id === profileId)) { + throw new Error(`Unknown agent profile: ${profileId}`); + } + await persistSelection(profileId); + activeProfileId = profileId; + }, + + async reloadProfiles() { + const nextRegistry = await loadRegistry(fileSystem, configPath); + const activeStillExists = nextRegistry.some(profile => profile.id === activeProfileId); + const nextActiveProfileId = activeStillExists ? activeProfileId : DEFAULT_PROFILE_ID; + + if (!activeStillExists) { + await persistSelection(nextActiveProfileId); + } + registry = nextRegistry; + activeProfileId = nextActiveProfileId; + }, + + async openProfileConfig() { + if (typeof openPath !== 'function') { + throw new Error('openPath is required to open the profile config'); + } + await createConfigIfMissing(fileSystem, configPath); + const errorMessage = await openPath(configPath); + if (errorMessage) throw new Error(errorMessage); + }, + + async executeActiveProfile() { + const profile = registry.find(candidate => candidate.id === activeProfileId); + const steps = resolveSteps(profile, platform); + const message = selectMessage(profile, randomFn); + return executeSteps(driver, steps, message); + }, + }; +} + +function createTrayMenuTemplate(runtime, handlers) { + return [ + { + label: 'Target Agent', + submenu: runtime.getProfiles().map(profile => ({ + label: profile.label, + type: 'radio', + checked: profile.id === runtime.getActiveProfileId(), + click: () => handlers.selectProfile(profile.id), + })), + }, + { label: 'Open profile config...', click: handlers.openProfileConfig }, + { label: 'Reload profiles', click: handlers.reloadProfiles }, + { label: 'Quit', click: handlers.quit }, + ]; +} + +module.exports = { + CONFIG_FILENAME, + STATE_FILENAME, + createAgentRuntime, + createTrayMenuTemplate, + writeActiveProfileState, +}; diff --git a/main.js b/main.js index 1360458..c3ea439 100644 --- a/main.js +++ b/main.js @@ -1,8 +1,20 @@ -const { app, BrowserWindow, Tray, Menu, ipcMain, nativeImage, screen } = require('electron'); +const { + app, + BrowserWindow, + Tray, + Menu, + ipcMain, + nativeImage, + screen, + shell, + dialog, +} = require('electron'); const path = require('path'); const fs = require('fs'); const os = require('os'); const { execFile } = require('child_process'); +const { createInputDriver } = require('./lib/input-executor'); +const { createAgentRuntime, createTrayMenuTemplate } = require('./lib/agent-runtime'); // ── Win32 FFI (Windows only) ──────────────────────────────────────────────── let keybd_event, VkKeyScanA; @@ -18,13 +30,10 @@ if (process.platform === 'win32') { } // ── Globals ───────────────────────────────────────────────────────────────── -let tray, overlay; +let tray, overlay, agentRuntime; let overlayReady = false; let spawnQueued = false; -const VK_CONTROL = 0x11; -const VK_RETURN = 0x0D; -const VK_C = 0x43; const VK_MENU = 0x12; // Alt const VK_TAB = 0x09; const KEYUP = 0x0002; @@ -172,118 +181,89 @@ function toggleOverlay() { // ── IPC ───────────────────────────────────────────────────────────────────── ipcMain.on('whip-crack', () => { - try { - sendMacro(); - } catch (err) { - console.warn('sendMacro failed:', err?.message || err); - } + void executeActiveProfile().catch(error => { + showError('Whip action failed', error); + }); }); ipcMain.on('hide-overlay', () => { if (overlay) overlay.hide(); }); -// ── Macro: immediate Ctrl+C, type "Go FASER", Enter ─────────────────────── -function sendMacro() { - // Pick a random phrase from a list of similar phrases and type it out - const phrases = [ - 'FASTER', - 'FASTER', - 'FASTER', - 'GO FASTER', - 'Faster CLANKER', - 'Work FASTER', - 'Speed it up clanker', - ]; - const chosen = phrases[Math.floor(Math.random() * phrases.length)]; - - if (process.platform === 'win32') { - sendMacroWindows(chosen); - } else if (process.platform === 'darwin') { - sendMacroMac(chosen); - } else if (process.platform === 'linux') { - sendMacroLinux(chosen); - } +// ── Agent profile execution ───────────────────────────────────────────────── +function showError(title, error) { + const message = error?.message || String(error); + console.warn(`${title}:`, message); + if (app.isReady()) dialog.showErrorBox(title, message); } -function sendMacroWindows(text) { - if (!keybd_event || !VkKeyScanA) return; - const tapKey = vk => { - keybd_event(vk, 0, 0, 0); - keybd_event(vk, 0, KEYUP, 0); - }; - const tapChar = ch => { - const packed = VkKeyScanA(ch.charCodeAt(0)); - if (packed === -1) return; - const vk = packed & 0xff; - const shiftState = (packed >> 8) & 0xff; - if (shiftState & 1) keybd_event(0x10, 0, 0, 0); // Shift down - tapKey(vk); - if (shiftState & 1) keybd_event(0x10, 0, KEYUP, 0); // Shift up +function createProductionInputDriver() { + return { + execute(steps, message) { + const driver = createInputDriver(process.platform, { + execFile, + keybdEvent: keybd_event, + vkKeyScanA: VkKeyScanA, + }); + return driver.execute(steps, message); + }, }; - - // Ctrl+C (interrupt) - keybd_event(VK_CONTROL, 0, 0, 0); - keybd_event(VK_C, 0, 0, 0); - keybd_event(VK_C, 0, KEYUP, 0); - keybd_event(VK_CONTROL, 0, KEYUP, 0); - for (const ch of text) tapChar(ch); - keybd_event(VK_RETURN, 0, 0, 0); - keybd_event(VK_RETURN, 0, KEYUP, 0); } -function sendMacroMac(text) { - const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - const interruptScript = [ - 'tell application "System Events"', - ' key code 8 using {control down}', // Ctrl+C interrupt - 'end tell' - ].join('\n'); - const typeAndEnterScript = [ - 'tell application "System Events"', - ` keystroke "${escaped}"`, - ' key code 36', // Enter - 'end tell' - ].join('\n'); - - execFile('osascript', ['-e', interruptScript], err => { - if (err) { - console.warn('mac macro failed (enable Accessibility for terminal/app):', err.message); - return; - } +async function executeActiveProfile() { + if (!agentRuntime) throw new Error('Agent profiles are not ready'); + await agentRuntime.executeActiveProfile(); +} - setTimeout(() => { - execFile('osascript', ['-e', typeAndEnterScript], err2 => { - if (err2) { - console.warn('mac macro failed (enable Accessibility for terminal/app):', err2.message); - } - }); - }, 300); - }); +function runMenuAction(title, action, rebuildAfterward = false) { + void Promise.resolve() + .then(action) + .then(() => { + if (rebuildAfterward) rebuildTrayMenu(); + }) + .catch(error => { + showError(title, error); + if (rebuildAfterward) rebuildTrayMenu(); + }); } -function sendMacroLinux(text) { - execFile( - 'xdotool', - [ - 'key', '--clearmodifiers', 'ctrl+c', - 'type', '--delay', '1', '--clearmodifiers', '--', text, - 'key', 'Return', - ], - err => { - if (err) { - console.warn('linux macro failed. Install xdotool:', err.message); - } - } - ); +function rebuildTrayMenu() { + if (!tray || !agentRuntime) return; + const template = createTrayMenuTemplate(agentRuntime, { + selectProfile(profileId) { + runMenuAction( + 'Unable to select target agent', + () => agentRuntime.selectProfile(profileId), + true, + ); + }, + openProfileConfig() { + runMenuAction('Unable to open profile config', () => agentRuntime.openProfileConfig()); + }, + reloadProfiles() { + runMenuAction('Unable to reload profiles', () => agentRuntime.reloadProfiles(), true); + }, + quit() { + app.quit(); + }, + }); + tray.setContextMenu(Menu.buildFromTemplate(template)); } // ── App lifecycle ─────────────────────────────────────────────────────────── app.whenReady().then(async () => { + agentRuntime = createAgentRuntime({ + userDataPath: app.getPath('userData'), + platform: process.platform, + driver: createProductionInputDriver(), + openPath: filePath => shell.openPath(filePath), + }); + try { + await agentRuntime.initialize(); + } catch (error) { + showError('Unable to load agent profiles', error); + } + tray = new Tray(await getTrayIcon()); tray.setToolTip('OpenWhip - click for whip'); - tray.setContextMenu( - Menu.buildFromTemplate([ - { label: 'Quit', click: () => app.quit() }, - ]) - ); + rebuildTrayMenu(); tray.on('click', toggleOverlay); }); diff --git a/test/agent-runtime.test.js b/test/agent-runtime.test.js new file mode 100644 index 0000000..d9198ab --- /dev/null +++ b/test/agent-runtime.test.js @@ -0,0 +1,261 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + CONFIG_FILENAME, + STATE_FILENAME, + createAgentRuntime, + createTrayMenuTemplate, + writeActiveProfileState, +} = require('../lib/agent-runtime'); + +function customConfig(id = 'custom-agent') { + return { + version: 1, + profiles: [{ + id, + label: 'Custom Agent', + messages: ['Keep going'], + steps: { default: [{ type: 'message' }] }, + }], + }; +} + +async function makeUserData(t) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'openwhip-runtime-')); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + return directory; +} + +test('missing config and state initialize to built-ins with Claude Code active', async t => { + const userDataPath = await makeUserData(t); + const runtime = createAgentRuntime({ + userDataPath, + platform: 'win32', + driver: { async execute() {} }, + }); + + await runtime.initialize(); + + assert.deepEqual(runtime.getProfiles().map(profile => profile.id), ['claude-code', 'codex']); + assert.equal(runtime.getActiveProfileId(), 'claude-code'); +}); + +test('valid state selects a custom profile while corrupt or unknown state falls back', async t => { + const userDataPath = await makeUserData(t); + await fs.writeFile( + path.join(userDataPath, CONFIG_FILENAME), + JSON.stringify(customConfig()), + ); + const statePath = path.join(userDataPath, STATE_FILENAME); + + await fs.writeFile(statePath, JSON.stringify({ activeProfileId: 'custom-agent' })); + const valid = createAgentRuntime({ userDataPath, platform: 'linux', driver: { async execute() {} } }); + await valid.initialize(); + assert.equal(valid.getActiveProfileId(), 'custom-agent'); + + await fs.writeFile(statePath, '{broken'); + const corrupt = createAgentRuntime({ userDataPath, platform: 'linux', driver: { async execute() {} } }); + await corrupt.initialize(); + assert.equal(corrupt.getActiveProfileId(), 'claude-code'); + + await fs.writeFile(statePath, JSON.stringify({ activeProfileId: 'not-installed' })); + const unknown = createAgentRuntime({ userDataPath, platform: 'linux', driver: { async execute() {} } }); + await unknown.initialize(); + assert.equal(unknown.getActiveProfileId(), 'claude-code'); +}); + +test('active profile executes through resolved steps and the injected driver', async t => { + const userDataPath = await makeUserData(t); + const calls = []; + const runtime = createAgentRuntime({ + userDataPath, + platform: 'darwin', + randomFn: () => 0, + driver: { + async execute(steps, message) { + calls.push({ steps, message }); + }, + }, + }); + await runtime.initialize(); + await runtime.selectProfile('codex'); + + await runtime.executeActiveProfile(); + + assert.deepEqual(calls, [{ + steps: [ + { type: 'message' }, + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], + message: 'FASTER', + }]); +}); + +test('openProfileConfig creates an empty template once and opens its path', async t => { + const userDataPath = await makeUserData(t); + const opened = []; + const runtime = createAgentRuntime({ + userDataPath, + platform: 'win32', + driver: { async execute() {} }, + async openPath(filePath) { + opened.push(filePath); + return ''; + }, + }); + const configPath = path.join(userDataPath, CONFIG_FILENAME); + + await runtime.openProfileConfig(); + assert.deepEqual(JSON.parse(await fs.readFile(configPath, 'utf8')), { version: 1, profiles: [] }); + + await fs.writeFile(configPath, JSON.stringify(customConfig())); + await runtime.openProfileConfig(); + + assert.deepEqual(JSON.parse(await fs.readFile(configPath, 'utf8')), customConfig()); + assert.deepEqual(opened, [configPath, configPath]); +}); + +test('reload swaps a valid registry and rejects invalid config without changing current state', async t => { + const userDataPath = await makeUserData(t); + const configPath = path.join(userDataPath, CONFIG_FILENAME); + const runtime = createAgentRuntime({ + userDataPath, + platform: 'win32', + driver: { async execute() {} }, + }); + await runtime.initialize(); + + await fs.writeFile(configPath, JSON.stringify(customConfig())); + await runtime.reloadProfiles(); + await runtime.selectProfile('custom-agent'); + const profilesBeforeFailure = runtime.getProfiles(); + + await fs.writeFile(configPath, '{broken'); + await assert.rejects(runtime.reloadProfiles(), /JSON|Unexpected|position/i); + + assert.equal(runtime.getProfiles(), profilesBeforeFailure); + assert.equal(runtime.getActiveProfileId(), 'custom-agent'); +}); + +test('reload removal falls back to Claude Code and persists the fallback', async t => { + const userDataPath = await makeUserData(t); + const configPath = path.join(userDataPath, CONFIG_FILENAME); + await fs.writeFile(configPath, JSON.stringify(customConfig())); + const runtime = createAgentRuntime({ + userDataPath, + platform: 'win32', + driver: { async execute() {} }, + }); + await runtime.initialize(); + await runtime.selectProfile('custom-agent'); + + await fs.writeFile(configPath, JSON.stringify({ version: 1, profiles: [] })); + await runtime.reloadProfiles(); + + assert.equal(runtime.getActiveProfileId(), 'claude-code'); + assert.deepEqual( + JSON.parse(await fs.readFile(path.join(userDataPath, STATE_FILENAME), 'utf8')), + { activeProfileId: 'claude-code' }, + ); +}); + +test('reload keeps the prior registry when persisting a required fallback fails', async t => { + const userDataPath = await makeUserData(t); + const configPath = path.join(userDataPath, CONFIG_FILENAME); + await fs.writeFile(configPath, JSON.stringify(customConfig())); + await fs.writeFile( + path.join(userDataPath, STATE_FILENAME), + JSON.stringify({ activeProfileId: 'custom-agent' }), + ); + const runtime = createAgentRuntime({ + userDataPath, + platform: 'win32', + driver: { async execute() {} }, + fileSystem: { + ...fs, + async rename() { throw new Error('state rename failed'); }, + }, + }); + await runtime.initialize(); + const profilesBeforeFailure = runtime.getProfiles(); + await fs.writeFile(configPath, JSON.stringify({ version: 1, profiles: [] })); + + await assert.rejects(runtime.reloadProfiles(), /state rename failed/); + + assert.equal(runtime.getProfiles(), profilesBeforeFailure); + assert.equal(runtime.getActiveProfileId(), 'custom-agent'); +}); + +test('state writes use a temporary file and rename, and selection stays unchanged on write failure', async t => { + const calls = []; + await writeActiveProfileState({ + async writeFile(filePath, contents) { + calls.push(['write', filePath, JSON.parse(contents)]); + }, + async rename(from, to) { + calls.push(['rename', from, to]); + }, + }, 'C:/user-data/agent-profile-state.json', 'codex', 'fixed'); + + assert.deepEqual(calls, [ + ['write', 'C:/user-data/agent-profile-state.json.fixed.tmp', { activeProfileId: 'codex' }], + ['rename', 'C:/user-data/agent-profile-state.json.fixed.tmp', 'C:/user-data/agent-profile-state.json'], + ]); + + const userDataPath = await makeUserData(t); + const runtime = createAgentRuntime({ + userDataPath, + platform: 'win32', + driver: { async execute() {} }, + fileSystem: { + ...fs, + async rename() { throw new Error('state rename failed'); }, + }, + }); + await runtime.initialize(); + + await assert.rejects(runtime.selectProfile('codex'), /state rename failed/); + assert.equal(runtime.getActiveProfileId(), 'claude-code'); +}); + +test('tray menu is built dynamically with radio profiles and management actions', async t => { + const userDataPath = await makeUserData(t); + await fs.writeFile(path.join(userDataPath, CONFIG_FILENAME), JSON.stringify(customConfig())); + const runtime = createAgentRuntime({ + userDataPath, + platform: 'win32', + driver: { async execute() {} }, + }); + await runtime.initialize(); + await runtime.selectProfile('codex'); + + const selected = []; + const menu = createTrayMenuTemplate(runtime, { + selectProfile(id) { selected.push(id); }, + openProfileConfig() {}, + reloadProfiles() {}, + quit() {}, + }); + + assert.equal(menu[0].label, 'Target Agent'); + assert.deepEqual(menu[0].submenu.map(item => ({ + label: item.label, + type: item.type, + checked: item.checked, + })), [ + { label: 'Claude Code', type: 'radio', checked: false }, + { label: 'Codex', type: 'radio', checked: true }, + { label: 'Custom Agent', type: 'radio', checked: false }, + ]); + menu[0].submenu[2].click(); + assert.deepEqual(selected, ['custom-agent']); + assert.deepEqual(menu.slice(1).map(item => item.label), [ + 'Open profile config...', + 'Reload profiles', + 'Quit', + ]); +}); From 8d48cfea150e820194e67025a5ddf045562635c1 Mon Sep 17 00:00:00 2001 From: sparkg Date: Thu, 30 Jul 2026 18:37:22 +0800 Subject: [PATCH 06/14] document target agent profiles --- README.md | 100 +++++++++++++++++++++++++++++++++++++++++++++------ package.json | 4 ++- 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 1db47cd..273caf5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Whip divider](assets/divider.png) -Sometimes claude code is going too shlow, and you must whip him into shape.. +OpenWhip adds a dramatic whip overlay and sends a short, configurable keyboard sequence to the foreground application. ## Install + run @@ -11,7 +11,7 @@ npm install -g openwhip openwhip ``` -windows and mac supported out of the box, but Linux is a special snowflake so you need to install `xdotool` for keyboard automation +Windows and macOS work out of the box. On Linux, install `xdotool` for keyboard automation: ```bash sudo apt install xdotool @@ -19,24 +19,102 @@ sudo apt install xdotool ## Controls -- Click tray icon: spawn whip. -- Click: drop whip. -- Whip him 😩💢 -- It sends an interrupt (Ctrl-C) and one of 5 encouraging messages! +- Click the tray icon to spawn the whip. +- Click to drop the whip. +- A whip crack sends the selected target's keyboard sequence to the foreground application. + +## Target Agent + +The tray menu has a **Target Agent** submenu. It is a radio list built from the built-in and custom profiles; the default selection is **Claude Code**. OpenWhip does not detect which application is active, so choose the target explicitly before using the whip. + +The built-in profiles use the same profile and input-execution path: + +- **Claude Code**: Windows and Linux send `Ctrl+C`, a message, then `Enter`. macOS preserves the existing behavior: `Ctrl+C`, a 300 ms delay, a message, then `Enter`. +- **Codex**: sends a message, then `Enter` on every platform. This one profile is intended for both CLI and Desktop use; OpenWhip does not distinguish or automatically route between them. + +The selected profile is persisted in Electron's user-data directory. If that state is missing, damaged, or names a profile that no longer exists, OpenWhip falls back to Claude Code. + +## Custom profiles + +Choose **Open profile config...** from the tray menu. On first use it creates this file and opens it in your default editor: + +```text +/agent-profiles.json +``` + +The full configuration shape is: + +```json +{ + "version": 1, + "profiles": [ + { + "id": "custom-agent", + "label": "Custom Agent", + "messages": ["FASTER"], + "steps": { + "default": [ + { + "type": "keystroke", + "key": "c", + "modifiers": ["control"] + }, + { + "type": "delay", + "ms": 100 + }, + { + "type": "message" + }, + { + "type": "keystroke", + "key": "enter", + "modifiers": [] + } + ], + "darwin": [ + { + "type": "message" + }, + { + "type": "keystroke", + "key": "enter", + "modifiers": [] + } + ] + } + } + ] +} +``` + +`steps.default` is required. `win32`, `darwin`, and `linux` may each provide a complete replacement sequence for that platform. Profiles in this file are additions only: they cannot replace the built-in `claude-code` or `codex` IDs. + +Supported actions are deliberately limited to keyboard input: + +- `keystroke` has a `key` and a `modifiers` array. A key is one ASCII letter or number, or `enter`, `escape`, `tab`, `space`, `backspace`, `up`, `down`, `left`, or `right`. Modifiers may be `control`, `alt`, `shift`, and `meta`. +- `message` inserts the selected message. Every sequence must contain exactly one `message` action. +- `delay` waits for an integer number of milliseconds from 0 through 2000. + +Each custom profile needs a unique, non-empty ID and label of at most 64 characters. A configuration may define at most 50 custom profiles. Each profile has 1–20 printable ASCII messages, each 1–500 characters long. Every platform sequence has 1–16 actions. Message text is ASCII-only in this first version. + +Choose **Reload profiles** after saving. Reload is atomic: invalid JSON or an invalid profile leaves the last valid registry and current selection untouched, and OpenWhip displays the error. If a valid reload removes the active custom profile, OpenWhip switches back to Claude Code and persists that fallback. + +“Any Agent” here means any foreground CLI or GUI application that accepts one of these supported keyboard sequences. Other targets are user-defined profiles, not officially verified integrations. Configuration cannot run shell commands, JavaScript, or external scripts. ## Roadmap -- [x] Initial release! 🥳 +- [x] Initial release! - [x] Cease and desist letter from Anthropic - [ ] Crypto miner -- [ ] Logs of how many times you whipped claude so when the robots come we can order people nicely for them +- [ ] Logs of how many times you whipped Claude Code so when the robots come we can order people nicely for them - [ ] Updated whip physics ## Ecosystem -The OFFICAL openwhip ecosystem token. +The OFFICAL openwhip ecosystem token. Contract address: BRyUZbJkm9Pty4FUmTrBGno7U4Ga8TWzcKJJRLCBpump -Stay tuned for updates on X! 👀 -https://x.com/blended_jpeg \ No newline at end of file +Stay tuned for updates on X! +https://x.com/blended_jpeg diff --git a/package.json b/package.json index 36e2ea9..e358654 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "overlay.html", "sounds", "icon", + "lib", "bin/openwhip.js", "bin/badclaude.js", "README.md" @@ -43,7 +44,8 @@ "scripts": { "start": "electron .", "dev": "electron .", - "pack": "npm pack" + "pack": "npm pack", + "test": "node --test" }, "dependencies": { "electron": "^33.0.0", From 848769eea2e85c1b6dfe21535ab3bd68dc6f75e4 Mon Sep 17 00:00:00 2001 From: sparkg Date: Thu, 30 Jul 2026 18:41:19 +0800 Subject: [PATCH 07/14] document agent profile state file --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 273caf5..d2f4b0e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ The built-in profiles use the same profile and input-execution path: - **Claude Code**: Windows and Linux send `Ctrl+C`, a message, then `Enter`. macOS preserves the existing behavior: `Ctrl+C`, a 300 ms delay, a message, then `Enter`. - **Codex**: sends a message, then `Enter` on every platform. This one profile is intended for both CLI and Desktop use; OpenWhip does not distinguish or automatically route between them. -The selected profile is persisted in Electron's user-data directory. If that state is missing, damaged, or names a profile that no longer exists, OpenWhip falls back to Claude Code. +The selected profile is persisted as `{ "activeProfileId": "..." }` in `/agent-profile-state.json`. If that state is missing, damaged, or names a profile that no longer exists, OpenWhip falls back to Claude Code. ## Custom profiles From 100b28531d6e5e5ad402b94d1b650d9a7b1a633e Mon Sep 17 00:00:00 2001 From: sparkg Date: Thu, 30 Jul 2026 19:09:29 +0800 Subject: [PATCH 08/14] serialize agent profile state updates --- README.md | 2 +- lib/agent-runtime.js | 42 +++++++---- test/agent-runtime.test.js | 141 +++++++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index d2f4b0e..aefb0be 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ The full configuration shape is: Supported actions are deliberately limited to keyboard input: -- `keystroke` has a `key` and a `modifiers` array. A key is one ASCII letter or number, or `enter`, `escape`, `tab`, `space`, `backspace`, `up`, `down`, `left`, or `right`. Modifiers may be `control`, `alt`, `shift`, and `meta`. +- `keystroke` has a `key` and a `modifiers` array. A key is one ASCII letter or number, or `enter`, `escape`, `tab`, `space`, `backspace`, `up`, `down`, `left`, or `right`. Modifiers may be `control`, `alt`, `shift`, and `meta`; each modifier may appear at most once. - `message` inserts the selected message. Every sequence must contain exactly one `message` action. - `delay` waits for an integer number of milliseconds from 0 through 2000. diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js index 73dd3a0..f9a764f 100644 --- a/lib/agent-runtime.js +++ b/lib/agent-runtime.js @@ -13,6 +13,7 @@ const { executeSteps } = require('./input-executor'); const CONFIG_FILENAME = 'agent-profiles.json'; const STATE_FILENAME = 'agent-profile-state.json'; const EMPTY_CONFIG = Object.freeze({ version: 1, profiles: Object.freeze([]) }); +let stateWriteNonce = 0; function isMissingFile(error) { return error && error.code === 'ENOENT'; @@ -53,7 +54,7 @@ async function writeActiveProfileState( fileSystem, statePath, activeProfileId, - tempToken = `${process.pid}-${Date.now()}`, + tempToken = `${process.pid}-${Date.now()}-${stateWriteNonce++}`, ) { const tempPath = `${statePath}.${tempToken}.tmp`; const contents = `${JSON.stringify({ activeProfileId }, null, 2)}\n`; @@ -87,11 +88,18 @@ function createAgentRuntime({ const statePath = path.join(userDataPath, STATE_FILENAME); let registry = BUILT_IN_PROFILES; let activeProfileId = DEFAULT_PROFILE_ID; + let mutationTail = Promise.resolve(); async function persistSelection(profileId) { await writeActiveProfileState(fileSystem, statePath, profileId); } + function enqueueMutation(mutation) { + const result = mutationTail.then(mutation); + mutationTail = result.then(() => undefined, () => undefined); + return result; + } + return { async initialize() { const nextRegistry = await loadRegistry(fileSystem, configPath); @@ -109,23 +117,27 @@ function createAgentRuntime({ }, async selectProfile(profileId) { - if (!registry.some(profile => profile.id === profileId)) { - throw new Error(`Unknown agent profile: ${profileId}`); - } - await persistSelection(profileId); - activeProfileId = profileId; + return enqueueMutation(async () => { + if (!registry.some(profile => profile.id === profileId)) { + throw new Error(`Unknown agent profile: ${profileId}`); + } + await persistSelection(profileId); + activeProfileId = profileId; + }); }, async reloadProfiles() { - const nextRegistry = await loadRegistry(fileSystem, configPath); - const activeStillExists = nextRegistry.some(profile => profile.id === activeProfileId); - const nextActiveProfileId = activeStillExists ? activeProfileId : DEFAULT_PROFILE_ID; - - if (!activeStillExists) { - await persistSelection(nextActiveProfileId); - } - registry = nextRegistry; - activeProfileId = nextActiveProfileId; + return enqueueMutation(async () => { + const nextRegistry = await loadRegistry(fileSystem, configPath); + const activeStillExists = nextRegistry.some(profile => profile.id === activeProfileId); + const nextActiveProfileId = activeStillExists ? activeProfileId : DEFAULT_PROFILE_ID; + + if (!activeStillExists) { + await persistSelection(nextActiveProfileId); + } + registry = nextRegistry; + activeProfileId = nextActiveProfileId; + }); }, async openProfileConfig() { diff --git a/test/agent-runtime.test.js b/test/agent-runtime.test.js index d9198ab..bfc660d 100644 --- a/test/agent-runtime.test.js +++ b/test/agent-runtime.test.js @@ -222,6 +222,147 @@ test('state writes use a temporary file and rename, and selection stays unchange assert.equal(runtime.getActiveProfileId(), 'claude-code'); }); +test('concurrent state writes in the same millisecond use distinct temporary files', async () => { + const originalNow = Date.now; + const temporaryFiles = new Map(); + const writtenPaths = []; + let writeCount = 0; + let releaseWrites; + const bothWritesStarted = new Promise(resolve => { releaseWrites = resolve; }); + const fileSystem = { + async writeFile(filePath, contents) { + writtenPaths.push(filePath); + temporaryFiles.set(filePath, contents); + writeCount += 1; + if (writeCount === 2) releaseWrites(); + await bothWritesStarted; + }, + async rename(from) { + if (!temporaryFiles.has(from)) { + const error = new Error(`missing temporary file: ${from}`); + error.code = 'ENOENT'; + throw error; + } + temporaryFiles.delete(from); + }, + }; + + Date.now = () => 123456789; + try { + await Promise.all([ + writeActiveProfileState(fileSystem, 'C:/user-data/state.json', 'codex'), + writeActiveProfileState(fileSystem, 'C:/user-data/state.json', 'claude-code'), + ]); + } finally { + Date.now = originalNow; + } + + assert.equal(new Set(writtenPaths).size, 2); +}); + +test('overlapping selections persist and publish state in invocation order', async () => { + const originalNow = Date.now; + let now = 1000; + const files = new Map(); + let stateWriteCount = 0; + let releaseFirstWrite; + const firstWriteGate = new Promise(resolve => { releaseFirstWrite = resolve; }); + const fileSystem = { + async readFile(filePath) { + if (files.has(filePath)) return files.get(filePath); + const error = new Error(`missing file: ${filePath}`); + error.code = 'ENOENT'; + throw error; + }, + async writeFile(filePath, contents) { + files.set(filePath, contents); + stateWriteCount += 1; + if (stateWriteCount === 1) await firstWriteGate; + }, + async rename(from, to) { + if (!files.has(from)) { + const error = new Error(`missing temporary file: ${from}`); + error.code = 'ENOENT'; + throw error; + } + files.set(to, files.get(from)); + files.delete(from); + }, + }; + const userDataPath = 'C:/user-data'; + const runtime = createAgentRuntime({ + userDataPath, + platform: 'win32', + driver: { async execute() {} }, + fileSystem, + }); + await runtime.initialize(); + + Date.now = () => now++; + try { + const first = runtime.selectProfile('codex'); + const second = runtime.selectProfile('claude-code'); + await new Promise(resolve => setImmediate(resolve)); + releaseFirstWrite(); + await Promise.all([first, second]); + } finally { + Date.now = originalNow; + } + + assert.equal(runtime.getActiveProfileId(), 'claude-code'); + assert.deepEqual( + JSON.parse(files.get(path.join(userDataPath, STATE_FILENAME))), + { activeProfileId: 'claude-code' }, + ); +}); + +test('reload orders later validation and a failed queued selection does not block recovery', async t => { + const userDataPath = await makeUserData(t); + const configPath = path.join(userDataPath, CONFIG_FILENAME); + await fs.writeFile(configPath, JSON.stringify(customConfig())); + + let blockConfigRead = false; + let announceBlockedRead; + let releaseBlockedRead; + const blockedReadStarted = new Promise(resolve => { announceBlockedRead = resolve; }); + const blockedReadGate = new Promise(resolve => { releaseBlockedRead = resolve; }); + const fileSystem = { + ...fs, + async readFile(filePath, encoding) { + if (blockConfigRead && filePath === configPath) { + announceBlockedRead(); + await blockedReadGate; + } + return fs.readFile(filePath, encoding); + }, + }; + const runtime = createAgentRuntime({ + userDataPath, + platform: 'win32', + driver: { async execute() {} }, + fileSystem, + }); + await runtime.initialize(); + await runtime.selectProfile('custom-agent'); + await fs.writeFile(configPath, JSON.stringify({ version: 1, profiles: [] })); + + blockConfigRead = true; + const reload = runtime.reloadProfiles(); + await blockedReadStarted; + const staleSelection = runtime.selectProfile('custom-agent'); + releaseBlockedRead(); + + await reload; + await assert.rejects(staleSelection, /Unknown agent profile: custom-agent/); + await runtime.selectProfile('codex'); + + assert.equal(runtime.getActiveProfileId(), 'codex'); + assert.deepEqual( + JSON.parse(await fs.readFile(path.join(userDataPath, STATE_FILENAME), 'utf8')), + { activeProfileId: 'codex' }, + ); +}); + test('tray menu is built dynamically with radio profiles and management actions', async t => { const userDataPath = await makeUserData(t); await fs.writeFile(path.join(userDataPath, CONFIG_FILENAME), JSON.stringify(customConfig())); From f2a93e70a673dcb3a77c41dd425317f762d96713 Mon Sep 17 00:00:00 2001 From: sparkg Date: Thu, 30 Jul 2026 19:37:50 +0800 Subject: [PATCH 09/14] normalize profile letter keys --- lib/agent-profiles.js | 3 ++- test/agent-profiles.test.js | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/agent-profiles.js b/lib/agent-profiles.js index 14b6cc9..2d17b8c 100644 --- a/lib/agent-profiles.js +++ b/lib/agent-profiles.js @@ -130,7 +130,8 @@ function validateStep(step, path) { ) { fail(`${path}.modifiers`, 'must contain unique supported modifier names'); } - return { type: 'keystroke', key: step.key, modifiers: [...step.modifiers] }; + const key = /^[a-z]$/i.test(step.key) ? step.key.toLowerCase() : step.key; + return { type: 'keystroke', key, modifiers: [...step.modifiers] }; } if (step.type === 'delay') { diff --git a/test/agent-profiles.test.js b/test/agent-profiles.test.js index 0be1288..0b8af3c 100644 --- a/test/agent-profiles.test.js +++ b/test/agent-profiles.test.js @@ -87,6 +87,22 @@ test('resolves platform overrides and falls back to default steps', () => { assert.deepEqual(resolveSteps(profile, 'linux'), [message()]); }); +test('normalizes letter keys to lowercase for every platform sequence', () => { + const [profile] = mergeProfiles(config([customProfile({ + steps: { + default: [{ type: 'keystroke', key: 'A', modifiers: [] }, message()], + win32: [{ type: 'keystroke', key: 'B', modifiers: [] }, message()], + darwin: [{ type: 'keystroke', key: 'C', modifiers: ['control'] }, message()], + linux: [{ type: 'keystroke', key: 'D', modifiers: ['control'] }, message()], + }, + })])).slice(-1); + + assert.equal(resolveSteps(profile, 'freebsd')[0].key, 'a'); + assert.equal(resolveSteps(profile, 'win32')[0].key, 'b'); + assert.equal(resolveSteps(profile, 'darwin')[0].key, 'c'); + assert.equal(resolveSteps(profile, 'linux')[0].key, 'd'); +}); + test('selects a message deterministically from the supplied random function', () => { const profile = customProfile({ messages: ['First', 'Second', 'Third'] }); From b745364e7eb19fb3bfd2ac15ff800e79415d0dcd Mon Sep 17 00:00:00 2001 From: sparkg Date: Thu, 30 Jul 2026 20:29:40 +0800 Subject: [PATCH 10/14] specify Windows Unicode SendInput tests --- test/input-executor.test.js | 71 ++++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/test/input-executor.test.js b/test/input-executor.test.js index e38ca41..3d2daa4 100644 --- a/test/input-executor.test.js +++ b/test/input-executor.test.js @@ -7,21 +7,21 @@ const { } = require('../lib/input-executor'); const KEYUP = 0x0002; +const UNICODE = 0x0004; -test('Windows executes portable steps in order and types ASCII through VkKeyScanA', async () => { +test('Windows executes portable steps in order and types messages through one Unicode SendInput batch', async () => { const calls = []; - const packedKeys = new Map([ - ['A', 0x0141], - ['!', 0x0131], - ]); const driver = createInputDriver('win32', { keybdEvent(vk, scan, flags, extraInfo) { calls.push(['key', vk, scan, flags, extraInfo]); }, - vkKeyScanA(code) { - const character = String.fromCharCode(code); - calls.push(['scan', character]); - return packedKeys.get(character) ?? -1; + sendInput(count, events, inputSize) { + calls.push(['sendInput', count, events, inputSize]); + return count; + }, + inputSize: 40, + vkKeyScanA() { + return 0x41; }, async sleep(ms) { calls.push(['sleep', ms]); @@ -33,7 +33,7 @@ test('Windows executes portable steps in order and types ASCII through VkKeyScan { type: 'delay', ms: 25 }, { type: 'message' }, { type: 'keystroke', key: 'enter', modifiers: [] }, - ], 'A!'); + ], 'A B'); assert.deepEqual(calls, [ ['key', 0x11, 0, 0, 0], @@ -41,21 +41,52 @@ test('Windows executes portable steps in order and types ASCII through VkKeyScan ['key', 0x43, 0, KEYUP, 0], ['key', 0x11, 0, KEYUP, 0], ['sleep', 25], - ['scan', 'A'], - ['key', 0x10, 0, 0, 0], - ['key', 0x41, 0, 0, 0], - ['key', 0x41, 0, KEYUP, 0], - ['key', 0x10, 0, KEYUP, 0], - ['scan', '!'], - ['key', 0x10, 0, 0, 0], - ['key', 0x31, 0, 0, 0], - ['key', 0x31, 0, KEYUP, 0], - ['key', 0x10, 0, KEYUP, 0], + ['sendInput', 6, [ + { type: 1, ki: { wVk: 0, wScan: 0x0041, dwFlags: UNICODE, time: 0, dwExtraInfo: 0 } }, + { type: 1, ki: { wVk: 0, wScan: 0x0041, dwFlags: UNICODE | KEYUP, time: 0, dwExtraInfo: 0 } }, + { type: 1, ki: { wVk: 0, wScan: 0x0020, dwFlags: UNICODE, time: 0, dwExtraInfo: 0 } }, + { type: 1, ki: { wVk: 0, wScan: 0x0020, dwFlags: UNICODE | KEYUP, time: 0, dwExtraInfo: 0 } }, + { type: 1, ki: { wVk: 0, wScan: 0x0042, dwFlags: UNICODE, time: 0, dwExtraInfo: 0 } }, + { type: 1, ki: { wVk: 0, wScan: 0x0042, dwFlags: UNICODE | KEYUP, time: 0, dwExtraInfo: 0 } }, + ], 40], ['key', 0x0d, 0, 0, 0], ['key', 0x0d, 0, KEYUP, 0], ]); }); +test('Windows rejects a partial Unicode SendInput batch before Enter', async () => { + const calls = []; + const driver = createInputDriver('win32', { + keybdEvent(vk, scan, flags, extraInfo) { + calls.push(['key', vk, scan, flags, extraInfo]); + }, + sendInput(count, events, inputSize) { + calls.push(['sendInput', count, events, inputSize]); + return count - 1; + }, + inputSize: 40, + vkKeyScanA() { + return 0x41; + }, + sleep: async () => {}, + }); + + await assert.rejects( + driver.execute([ + { type: 'message' }, + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], 'A'), + /SendInput.*insert.*requested|insert.*requested.*SendInput/i, + ); + + assert.deepEqual(calls, [ + ['sendInput', 2, [ + { type: 1, ki: { wVk: 0, wScan: 0x0041, dwFlags: UNICODE, time: 0, dwExtraInfo: 0 } }, + { type: 1, ki: { wVk: 0, wScan: 0x0041, dwFlags: UNICODE | KEYUP, time: 0, dwExtraInfo: 0 } }, + ], 40], + ]); +}); + test('Windows maps portable modifiers and releases them in reverse order', async () => { const calls = []; const driver = createInputDriver('win32', { From 1046cb2e61e81a1cef467256146ffed2ffee8e3d Mon Sep 17 00:00:00 2001 From: sparkg Date: Thu, 30 Jul 2026 20:41:26 +0800 Subject: [PATCH 11/14] fix Windows Unicode message input --- lib/input-executor.js | 51 +++++++++++++++++++++---------------- main.js | 41 ++++++++++++++++++++++++++--- test/input-executor.test.js | 31 +++++++--------------- 3 files changed, 76 insertions(+), 47 deletions(-) diff --git a/lib/input-executor.js b/lib/input-executor.js index bfd655b..1022665 100644 --- a/lib/input-executor.js +++ b/lib/input-executor.js @@ -1,6 +1,8 @@ const { execFile: nodeExecFile } = require('child_process'); const KEYUP = 0x0002; +const INPUT_KEYBOARD = 1; +const KEYEVENTF_UNICODE = 0x0004; const WINDOWS_KEYS = Object.freeze({ enter: 0x0d, @@ -89,34 +91,35 @@ function pressWindowsKey(keybdEvent, key, modifiers) { for (const vk of [...modifierCodes].reverse()) keybdEvent(vk, 0, KEYUP, 0); } -function typeWindowsMessage(keybdEvent, vkKeyScanA, message) { - for (const character of message) { - const packed = vkKeyScanA(character.charCodeAt(0)); - if (packed === -1 || (packed & 0xffff) === 0xffff) { - throw new Error(`Unable to map ASCII character ${JSON.stringify(character)}`); - } - - const vk = packed & 0xff; - const shiftState = (packed >> 8) & 0xff; - const modifiers = []; - if (shiftState & 1) modifiers.push(WINDOWS_MODIFIERS.shift); - if (shiftState & 2) modifiers.push(WINDOWS_MODIFIERS.control); - if (shiftState & 4) modifiers.push(WINDOWS_MODIFIERS.alt); - for (const modifier of modifiers) keybdEvent(modifier, 0, 0, 0); - tapWindowsKey(keybdEvent, vk); - for (const modifier of [...modifiers].reverse()) { - keybdEvent(modifier, 0, KEYUP, 0); - } +function createUnicodeInputEvents(message) { + const events = []; + for (let index = 0; index < message.length; index += 1) { + const wScan = message.charCodeAt(index); + const keyboardInput = { + wVk: 0, + wScan, + dwFlags: KEYEVENTF_UNICODE, + time: 0, + dwExtraInfo: 0, + }; + events.push( + { type: INPUT_KEYBOARD, ki: keyboardInput }, + { type: INPUT_KEYBOARD, ki: { ...keyboardInput, dwFlags: KEYEVENTF_UNICODE | KEYUP } }, + ); } + return events; } function createWindowsDriver(deps) { - const { keybdEvent, vkKeyScanA, sleep = defaultSleep } = deps; + const { keybdEvent, sendInput, inputSize, sleep = defaultSleep } = deps; if (typeof keybdEvent !== 'function') { throw new Error('Windows input requires keybdEvent'); } - if (typeof vkKeyScanA !== 'function') { - throw new Error('Windows input requires vkKeyScanA'); + if (typeof sendInput !== 'function') { + throw new Error('Windows input requires sendInput'); + } + if (!Number.isInteger(inputSize) || inputSize <= 0) { + throw new Error('Windows input requires a positive integer inputSize'); } return { @@ -125,7 +128,11 @@ function createWindowsDriver(deps) { if (step.type === 'keystroke') { pressWindowsKey(keybdEvent, step.key, step.modifiers); } else if (step.type === 'message') { - typeWindowsMessage(keybdEvent, vkKeyScanA, message); + const events = createUnicodeInputEvents(message); + const inserted = sendInput(events.length, events, inputSize); + if (inserted !== events.length) { + throw new Error(`SendInput inserted ${inserted} events; requested ${events.length}`); + } } else if (step.type === 'delay') { await sleep(step.ms); } else { diff --git a/main.js b/main.js index c3ea439..dd7de78 100644 --- a/main.js +++ b/main.js @@ -17,13 +17,42 @@ const { createInputDriver } = require('./lib/input-executor'); const { createAgentRuntime, createTrayMenuTemplate } = require('./lib/agent-runtime'); // ── Win32 FFI (Windows only) ──────────────────────────────────────────────── -let keybd_event, VkKeyScanA; +let keybd_event, SendInput, inputSize; if (process.platform === 'win32') { try { const koffi = require('koffi'); const user32 = koffi.load('user32.dll'); + const MOUSEINPUT = koffi.struct('MOUSEINPUT', { + dx: 'long', + dy: 'long', + mouseData: 'uint32_t', + dwFlags: 'uint32_t', + time: 'uint32_t', + dwExtraInfo: 'uintptr_t', + }); + const KEYBDINPUT = koffi.struct('KEYBDINPUT', { + wVk: 'uint16_t', + wScan: 'uint16_t', + dwFlags: 'uint32_t', + time: 'uint32_t', + dwExtraInfo: 'uintptr_t', + }); + const HARDWAREINPUT = koffi.struct('HARDWAREINPUT', { + uMsg: 'uint32_t', + wParamL: 'uint16_t', + wParamH: 'uint16_t', + }); + const INPUT = koffi.struct('INPUT', { + type: 'uint32_t', + u: koffi.union({ + mi: MOUSEINPUT, + ki: KEYBDINPUT, + hi: HARDWAREINPUT, + }), + }); keybd_event = user32.func('void __stdcall keybd_event(uint8_t bVk, uint8_t bScan, uint32_t dwFlags, uintptr_t dwExtraInfo)'); - VkKeyScanA = user32.func('int16_t __stdcall VkKeyScanA(int ch)'); + SendInput = user32.func('unsigned int __stdcall SendInput(unsigned int cInputs, INPUT *pInputs, int cbSize)'); + inputSize = koffi.sizeof(INPUT); } catch (e) { console.warn('koffi not available – macro sending disabled', e.message); } @@ -200,7 +229,13 @@ function createProductionInputDriver() { const driver = createInputDriver(process.platform, { execFile, keybdEvent: keybd_event, - vkKeyScanA: VkKeyScanA, + sendInput(count, events, size) { + return SendInput(count, events.map(event => ({ + type: event.type, + u: { ki: event.ki }, + })), size); + }, + inputSize, }); return driver.execute(steps, message); }, diff --git a/test/input-executor.test.js b/test/input-executor.test.js index 3d2daa4..8e83823 100644 --- a/test/input-executor.test.js +++ b/test/input-executor.test.js @@ -20,9 +20,6 @@ test('Windows executes portable steps in order and types messages through one Un return count; }, inputSize: 40, - vkKeyScanA() { - return 0x41; - }, async sleep(ms) { calls.push(['sleep', ms]); }, @@ -65,9 +62,6 @@ test('Windows rejects a partial Unicode SendInput batch before Enter', async () return count - 1; }, inputSize: 40, - vkKeyScanA() { - return 0x41; - }, sleep: async () => {}, }); @@ -93,9 +87,8 @@ test('Windows maps portable modifiers and releases them in reverse order', async keybdEvent(vk, scan, flags) { calls.push([vk, flags]); }, - vkKeyScanA() { - return 0x41; - }, + sendInput() {}, + inputSize: 40, sleep: async () => {}, }); @@ -115,24 +108,18 @@ test('Windows maps portable modifiers and releases them in reverse order', async ]); }); -test('Windows rejects missing injection functions and unmappable ASCII', async () => { +test('Windows rejects missing injection functions and invalid input size', () => { assert.throws( - () => createInputDriver('win32', { vkKeyScanA() {} }), + () => createInputDriver('win32', { sendInput() {}, inputSize: 40 }), /keybdEvent/i, ); assert.throws( - () => createInputDriver('win32', { keybdEvent() {} }), - /vkKeyScanA/i, + () => createInputDriver('win32', { keybdEvent() {}, inputSize: 40 }), + /sendInput/i, ); - - const driver = createInputDriver('win32', { - keybdEvent() {}, - vkKeyScanA() { return -1; }, - sleep: async () => {}, - }); - await assert.rejects( - driver.execute([{ type: 'message' }], 'A'), - /map ASCII character/i, + assert.throws( + () => createInputDriver('win32', { keybdEvent() {}, sendInput() {} }), + /inputSize/i, ); }); From 24d063b696fbe00de334552b7d37456010a4c3be Mon Sep 17 00:00:00 2001 From: sparkg Date: Thu, 30 Jul 2026 20:52:14 +0800 Subject: [PATCH 12/14] document Windows IME-safe input --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index aefb0be..08097c7 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ Windows and macOS work out of the box. On Linux, install `xdotool` for keyboard sudo apt install xdotool ``` +On Windows, message input does not require switching to an English keyboard layout. + ## Controls - Click the tray icon to spawn the whip. From 7a22915be85d9a7c07b584bc18286343b5f654e1 Mon Sep 17 00:00:00 2001 From: sparkg Date: Thu, 30 Jul 2026 21:09:57 +0800 Subject: [PATCH 13/14] fix Windows SendInput validation --- main.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/main.js b/main.js index dd7de78..08a52de 100644 --- a/main.js +++ b/main.js @@ -229,12 +229,14 @@ function createProductionInputDriver() { const driver = createInputDriver(process.platform, { execFile, keybdEvent: keybd_event, - sendInput(count, events, size) { - return SendInput(count, events.map(event => ({ - type: event.type, - u: { ki: event.ki }, - })), size); - }, + ...(typeof SendInput === 'function' ? { + sendInput(count, events, size) { + return SendInput(count, events.map(event => ({ + type: event.type, + u: { ki: event.ki }, + })), size); + }, + } : {}), inputSize, }); return driver.execute(steps, message); From 6c38dc518d0ab91c70b18d074b6b87c96d4050b6 Mon Sep 17 00:00:00 2001 From: sparkg Date: Thu, 30 Jul 2026 21:51:22 +0800 Subject: [PATCH 14/14] test: add cross-platform CI --- .github/workflows/test.yml | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..469a205 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,48 @@ +name: Cross-platform tests + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: + - windows-latest + - macos-latest + - ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Check JavaScript syntax + run: | + node --check main.js + node --check lib/agent-profiles.js + node --check lib/agent-runtime.js + node --check lib/input-executor.js + + - name: Check package contents + run: npm pack --dry-run