runsignal 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/runsignal.js +204 -49
  2. package/package.json +1 -1
package/bin/runsignal.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import {Command} from 'commander';
4
4
  import {spawn, execSync} from 'node:child_process';
5
5
  import {promises as fs} from 'node:fs';
6
+ import {existsSync} from 'node:fs';
6
7
  import os from 'node:os';
7
8
  import path from 'node:path';
8
9
  import {
@@ -17,6 +18,20 @@ const DEFAULT_APP_URL = process.env.RUNSIGNAL_APP_URL || 'https://runsignal.dev'
17
18
  const CONFIG_DIR = path.join(os.homedir(), '.runsignal');
18
19
  const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
19
20
 
21
+ function extractErrorDetail(code, marker) {
22
+ const index = code.indexOf(marker);
23
+ if (index === -1) {
24
+ return '';
25
+ }
26
+
27
+ const detail = code.slice(index + marker.length).trim();
28
+ if (!detail) {
29
+ return '';
30
+ }
31
+
32
+ return detail.slice(0, 180);
33
+ }
34
+
20
35
  function toUserMessage(errorCode) {
21
36
  const code = String(errorCode || '');
22
37
  if (code.includes('not_logged_in')) return 'You are not logged in. Run: runsignal login';
@@ -31,8 +46,13 @@ function toUserMessage(errorCode) {
31
46
  if (code.includes('login_timeout')) return 'Login timed out. Retry runsignal login and validate quickly.';
32
47
  if (code.includes('login_poll_failed'))
33
48
  return 'Login polling failed. Check APP URL and server availability.';
34
- if (code.includes('send_event_failed'))
49
+ if (code.includes('send_event_failed')) {
50
+ const detail = extractErrorDetail(code, 'send_event_failed:');
51
+ if (detail) {
52
+ return `Event send failed (${detail}). Check machine key, network and server logs.`;
53
+ }
35
54
  return 'Event send failed. Check machine key, network and server logs.';
55
+ }
36
56
  if (code.includes('status_failed')) return 'Status check failed. Verify event id and machine ownership.';
37
57
  if (code.includes('event_type_invalid'))
38
58
  return 'Invalid event type. Use approval_required, task_completed, error, quota_warning, question.';
@@ -100,6 +120,39 @@ function sleep(ms) {
100
120
  return new Promise((resolve) => setTimeout(resolve, ms));
101
121
  }
102
122
 
123
+ function tryOpenBrowser(url) {
124
+ if (!url) return false;
125
+
126
+ try {
127
+ if (process.platform === 'win32') {
128
+ const child = spawn('cmd', ['/c', 'start', '', url], {
129
+ detached: true,
130
+ stdio: 'ignore'
131
+ });
132
+ child.unref();
133
+ return true;
134
+ }
135
+
136
+ if (process.platform === 'darwin') {
137
+ const child = spawn('open', [url], {
138
+ detached: true,
139
+ stdio: 'ignore'
140
+ });
141
+ child.unref();
142
+ return true;
143
+ }
144
+
145
+ const child = spawn('xdg-open', [url], {
146
+ detached: true,
147
+ stdio: 'ignore'
148
+ });
149
+ child.unref();
150
+ return true;
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
103
156
  const HOOK_START = '# >>> RunSignal hooks start >>>';
104
157
  const HOOK_END = '# <<< RunSignal hooks end <<<';
105
158
 
@@ -122,43 +175,44 @@ function buildHookBlock(shellKind) {
122
175
  return `${HOOK_START}
123
176
  function RunSignal-Resolve-NativeCommand {
124
177
  param([string]$Name)
125
- $cmd = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
126
- if (-not $cmd) { return $null }
127
- return $cmd.Source
178
+ $candidates = @(Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue)
179
+ if (-not $candidates -or $candidates.Count -eq 0) { return $null }
180
+ $preferred = $candidates | Where-Object { $_.Source -match '\\.exe$' } | Select-Object -First 1
181
+ if ($preferred) { return $preferred.Source }
182
+ $launcher = $candidates | Where-Object { $_.Source -match '\\.(cmd|bat)$' } | Select-Object -First 1
183
+ if ($launcher) { return $launcher.Source }
184
+ $secondary = $candidates | Where-Object { $_.Source -match '\\.[A-Za-z0-9]+$' } | Select-Object -First 1
185
+ if ($secondary) { return $secondary.Source }
186
+ return $candidates[0].Source
128
187
  }
129
188
  function claude {
130
189
  $native = RunSignal-Resolve-NativeCommand "claude"
131
190
  if (-not $native) {
132
- $home = [Environment]::GetFolderPath("UserProfile")
133
- $fallback = Join-Path $home ".local\\bin\\claude.exe"
191
+ $userHome = [Environment]::GetFolderPath("UserProfile")
192
+ $fallback = Join-Path $userHome ".local\\bin\\claude.exe"
134
193
  if (Test-Path $fallback) { $native = $fallback }
135
194
  }
136
195
  if (-not $native) { Write-Error "RunSignal hook: native claude executable not found. Add your native installer path to PATH (example: %USERPROFILE%\\.local\\bin)."; return }
137
- if ($args.Count -eq 0) { & $native; return }
138
196
  runsignal wrap -- $native @args
139
197
  }
140
198
  function codex {
141
199
  $native = RunSignal-Resolve-NativeCommand "codex"
142
200
  if (-not $native) { Write-Error "RunSignal hook: native codex executable not found."; return }
143
- if ($args.Count -eq 0) { & $native; return }
144
201
  runsignal wrap -- $native @args
145
202
  }
146
203
  function cursor {
147
204
  $native = RunSignal-Resolve-NativeCommand "cursor"
148
205
  if (-not $native) { Write-Error "RunSignal hook: native cursor executable not found."; return }
149
- if ($args.Count -eq 0) { & $native; return }
150
206
  runsignal wrap -- $native @args
151
207
  }
152
208
  function gemini {
153
209
  $native = RunSignal-Resolve-NativeCommand "gemini"
154
210
  if (-not $native) { Write-Error "RunSignal hook: native gemini executable not found."; return }
155
- if ($args.Count -eq 0) { & $native; return }
156
211
  runsignal wrap -- $native @args
157
212
  }
158
213
  function aider {
159
214
  $native = RunSignal-Resolve-NativeCommand "aider"
160
215
  if (-not $native) { Write-Error "RunSignal hook: native aider executable not found."; return }
161
- if ($args.Count -eq 0) { & $native; return }
162
216
  runsignal wrap -- $native @args
163
217
  }
164
218
  ${HOOK_END}
@@ -183,35 +237,30 @@ claude() {
183
237
  local native
184
238
  native="$(runsignal_resolve_native claude)"
185
239
  if [ -z "$native" ]; then echo "RunSignal hook: native claude executable not found." >&2; return 127; fi
186
- if [ "$#" -eq 0 ]; then "$native"; return $?; fi
187
240
  runsignal wrap -- "$native" "$@"
188
241
  }
189
242
  codex() {
190
243
  local native
191
244
  native="$(runsignal_resolve_native codex)"
192
245
  if [ -z "$native" ]; then echo "RunSignal hook: native codex executable not found." >&2; return 127; fi
193
- if [ "$#" -eq 0 ]; then "$native"; return $?; fi
194
246
  runsignal wrap -- "$native" "$@"
195
247
  }
196
248
  cursor() {
197
249
  local native
198
250
  native="$(runsignal_resolve_native cursor)"
199
251
  if [ -z "$native" ]; then echo "RunSignal hook: native cursor executable not found." >&2; return 127; fi
200
- if [ "$#" -eq 0 ]; then "$native"; return $?; fi
201
252
  runsignal wrap -- "$native" "$@"
202
253
  }
203
254
  gemini() {
204
255
  local native
205
256
  native="$(runsignal_resolve_native gemini)"
206
257
  if [ -z "$native" ]; then echo "RunSignal hook: native gemini executable not found." >&2; return 127; fi
207
- if [ "$#" -eq 0 ]; then "$native"; return $?; fi
208
258
  runsignal wrap -- "$native" "$@"
209
259
  }
210
260
  aider() {
211
261
  local native
212
262
  native="$(runsignal_resolve_native aider)"
213
263
  if [ -z "$native" ]; then echo "RunSignal hook: native aider executable not found." >&2; return 127; fi
214
- if [ "$#" -eq 0 ]; then "$native"; return $?; fi
215
264
  runsignal wrap -- "$native" "$@"
216
265
  }
217
266
  ${HOOK_END}
@@ -435,6 +484,35 @@ function quoteForShell(arg) {
435
484
  return `"${escaped}"`;
436
485
  }
437
486
 
487
+ function stripWrappingQuotes(value) {
488
+ const raw = String(value || '').trim();
489
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
490
+ return raw.slice(1, -1);
491
+ }
492
+ return raw;
493
+ }
494
+
495
+ function isWindowsScriptLauncher(filePath) {
496
+ if (process.platform !== 'win32') return false;
497
+ const ext = path.extname(String(filePath || '')).toLowerCase();
498
+ return ext === '.cmd' || ext === '.bat' || ext === '.ps1';
499
+ }
500
+
501
+ function normalizeWindowsExecutablePath(filePath) {
502
+ const raw = String(filePath || '');
503
+ if (process.platform !== 'win32') return raw;
504
+ if (!raw) return raw;
505
+ const ext = path.extname(raw).toLowerCase();
506
+ if (ext) return raw;
507
+ const exe = `${raw}.exe`;
508
+ if (existsSync(exe)) return exe;
509
+ const cmd = `${raw}.cmd`;
510
+ if (existsSync(cmd)) return cmd;
511
+ const bat = `${raw}.bat`;
512
+ if (existsSync(bat)) return bat;
513
+ return raw;
514
+ }
515
+
438
516
  async function runLogin(options) {
439
517
  const config = await readConfig();
440
518
  const baseUrl = String(options.appUrl || config.app_url || DEFAULT_APP_URL).replace(/\/$/, '');
@@ -453,7 +531,13 @@ async function runLogin(options) {
453
531
  throw new Error('login_payload_invalid');
454
532
  }
455
533
 
456
- console.log(`Open ${verificationUrl} and enter code: ${userCode}`);
534
+ const verificationUrlWithCode = `${verificationUrl}?code=${encodeURIComponent(String(userCode))}`;
535
+ const opened = tryOpenBrowser(verificationUrlWithCode);
536
+ if (opened) {
537
+ console.log('Browser opened.');
538
+ } else {
539
+ console.log(`Open ${verificationUrlWithCode} and enter code: ${userCode}`);
540
+ }
457
541
 
458
542
  let accessToken = null;
459
543
  const deadline = Date.now() + 10 * 60 * 1000;
@@ -578,7 +662,7 @@ async function runWrap(commandParts) {
578
662
  }
579
663
 
580
664
  const repo = getRepoFromGit();
581
- const branch = getBranchFromGit();
665
+ const branch = getBranchFromGit() || undefined;
582
666
  const machineName = config.machine_name || getMachineName();
583
667
  const agentLabel = inferAgentLabel(commandParts);
584
668
 
@@ -593,13 +677,75 @@ async function runWrap(commandParts) {
593
677
  commandParts.length === 1
594
678
  ? String(commandParts[0])
595
679
  : commandParts.map((part) => quoteForShell(part)).join(' ');
680
+ const singleCommand = String(commandParts[0] || '');
681
+ const singleExecutable = normalizeWindowsExecutablePath(stripWrappingQuotes(singleCommand));
596
682
  const interactivePassthrough =
597
- commandParts.length === 1 && !/[\s"'`]/.test(String(commandParts[0]));
598
- const child = spawn(commandString, {
599
- stdio: interactivePassthrough ? 'inherit' : ['inherit', 'pipe', 'pipe'],
600
- shell: true,
601
- env: process.env
602
- });
683
+ commandParts.length === 1 && (existsSync(singleExecutable) || !/\s/.test(singleExecutable));
684
+ const child = interactivePassthrough
685
+ ? isWindowsScriptLauncher(singleExecutable)
686
+ ? spawn(singleExecutable, [], {
687
+ stdio: 'inherit',
688
+ shell: true,
689
+ env: process.env
690
+ })
691
+ : spawn(singleExecutable, [], {
692
+ stdio: 'inherit',
693
+ shell: false,
694
+ env: process.env
695
+ })
696
+ : spawn(commandString, {
697
+ stdio: ['inherit', 'pipe', 'pipe'],
698
+ shell: true,
699
+ env: process.env
700
+ });
701
+
702
+ const startedAt = Date.now();
703
+ const sessionTarget = interactivePassthrough ? singleExecutable : commandString;
704
+ try {
705
+ await sendEvent({
706
+ apiFetch,
707
+ baseUrl,
708
+ machineApiKey,
709
+ event: {
710
+ message: `Session started: ${sessionTarget}`,
711
+ event_type: 'session_started',
712
+ repo,
713
+ branch,
714
+ severity: 'info',
715
+ context,
716
+ agent: agentLabel
717
+ }
718
+ });
719
+ } catch {
720
+ // non-blocking
721
+ }
722
+
723
+ let heartbeatInterval = null;
724
+ if (interactivePassthrough) {
725
+ const heartbeatMin = Number.parseInt(String(process.env.RUNSIGNAL_HEARTBEAT_MIN ?? '0'), 10);
726
+ if (Number.isFinite(heartbeatMin) && heartbeatMin > 0) {
727
+ heartbeatInterval = setInterval(() => {
728
+ const elapsedMin = Math.max(1, Math.floor((Date.now() - startedAt) / 60_000));
729
+ sendEvent({
730
+ apiFetch,
731
+ baseUrl,
732
+ machineApiKey,
733
+ event: {
734
+ message: `Session running (${elapsedMin} min): ${singleExecutable}`,
735
+ event_type: 'session_running',
736
+ repo,
737
+ branch,
738
+ severity: 'info',
739
+ context,
740
+ agent: agentLabel
741
+ }
742
+ }).catch(() => {
743
+ // non-blocking
744
+ });
745
+ }, heartbeatMin * 60_000);
746
+ heartbeatInterval.unref?.();
747
+ }
748
+ }
603
749
 
604
750
  let sawApproval = false;
605
751
  let sawQuestion = false;
@@ -699,32 +845,38 @@ async function runWrap(commandParts) {
699
845
  }
700
846
 
701
847
  const exitCode = await new Promise((resolve) => {
848
+ child.on('error', () => resolve(1));
702
849
  child.on('close', (code) => resolve(code ?? 1));
703
850
  });
851
+ if (heartbeatInterval) {
852
+ clearInterval(heartbeatInterval);
853
+ }
704
854
  await Promise.allSettled(Array.from(pendingTasks));
705
855
 
706
- const completionMessage =
707
- exitCode === 0
708
- ? `Wrapped command completed: ${commandParts.join(' ')}`
709
- : `Wrapped command failed (code ${exitCode}): ${lastInterestingLine || commandParts.join(' ')}`;
856
+ if (!interactivePassthrough || exitCode !== 0) {
857
+ const completionMessage =
858
+ exitCode === 0
859
+ ? `Wrapped command completed: ${commandParts.join(' ')}`
860
+ : `Wrapped command failed (code ${exitCode}): ${lastInterestingLine || commandParts.join(' ')}`;
710
861
 
711
- try {
712
- await sendEvent({
713
- apiFetch,
714
- baseUrl,
715
- machineApiKey,
716
- event: {
717
- message: completionMessage.slice(0, 1000),
718
- event_type: exitCode === 0 ? 'task_completed' : 'error',
719
- repo,
720
- branch,
721
- severity: exitCode === 0 ? 'info' : 'critical',
722
- context,
723
- agent: agentLabel
724
- }
725
- });
726
- } catch {
727
- // no-op
862
+ try {
863
+ await sendEvent({
864
+ apiFetch,
865
+ baseUrl,
866
+ machineApiKey,
867
+ event: {
868
+ message: completionMessage.slice(0, 1000),
869
+ event_type: exitCode === 0 ? 'task_completed' : 'error',
870
+ repo,
871
+ branch,
872
+ severity: exitCode === 0 ? 'info' : 'critical',
873
+ context,
874
+ agent: agentLabel
875
+ }
876
+ });
877
+ } catch {
878
+ // no-op
879
+ }
728
880
  }
729
881
 
730
882
  // Avoid hard process exit on Windows: let Node close handles cleanly.
@@ -742,7 +894,7 @@ async function runTest() {
742
894
  }
743
895
 
744
896
  const repo = getRepoFromGit();
745
- const branch = getBranchFromGit();
897
+ const branch = getBranchFromGit() || undefined;
746
898
 
747
899
  const result = await sendEvent({
748
900
  apiFetch,
@@ -774,7 +926,9 @@ function normalizeEventType(value) {
774
926
  raw === 'task_completed' ||
775
927
  raw === 'error' ||
776
928
  raw === 'quota_warning' ||
777
- raw === 'question'
929
+ raw === 'question' ||
930
+ raw === 'session_started' ||
931
+ raw === 'session_running'
778
932
  ) {
779
933
  return raw;
780
934
  }
@@ -811,7 +965,8 @@ async function runSend(options) {
811
965
  }
812
966
 
813
967
  const repo = options.repo ? String(options.repo).trim() : getRepoFromGit();
814
- const branch = options.branch ? String(options.branch).trim() : getBranchFromGit();
968
+ const branchRaw = options.branch ? String(options.branch).trim() : getBranchFromGit();
969
+ const branch = branchRaw || undefined;
815
970
  const agent = options.agent ? String(options.agent).trim() : 'manual-cli';
816
971
 
817
972
  const result = await sendEvent({
@@ -944,7 +1099,7 @@ async function runSetup(options) {
944
1099
 
945
1100
  const program = new Command();
946
1101
 
947
- program.name('runsignal').description('RunSignal CLI').version('0.1.5');
1102
+ program.name('runsignal').description('RunSignal CLI').version('0.1.6');
948
1103
 
949
1104
  program
950
1105
  .command('setup')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runsignal",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "RunSignal CLI for wrapping AI agent runs and handling approvals",
5
5
  "type": "module",
6
6
  "license": "MIT",