claude-yes 1.24.1 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,8 @@ import DIE from 'phpdie';
5
5
  import sflow from 'sflow';
6
6
  import { TerminalTextRender } from 'terminal-render';
7
7
  import tsaComposer from 'tsa-composer';
8
+ import rawConfig from '../cli-yes.config.js';
9
+ import { defineCliYesConfig } from './defineConfig.js';
8
10
  import { IdleWaiter } from './idleWaiter';
9
11
  import { ReadyManager } from './ReadyManager';
10
12
  import { removeControlCharacters } from './removeControlCharacters';
@@ -14,74 +16,36 @@ import {
14
16
  shouldUseLock,
15
17
  updateCurrentTaskStatus,
16
18
  } from './runningLock';
17
-
18
- // const yesLog = tsaComposer()(async function yesLog(msg: string) {
19
- // // await rm('agent-yes.log').catch(() => null); // ignore error if file doesn't exist
20
- // await appendFile("agent-yes.log", `${msg}\n`).catch(() => null);
21
- // });
22
-
23
- export const CLI_CONFIGURES: Record<
24
- string,
25
- {
26
- install?: string; // hint user for install command if not installed
27
- binary?: string; // actual binary name if different from cli
28
- ready?: RegExp[]; // regex matcher for stdin ready, or line index for gemini
29
- enter?: RegExp[]; // array of regex to match for sending Enter
30
- fatal?: RegExp[]; // array of regex to match for fatal errors
31
- ensureArgs?: (args: string[]) => string[]; // function to ensure certain args are present
32
- }
33
- > = {
34
- grok: {
35
- install: 'npm install -g @vibe-kit/grok-cli',
36
- ready: [/^ │ ❯ /],
37
- enter: [/^ 1. Yes/],
38
- },
39
- claude: {
40
- install: 'npm install -g @anthropic-ai/claude-code',
41
- // ready: [/^> /], // regex matcher for stdin ready
42
- ready: [/\? for shortcuts/], // regex matcher for stdin ready
43
- enter: [/❯ 1. Yes/, /❯ 1. Dark mode✔/, /Press Enter to continue…/],
44
- fatal: [
45
- /No conversation found to continue/,
46
- /⎿ Claude usage limit reached\./,
47
- ],
48
- },
49
- gemini: {
50
- install: 'npm install -g @google/gemini-cli',
51
- // match the agent prompt after initial lines; handled by index logic using line index
52
- ready: [/Type your message/], // used with line index check
53
- enter: [/│ ● 1. Yes, allow once/],
54
- fatal: [],
55
- },
56
- codex: {
57
- install: 'npm install -g @openai/codex-cli',
58
- ready: [/⏎ send/],
59
- enter: [
60
- /> 1. Yes, allow Codex to work in this folder/,
61
- /> 1. Approve and run now/,
62
- ],
63
- fatal: [/Error: The cursor position could not be read within/],
64
- // add to codex --search by default when not provided by the user
65
- ensureArgs: (args: string[]) => {
66
- if (!args.includes('--search')) return ['--search', ...args];
67
- return args;
68
- },
69
- },
70
- copilot: {
71
- install: 'npm install -g @github/copilot',
72
- ready: [/^ +> /, /Ctrl\+c Exit/],
73
- enter: [/ │ ❯ 1. Yes, proceed/, /❯ 1. Yes/],
74
- fatal: [],
75
- },
76
- cursor: {
77
- install: 'open https://cursor.com/ja/docs/cli/installation',
78
- // map logical "cursor" cli name to actual binary name
79
- binary: 'cursor-agent',
80
- ready: [/\/ commands/],
81
- enter: [/→ Run \(once\) \(y\) \(enter\)/, /▶ \[a\] Trust this workspace/],
82
- fatal: [/^ Error: You've hit your usage limit/],
83
- },
19
+ import { catcher } from './tryCatch';
20
+ import { deepMixin } from './utils';
21
+ import { yesLog } from './yesLog';
22
+
23
+ export type AgentCliConfig = {
24
+ install?: string; // hint user for install command if not installed
25
+ version?: string; // hint user for version command to check if installed
26
+ binary?: string; // actual binary name if different from cli, e.g. cursor -> cursor-agent
27
+ ready?: RegExp[]; // regex matcher for stdin ready, or line index for gemini
28
+ enter?: RegExp[]; // array of regex to match for sending Enter
29
+ fatal?: RegExp[]; // array of regex to match for fatal errors
30
+ restoreArgs?: string[]; // arguments to continue the session when crashed
31
+ defaultArgs?: string[]; // function to ensure certain args are present
32
+ noEOL?: boolean; // if true, do not split lines by \n, used for codex, which uses cursor-move csi code instead of \n to move lines
33
+ promptArg?: (string & {}) | 'first-arg' | 'last-arg'; // argument name to pass the prompt, e.g. --prompt, or first-arg for positional arg
84
34
  };
35
+ export type CliYesConfig = {
36
+ clis: { [key: string]: AgentCliConfig };
37
+ };
38
+
39
+ // load user config from cli-yes.config.ts if exists
40
+ export const config = await rawConfig;
41
+
42
+ export const CLIS_CONFIG = config.clis as Record<
43
+ keyof Awaited<typeof config>['clis'],
44
+ AgentCliConfig
45
+ >;
46
+ export type SUPPORTED_CLIS = keyof typeof CLIS_CONFIG;
47
+ export const SUPPORTED_CLIS = Object.keys(CLIS_CONFIG) as SUPPORTED_CLIS[];
48
+
85
49
  /**
86
50
  * Main function to run agent-cli with automatic yes/no responses
87
51
  * @param options Configuration options
@@ -97,77 +61,86 @@ export const CLI_CONFIGURES: Record<
97
61
  *
98
62
  * @example
99
63
  * ```typescript
100
- * import claudeYes from 'claude-yes';
101
- * await claudeYes({
64
+ * import cliYes from 'cli-yes';
65
+ * await cliYes({
102
66
  * prompt: 'help me solve all todos in my codebase',
103
67
  *
104
68
  * // optional
105
- * cli: 'claude',
106
- * cliArgs: ['--verbose'], // additional args to pass to claude
69
+ * cliArgs: ['--verbose'], // additional args to pass to agent-cli
107
70
  * exitOnIdle: 30000, // exit after 30 seconds of idle
108
- * continueOnCrash: true, // restart if claude crashes, default is true
71
+ * robust: true, // auto restart with --continue if claude crashes, default is true
109
72
  * logFile: 'claude.log', // save logs to file
110
73
  * disableLock: false, // disable running lock (default is false)
111
74
  * });
112
75
  * ```
113
76
  */
114
- export default async function claudeYes({
115
- cli = 'claude',
77
+ export default async function cliYes({
78
+ cli,
116
79
  cliArgs = [],
117
80
  prompt,
118
- continueOnCrash,
81
+ robust = true,
119
82
  cwd,
120
83
  env,
121
84
  exitOnIdle,
122
85
  logFile,
123
86
  removeControlCharactersFromStdout = false, // = !process.stdout.isTTY,
124
87
  verbose = false,
125
- disableLock = false,
88
+ queue = true,
126
89
  }: {
127
- cli?: (string & {}) | keyof typeof CLI_CONFIGURES;
90
+ cli: SUPPORTED_CLIS;
128
91
  cliArgs?: string[];
129
92
  prompt?: string;
130
- continueOnCrash?: boolean;
93
+ robust?: boolean;
131
94
  cwd?: string;
132
95
  env?: Record<string, string>;
133
96
  exitOnIdle?: number;
134
97
  logFile?: string;
135
98
  removeControlCharactersFromStdout?: boolean;
136
99
  verbose?: boolean;
137
- disableLock?: boolean;
138
- } = {}) {
139
- const continueArgs = {
140
- codex: 'resume --last'.split(' '),
141
- claude: '--continue'.split(' '),
142
- gemini: [], // not possible yet
143
- };
100
+ queue?: boolean;
101
+ }) {
102
+ // those overrides seems only works in bun
103
+ // await Promise.allSettled([
104
+ // import(path.join(process.cwd(), "cli-yes.config")),
105
+ // ])
106
+ // .then((e) => e.flatMap((e) => (e.status === "fulfilled" ? [e.value] : [])))
107
+ // .then(e=>e.at(0))
108
+ // .then((e) => e.default as ReturnType<typeof defineCliYesConfig>)
109
+ // .then(async (override) => deepMixin(config, override || {}))
110
+ // .catch((error) => {
111
+ // if (process.env.VERBOSE)
112
+ // console.warn("Fail to load cli-yes.config.ts", error);
113
+ // });
114
+
115
+ if (!cli) throw new Error(`cli is required`);
116
+ const conf = CLIS_CONFIG[cli] || DIE(`Unsupported cli tool: ${cli}`);
144
117
 
145
118
  // Acquire lock before starting agent (if in git repo or same cwd and lock is not disabled)
146
119
  const workingDir = cwd ?? process.cwd();
147
- if (!disableLock && shouldUseLock(workingDir)) {
148
- await acquireLock(workingDir, prompt ?? 'Interactive session');
149
- }
150
-
151
- // Register cleanup handlers for lock release
152
- const cleanupLock = async () => {
153
- if (!disableLock && shouldUseLock(workingDir)) {
154
- await releaseLock().catch(() => null); // Ignore errors during cleanup
120
+ if (queue) {
121
+ if (queue && shouldUseLock(workingDir)) {
122
+ await acquireLock(workingDir, prompt ?? 'Interactive session');
155
123
  }
156
- };
157
124
 
158
- process.on('exit', () => {
159
- if (!disableLock) {
160
- releaseLock().catch(() => null);
161
- }
162
- });
163
- process.on('SIGINT', async () => {
164
- await cleanupLock();
165
- process.exit(130);
166
- });
167
- process.on('SIGTERM', async () => {
168
- await cleanupLock();
169
- process.exit(143);
170
- });
125
+ // Register cleanup handlers for lock release
126
+ const cleanupLock = async () => {
127
+ if (queue && shouldUseLock(workingDir)) {
128
+ await releaseLock().catch(() => null); // Ignore errors during cleanup
129
+ }
130
+ };
131
+
132
+ process.on('exit', () => {
133
+ if (queue) releaseLock().catch(() => null);
134
+ });
135
+ process.on('SIGINT', async (code) => {
136
+ await cleanupLock();
137
+ process.exit(code);
138
+ });
139
+ process.on('SIGTERM', async (code) => {
140
+ await cleanupLock();
141
+ process.exit(code);
142
+ });
143
+ }
171
144
 
172
145
  process.stdin.setRawMode?.(true); // must be called any stdout/stdin usage
173
146
  let isFatal = false; // when true, do not restart on crash, and exit agent
@@ -193,21 +166,49 @@ export default async function claudeYes({
193
166
  });
194
167
 
195
168
  // Apply CLI specific configurations (moved to CLI_CONFIGURES)
196
- const cliConf = (CLI_CONFIGURES as Record<string, any>)[cli] || {};
197
- cliArgs = cliConf.ensureArgs?.(cliArgs) ?? cliArgs;
169
+ const cliConf = (CLIS_CONFIG as Record<string, AgentCliConfig>)[cli] || {};
170
+ cliArgs = cliConf.defaultArgs
171
+ ? [...cliConf.defaultArgs, ...cliArgs]
172
+ : cliArgs;
173
+
174
+ if (prompt && cliConf.promptArg) {
175
+ if (cliConf.promptArg === 'first-arg') {
176
+ cliArgs = [prompt, ...cliArgs];
177
+ prompt = undefined; // clear prompt to avoid sending later
178
+ } else if (cliConf.promptArg === 'last-arg') {
179
+ cliArgs = [...cliArgs, prompt];
180
+ prompt = undefined; // clear prompt to avoid sending later
181
+ } else if (cliConf.promptArg.startsWith('--')) {
182
+ cliArgs = [cliConf.promptArg, prompt, ...cliArgs];
183
+ prompt = undefined; // clear prompt to avoid sending later
184
+ } else {
185
+ console.warn(`Unknown promptArg format: ${cliConf.promptArg}`);
186
+ }
187
+ }
198
188
  const cliCommand = cliConf?.binary || cli;
199
189
 
200
- let shell = tryCatch(
201
- () => pty.spawn(cliCommand, cliArgs, getPtyOptions()),
190
+ let shell = catcher(
202
191
  (error: unknown) => {
203
192
  console.error(`Fatal: Failed to start ${cliCommand}.`);
204
- if (cliConf?.install)
193
+ if (cliConf?.install && isCommandNotFoundError(error))
205
194
  console.error(
206
195
  `If you did not installed it yet, Please install it first: ${cliConf.install}`,
207
196
  );
208
197
  throw error;
198
+
199
+ function isCommandNotFoundError(e: unknown) {
200
+ if (e instanceof Error) {
201
+ return (
202
+ e.message.includes('command not found') ||
203
+ e.message.includes('ENOENT') ||
204
+ e.message.includes('spawn') // windows
205
+ );
206
+ }
207
+ return false;
208
+ }
209
209
  },
210
- );
210
+ () => pty.spawn(cliCommand, cliArgs, getPtyOptions()),
211
+ )();
211
212
  const pendingExitCode = Promise.withResolvers<number | null>();
212
213
  let pendingExitCodeValue = null;
213
214
 
@@ -215,22 +216,22 @@ export default async function claudeYes({
215
216
  // npm install -g @anthropic-ai/claude-code
216
217
 
217
218
  async function onData(data: string) {
218
- nextStdout.ready();
219
219
  // append data to the buffer, so we can process it later
220
220
  await outputWriter.write(data);
221
221
  }
222
222
 
223
223
  shell.onData(onData);
224
224
  shell.onExit(function onExit({ exitCode }) {
225
- nextStdout.ready();
226
225
  stdinReady.unready(); // start buffer stdin
227
226
  const agentCrashed = exitCode !== 0;
228
- const continueArg = (continueArgs as Record<string, string[]>)[cli];
229
227
 
230
- if (agentCrashed && continueOnCrash && continueArg) {
231
- if (!continueArg) {
228
+ if (agentCrashed && robust && conf?.restoreArgs) {
229
+ if (!conf.restoreArgs) {
232
230
  return console.warn(
233
- `continueOnCrash is only supported for ${Object.keys(continueArgs).join(', ')} currently, not ${cli}`,
231
+ `robust is only supported for ${Object.entries(CLIS_CONFIG)
232
+ .filter(([_, v]) => v.restoreArgs)
233
+ .map(([k]) => k)
234
+ .join(', ')} currently, not ${cli}`,
234
235
  );
235
236
  }
236
237
  if (isFatal) {
@@ -239,7 +240,7 @@ export default async function claudeYes({
239
240
 
240
241
  console.log(`${cli} crashed, restarting...`);
241
242
 
242
- shell = pty.spawn(cli, continueArg, getPtyOptions());
243
+ shell = pty.spawn(cli, conf.restoreArgs, getPtyOptions());
243
244
  shell.onData(onData);
244
245
  shell.onExit(onExit);
245
246
  return;
@@ -295,7 +296,8 @@ export default async function claudeYes({
295
296
  readable: shellOutputStream.readable,
296
297
  })
297
298
  .forEach(() => idleWaiter.ping())
298
- .forEach((text) => {
299
+ .forEach(() => nextStdout.ready())
300
+ .forEach(async (text) => {
299
301
  terminalRender.write(text);
300
302
  // todo: .onStatus((msg)=> shell.write(msg))
301
303
  if (process.stdin.isTTY) return; // only handle it when stdin is not tty
@@ -307,7 +309,7 @@ export default async function claudeYes({
307
309
  // const rendered = terminalRender.render();
308
310
  const { col, row } = terminalRender.getCursorPosition();
309
311
  shell.write(`\u001b[${row};${col}R`); // reply cli when getting cursor position
310
- // await yesLog(`cursor|respond position: row=${row}, col=${col}`);
312
+ await yesLog`cursor|respond position: row=${String(row)}, col=${String(col)}`;
311
313
  // const row = rendered.split('\n').length + 1;
312
314
  // const col = (rendered.split('\n').slice(-1)[0]?.length || 0) + 1;
313
315
  })
@@ -318,33 +320,29 @@ export default async function claudeYes({
318
320
  .map((e) => removeControlCharacters(e))
319
321
  .map((e) => e.replaceAll('\r', '')) // remove carriage return
320
322
  .by((s) => {
321
- if (cli === 'codex') return s; // codex use cursor-move csi code insteadof \n to move lines, so the output have no \n at all, this hack prevents stuck on unended line
323
+ if (conf.noEOL) return s; // codex use cursor-move csi code insteadof \n to move lines, so the output have no \n at all, this hack prevents stuck on unended line
322
324
  return s.lines({ EOL: 'NONE' }); // other clis use ink, which is rerendering the block based on \n lines
323
325
  })
324
- // .forEach((e) => yesLog`output|${e}`) // for debugging
326
+ .forEach((e) => yesLog`output|${e}`) // for debugging
325
327
  // Generic auto-response handler driven by CLI_CONFIGURES
326
328
  .forEach(async (e, i) => {
327
- const conf =
328
- CLI_CONFIGURES[cli as keyof typeof CLI_CONFIGURES] || null;
329
- if (!conf) return;
330
-
331
329
  // ready matcher: if matched, mark stdin ready
332
330
  if (conf.ready?.some((rx: RegExp) => e.match(rx))) {
333
- // await yesLog`ready |${e}`;
331
+ await yesLog`ready |${e}`;
334
332
  if (cli === 'gemini' && i <= 80) return; // gemini initial noise, only after many lines
335
333
  stdinReady.ready();
336
334
  }
337
335
 
338
336
  // enter matchers: send Enter when any enter regex matches
339
337
  if (conf.enter?.some((rx: RegExp) => e.match(rx))) {
340
- // await yesLog`enter |${e}`;
338
+ await yesLog`enter |${e}`;
341
339
  await sendEnter(300); // send Enter after 300ms idle wait
342
340
  return;
343
341
  }
344
342
 
345
343
  // fatal matchers: set isFatal flag when matched
346
344
  if (conf.fatal?.some((rx: RegExp) => e.match(rx))) {
347
- // await yesLog`fatal |${e}`;
345
+ await yesLog`fatal |${e}`;
348
346
  isFatal = true;
349
347
  await exitAgent();
350
348
  }
@@ -365,7 +363,7 @@ export default async function claudeYes({
365
363
  console.log(`[${cli}-yes] ${cli} exited with code ${exitCode}`);
366
364
 
367
365
  // Update task status and release lock
368
- if (!disableLock && shouldUseLock(workingDir)) {
366
+ if (queue && shouldUseLock(workingDir)) {
369
367
  await updateCurrentTaskStatus(
370
368
  exitCode === 0 ? 'completed' : 'failed',
371
369
  ).catch(() => null);
@@ -389,24 +387,40 @@ export default async function claudeYes({
389
387
  await idleWaiter.wait(waitms);
390
388
  const et = Date.now();
391
389
  // process.stdout.write(`\ridleWaiter.wait(${waitms}) took ${et - st}ms\r`);
392
- // await yesLog`sendEn| idleWaiter.wait(${String(waitms)}) took ${String(et - st)}ms`;
393
-
390
+ await yesLog`sendEn| idleWaiter.wait(${String(waitms)}) took ${String(et - st)}ms`;
391
+ nextStdout.unready();
394
392
  shell.write('\r');
393
+ // retry once if not received any output in 1 second after sending Enter
394
+ await Promise.race([
395
+ nextStdout.wait(),
396
+ new Promise<void>((resolve) =>
397
+ setTimeout(() => {
398
+ if (!nextStdout.ready) {
399
+ shell.write('\r');
400
+ }
401
+ resolve();
402
+ }, 1000),
403
+ ),
404
+ ]);
395
405
  }
396
406
 
397
407
  async function sendMessage(message: string) {
398
408
  await stdinReady.wait();
399
409
  // show in-place message: write msg and move cursor back start
400
- // await yesLog`send |${message}`;
401
- shell.write(message);
410
+ yesLog`send |${message}`;
402
411
  nextStdout.unready();
412
+ shell.write(message);
403
413
  idleWaiter.ping(); // just sent a message, wait for echo
414
+ yesLog`waiting next stdout|${message}`;
404
415
  await nextStdout.wait();
405
- await sendEnter();
416
+ yesLog`sending enter`;
417
+ await sendEnter(1000);
418
+ yesLog`sent enter`;
406
419
  }
407
420
 
408
421
  async function exitAgent() {
409
- continueOnCrash = false;
422
+ robust = false; // disable robust to avoid auto restart
423
+
410
424
  // send exit command to the shell, must sleep a bit to avoid claude treat it as pasted input
411
425
  await sendMessage('/exit');
412
426
 
@@ -438,11 +452,3 @@ export default async function claudeYes({
438
452
  }
439
453
 
440
454
  export { removeControlCharacters };
441
-
442
- function tryCatch<T, R>(fn: () => T, catchFn: (error: unknown) => R): T | R {
443
- try {
444
- return fn();
445
- } catch (error) {
446
- return catchFn(error);
447
- }
448
- }
@@ -0,0 +1,18 @@
1
+ #! /usr/bin/env bun
2
+ import { execaCommand } from 'execa';
3
+ import { copyFile } from 'fs/promises';
4
+ import * as pkg from '../package.json';
5
+ import { CLIS_CONFIG } from '.';
6
+
7
+ const src = 'dist/cli.js';
8
+ await Promise.all(
9
+ Object.keys(CLIS_CONFIG).map(async (cli) => {
10
+ const dst = `dist/${cli}-yes.js`;
11
+ if (!(pkg.bin as Record<string, string>)?.[`${cli}-yes`]) {
12
+ console.log(`package.json Updated bin.${cli}-yes = ${dst}`);
13
+ await execaCommand(`npm pkg set bin.${cli}-yes=${dst}`);
14
+ }
15
+ await copyFile(src, dst);
16
+ console.log(`${dst} Updated`);
17
+ }),
18
+ );
@@ -133,14 +133,21 @@ describe('runningLock', () => {
133
133
  });
134
134
 
135
135
  it('should not have gitRoot for non-git directory', async () => {
136
- // /tmp is typically not a git repository
137
- await acquireLock('/tmp', 'Non-git task');
136
+ // Create a temporary directory outside of any git repo
137
+ const tempDir = path.join('/tmp', 'test-non-git-' + Date.now());
138
+ await mkdir(tempDir, { recursive: true });
138
139
 
139
- const lockData = await readLockFile();
140
- expect(lockData.tasks[0].gitRoot).toBeUndefined();
141
- expect(lockData.tasks[0].cwd).toBe('/tmp');
140
+ try {
141
+ await acquireLock(tempDir, 'Non-git task');
142
142
 
143
- await releaseLock();
143
+ const lockData = await readLockFile();
144
+ expect(lockData.tasks[0].gitRoot).toBeUndefined();
145
+ expect(lockData.tasks[0].cwd).toBe(path.resolve(tempDir));
146
+
147
+ await releaseLock();
148
+ } finally {
149
+ await rm(tempDir, { recursive: true, force: true });
150
+ }
144
151
  });
145
152
  });
146
153
 
@@ -247,31 +254,27 @@ describe('runningLock', () => {
247
254
 
248
255
  describe('concurrent access', () => {
249
256
  it('should handle multiple tasks from different processes', async () => {
250
- // Simulate multiple processes by using different PIDs in the lock file
257
+ // Acquire first task
251
258
  await acquireLock(TEST_DIR, 'Task 1');
252
259
 
253
- // Manually add another task with our PID + 1 (simulating another process)
254
- const lockData = await readLockFile();
255
- lockData.tasks.push({
256
- cwd: '/tmp',
257
- task: 'Task 2',
258
- pid: process.pid + 1,
259
- status: 'running',
260
- startedAt: Date.now(),
261
- lockedAt: Date.now(),
262
- });
263
- await writeFile(LOCK_FILE, JSON.stringify(lockData, null, 2));
260
+ // Verify the task exists
261
+ let lockData = await readLockFile();
262
+ expect(lockData.tasks).toHaveLength(1);
263
+ expect(lockData.tasks[0].task).toBe('Task 1');
264
264
 
265
- // Read and verify both tasks exist
266
- const updatedLockData = await readLockFile();
267
- expect(updatedLockData.tasks).toHaveLength(2);
265
+ // Acquire a second task with the same PID (should replace the first)
266
+ await acquireLock('/tmp', 'Task 2');
267
+
268
+ // Should have only one task (the latest one)
269
+ lockData = await readLockFile();
270
+ expect(lockData.tasks).toHaveLength(1);
271
+ expect(lockData.tasks[0].task).toBe('Task 2');
268
272
 
269
273
  await releaseLock();
270
274
 
271
- // After release, only the "other process" task should remain
275
+ // After release, no tasks should remain
272
276
  const finalLockData = await readLockFile();
273
- expect(finalLockData.tasks).toHaveLength(1);
274
- expect(finalLockData.tasks[0].pid).toBe(process.pid + 1);
277
+ expect(finalLockData.tasks).toHaveLength(0);
275
278
  });
276
279
 
277
280
  it('should not duplicate tasks with same PID', async () => {
@@ -411,13 +414,16 @@ describe('runningLock', () => {
411
414
  });
412
415
 
413
416
  it('should allow different directories without git repos', async () => {
414
- // Create lock for /tmp
417
+ // Test that when we already have a task, acquiring a new one replaces it
418
+ // (since both use the same PID)
419
+
420
+ // Create lock for /tmp manually
415
421
  const lock = {
416
422
  tasks: [
417
423
  {
418
424
  cwd: '/tmp',
419
425
  task: 'Tmp task',
420
- pid: process.pid + 1,
426
+ pid: process.pid,
421
427
  status: 'running' as const,
422
428
  startedAt: Date.now(),
423
429
  lockedAt: Date.now(),
@@ -426,12 +432,18 @@ describe('runningLock', () => {
426
432
  };
427
433
  await writeFile(LOCK_FILE, JSON.stringify(lock, null, 2));
428
434
 
429
- // Acquire lock for different directory
435
+ // Verify initial state
436
+ let lockData = await readLockFile();
437
+ expect(lockData.tasks).toHaveLength(1);
438
+ expect(lockData.tasks[0].task).toBe('Tmp task');
439
+
440
+ // Acquire lock for different directory (should replace the existing task)
430
441
  await acquireLock(TEST_DIR, 'Test task');
431
442
 
432
- // Both should coexist
433
- const lockData = await readLockFile();
434
- expect(lockData.tasks).toHaveLength(2);
443
+ // Should only have the new task
444
+ lockData = await readLockFile();
445
+ expect(lockData.tasks).toHaveLength(1);
446
+ expect(lockData.tasks[0].task).toBe('Test task');
435
447
 
436
448
  await releaseLock();
437
449
  });
@@ -470,7 +482,9 @@ async function cleanupLockFile() {
470
482
  async function readLockFile(): Promise<{ tasks: Task[] }> {
471
483
  try {
472
484
  const content = await readFile(LOCK_FILE, 'utf8');
473
- return JSON.parse(content);
485
+ const lockFile = JSON.parse(content);
486
+ // Don't clean stale locks in tests - we want to see the raw data
487
+ return lockFile;
474
488
  } catch {
475
489
  return { tasks: [] };
476
490
  }
@@ -221,13 +221,57 @@ async function waitForUnlock(
221
221
  currentTask: Task,
222
222
  ): Promise<void> {
223
223
  const blockingTask = blockingTasks[0];
224
+ if (!blockingTask) return;
224
225
  console.log(`⏳ Queueing for unlock of: ${blockingTask.task}`);
226
+ console.log(` Press 'b' to bypass queue, 'k' to kill previous instance`);
225
227
 
226
228
  // Add current task as 'queued'
227
229
  await addTask({ ...currentTask, status: 'queued' });
228
230
 
231
+ // Set up keyboard input handling
232
+ const stdin = process.stdin;
233
+ const wasRaw = stdin.isRaw;
234
+ stdin.setRawMode?.(true);
235
+ stdin.resume();
236
+
237
+ let bypassed = false;
238
+ let killed = false;
239
+
240
+ const keyHandler = (key: Buffer) => {
241
+ const char = key.toString();
242
+ if (char === 'b' || char === 'B') {
243
+ console.log('\n⚡ Bypassing queue...');
244
+ bypassed = true;
245
+ } else if (char === 'k' || char === 'K') {
246
+ console.log('\n🔪 Killing previous instance...');
247
+ killed = true;
248
+ }
249
+ };
250
+
251
+ stdin.on('data', keyHandler);
252
+
229
253
  let dots = 0;
230
254
  while (true) {
255
+ if (bypassed) {
256
+ // Force bypass - update status to running immediately
257
+ await updateTaskStatus(currentTask.pid, 'running');
258
+ console.log('✓ Queue bypassed, starting task...');
259
+ break;
260
+ }
261
+
262
+ if (killed && blockingTask) {
263
+ // Kill the blocking task's process
264
+ try {
265
+ process.kill(blockingTask.pid, 'SIGTERM');
266
+ console.log(`✓ Killed process ${blockingTask.pid}`);
267
+ // Wait a bit for the process to be killed
268
+ await sleep(1000);
269
+ } catch (err) {
270
+ console.log(`⚠️ Could not kill process ${blockingTask.pid}: ${err}`);
271
+ }
272
+ killed = false; // Reset flag after attempting kill
273
+ }
274
+
231
275
  await sleep(POLL_INTERVAL);
232
276
 
233
277
  const lockCheck = await checkLock(currentTask.cwd, currentTask.task);
@@ -245,6 +289,11 @@ async function waitForUnlock(
245
289
  `\r⏳ Queueing${'.'.repeat(dots)}${' '.repeat(3 - dots)}`,
246
290
  );
247
291
  }
292
+
293
+ // Clean up keyboard handler
294
+ stdin.off('data', keyHandler);
295
+ stdin.setRawMode?.(wasRaw);
296
+ if (!wasRaw) stdin.pause();
248
297
  }
249
298
 
250
299
  /**