@zachjxyz/moxie 0.5.0 → 0.6.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.
package/bin/moxie CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  set -euo pipefail
19
19
 
20
- MOXIE_VERSION="0.5.0"
20
+ MOXIE_VERSION="0.6.0"
21
21
  # Resolve symlinks (npm installs bin as a symlink)
22
22
  _self="$0"
23
23
  while [ -L "$_self" ]; do
package/lib/agents.sh CHANGED
@@ -368,21 +368,23 @@ print('1' if d.get('$name', False) else '0')
368
368
  # Persist degradation state to disk (called after degrading an agent)
369
369
  _save_degraded() {
370
370
  local deg_file="$MOXIE_DIR/degraded.json"
371
+ local deg_tmp="${deg_file}.tmp"
371
372
  python3 -c "
372
- import json
373
+ import json, sys
373
374
  existing = {}
374
375
  try:
375
376
  with open('$deg_file') as f:
376
377
  existing = json.load(f)
377
378
  except: pass
378
379
  names = '${AGENT_NAMES[*]}'.split()
380
+ degraded_flags = '${AGENT_DEGRADED[*]}'.split()
379
381
  for i, name in enumerate(names):
380
- degraded_flags = '${AGENT_DEGRADED[*]}'.split()
381
382
  if i < len(degraded_flags) and degraded_flags[i] == '1':
382
383
  existing[name] = True
383
- with open('$deg_file', 'w') as f:
384
+ with open('$deg_tmp', 'w') as f:
384
385
  json.dump(existing, f, indent=2)
385
- " 2>/dev/null
386
+ " || { echo "WARNING: Failed to save degradation state" >&2; return; }
387
+ mv "$deg_tmp" "$deg_file"
386
388
  }
387
389
 
388
390
  # Returns 0 if the agent is degraded, 1 otherwise.
@@ -398,10 +400,11 @@ is_agent_degraded() {
398
400
  }
399
401
 
400
402
  # Record a productive or non-productive turn for an agent.
401
- # Usage: _record_turn_health <agent_name> <logfile>
403
+ # Usage: _record_turn_health <agent_name> <logfile> [exit_code]
402
404
  _record_turn_health() {
403
405
  local name="$1"
404
406
  local logfile="$2"
407
+ local exit_code="${3:-0}"
405
408
 
406
409
  local idx=-1
407
410
  for i in "${!AGENT_NAMES[@]}"; do
@@ -419,9 +422,13 @@ _record_turn_health() {
419
422
  log_size=$(wc -c < "$logfile" | tr -d ' ')
420
423
  fi
421
424
 
422
- # Check for known failure signatures in the log
425
+ # Check for known failure signatures
423
426
  local is_failure=0
424
- if [ "$log_size" -lt 1024 ]; then
427
+ if [ "$exit_code" = "124" ]; then
428
+ is_failure=1 # timeout
429
+ elif [ "$exit_code" != "0" ] && [ "$log_size" -lt 1024 ]; then
430
+ is_failure=1 # non-zero exit with minimal output
431
+ elif [ "$log_size" -lt 1024 ]; then
425
432
  is_failure=1
426
433
  elif grep -qE '^\[TIMEOUT\]' "$logfile" 2>/dev/null; then
427
434
  is_failure=1
@@ -526,15 +533,20 @@ dispatch_logged() {
526
533
  return 0
527
534
  fi
528
535
 
529
- dispatch_agent "$agent" "$prompt_file" "$timeout_secs" "$phase" 2>&1 | tee "$logfile" || true
536
+ # Capture exit code through the pipe (tee masks it)
537
+ local rc_file
538
+ rc_file=$(mktemp "${TMPDIR:-/tmp}/moxie-rc.XXXXXX")
539
+ ( dispatch_agent "$agent" "$prompt_file" "$timeout_secs" "$phase" 2>&1; echo $? > "$rc_file" ) | tee "$logfile"
540
+ local dispatch_rc=0
541
+ [ -f "$rc_file" ] && dispatch_rc=$(cat "$rc_file") && rm -f "$rc_file"
530
542
 
531
543
  local tokens
532
544
  tokens=$(extract_tokens "$logfile" "$agent")
533
545
  record_tokens "$csv_file" "$turn" "$agent" "$ts" "$tokens" "$phase"
534
546
  echo " Tokens: $tokens"
535
547
 
536
- # Track agent health based on output
537
- _record_turn_health "$agent" "$logfile" || return $?
548
+ # Track agent health based on output and exit code
549
+ _record_turn_health "$agent" "$logfile" "$dispatch_rc" || return $?
538
550
  }
539
551
 
540
552
  # ---- Rewrite agents section of config.toml ----
@@ -612,8 +624,11 @@ if '[settings]' in cleaned:
612
624
  else:
613
625
  cleaned = cleaned.rstrip() + '\n\n' + agents_block
614
626
 
615
- with open(config_path, 'w') as f:
627
+ tmp_path = config_path + '.tmp'
628
+ with open(tmp_path, 'w') as f:
616
629
  f.write(cleaned)
630
+ import os
631
+ os.rename(tmp_path, config_path)
617
632
  " "$MOXIE_CONFIG" \
618
633
  "$(IFS='|'; echo "${cli_agents[*]}")" \
619
634
  "$(IFS='|'; echo "${cli_cmds[*]}")" \
@@ -733,205 +748,59 @@ _cmd_agents_swap() {
733
748
  [ -n "$cfg_ep" ] && endpoint="$cfg_ep"
734
749
  fi
735
750
 
736
- # Fetch model catalog
737
- printf "Fetching models from AI Gateway..." >&2
738
- local models_json=""
739
- models_json=$(GATEWAY_API_KEY="$api_key" node "$MOXIE_LIB/gateway-models.mjs" "$endpoint" "" 2>/dev/null) || true
740
- printf "\\r\\033[K" >&2
741
-
742
- if [ -z "$models_json" ]; then
743
- echo "ERROR: Failed to fetch models. Check your API key or network." >&2
744
- return 1
751
+ # Build current models JSON
752
+ local current_json="["
753
+ local first=1
754
+ if [ ${#current_gw_models[@]} -gt 0 ]; then
755
+ for cm in "${current_gw_models[@]}"; do
756
+ [ "$first" = "0" ] && current_json="${current_json},"
757
+ first=0
758
+ current_json="${current_json}\"$cm\""
759
+ done
745
760
  fi
761
+ current_json="${current_json}]"
762
+
763
+ # Run the Node.js swap picker
764
+ local swap_output=""
765
+ swap_output=$(
766
+ GATEWAY_API_KEY="$api_key" \
767
+ GATEWAY_ENDPOINT="$endpoint" \
768
+ MOXIE_CURRENT_MODELS="$current_json" \
769
+ node "$MOXIE_LIB/select-gateway.mjs" < /dev/tty 2>/dev/tty
770
+ ) || {
771
+ echo "ERROR: Model selection failed." >&2
772
+ return 1
773
+ }
746
774
 
747
- # Parse into arrays
748
- local all_model_ids=()
749
- local all_model_labels=()
775
+ # Parse result
776
+ local cancelled=""
777
+ local new_gw_names=()
778
+ local new_gw_models=()
750
779
  eval "$(python3 -c "
751
780
  import json, sys, shlex
752
781
  data = json.loads(sys.stdin.read())
753
- models = data.get('models', [])
754
- ids = [m['id'] for m in models]
755
- labels = [m.get('name', m['id']) for m in models]
756
- print('all_model_ids=(' + ' '.join(shlex.quote(x) for x in ids) + ')')
757
- print('all_model_labels=(' + ' '.join(shlex.quote(x) for x in labels) + ')')
758
- " <<< "$models_json" 2>/dev/null)"
759
-
760
- if [ ${#all_model_ids[@]} -eq 0 ]; then
761
- echo "ERROR: No models returned from gateway." >&2
762
- return 1
763
- fi
764
-
765
- # Pre-select currently configured models
766
- local sselected=()
767
- for (( i = 0; i < ${#all_model_ids[@]}; i++ )); do
768
- local _matched=0
769
- for cm in "${current_gw_models[@]}"; do
770
- if [ "${all_model_ids[$i]}" = "$cm" ]; then
771
- _matched=1
772
- break
773
- fi
774
- done
775
- sselected+=("$_matched")
776
- done
777
-
778
- # Interactive search TUI (same as init picker)
779
- local search=""
780
- local scursor=0
781
- local max_visible=15
782
-
783
- _swap_render() {
784
- local filtered=()
785
- local lc_search
786
- lc_search=$(echo "$search" | tr '[:upper:]' '[:lower:]')
787
- for (( i = 0; i < ${#all_model_ids[@]}; i++ )); do
788
- if [ -z "$search" ]; then
789
- filtered+=("$i")
790
- else
791
- local lc_id
792
- lc_id=$(echo "${all_model_ids[$i]}" | tr '[:upper:]' '[:lower:]')
793
- [[ "$lc_id" == *"$lc_search"* ]] && filtered+=("$i")
794
- fi
795
- done
796
-
797
- local fcount=${#filtered[@]}
798
- [ "$scursor" -ge "$fcount" ] && scursor=$(( fcount > 0 ? fcount - 1 : 0 ))
799
- [ "$scursor" -lt 0 ] && scursor=0
800
-
801
- local vstart=0
802
- if [ "$scursor" -ge "$max_visible" ]; then
803
- vstart=$(( scursor - max_visible + 1 ))
804
- fi
805
- local vend=$(( vstart + max_visible ))
806
- [ "$vend" -gt "$fcount" ] && vend=$fcount
807
-
808
- local sel_count=0
809
- for (( i = 0; i < ${#sselected[@]}; i++ )); do
810
- [ "${sselected[$i]}" = "1" ] && sel_count=$(( sel_count + 1 ))
811
- done
812
-
813
- printf "\\r\\033[K \\033[1mSwap gateway models\\033[0m (%d available, %d selected)\\n" "$fcount" "$sel_count" >&2
814
- printf "\\r\\033[K Search: \\033[36m%s\\033[0m\\033[2m|\\033[0m\\n\\n" "$search" >&2
815
-
816
- local rendered=0
817
- for (( vi = vstart; vi < vend; vi++ )); do
818
- local ri=${filtered[$vi]}
819
- local mid="${all_model_ids[$ri]}"
820
- local marker=" "
821
- [ "$vi" -eq "$scursor" ] && marker="> "
822
- local check="[ ]"
823
- [ "${sselected[$ri]}" = "1" ] && check="[x]"
824
- printf "\\r\\033[K %s%s %s\\n" "$marker" "$check" "$mid" >&2
825
- rendered=$(( rendered + 1 ))
826
- done
827
-
828
- while [ "$rendered" -lt "$max_visible" ]; do
829
- printf "\\r\\033[K\\n" >&2
830
- rendered=$(( rendered + 1 ))
831
- done
832
-
833
- if [ "$vstart" -gt 0 ] || [ "$vend" -lt "$fcount" ]; then
834
- printf "\\r\\033[K \\033[2m(%d-%d of %d · scroll for more)\\033[0m\\n" "$(( vstart + 1 ))" "$vend" "$fcount" >&2
835
- else
836
- printf "\\r\\033[K\\n" >&2
837
- fi
838
-
839
- printf "\\r\\033[K \\033[2m↑↓ navigate · space select · enter confirm · esc cancel · type to filter\\033[0m" >&2
840
- }
841
-
842
- local swap_lines=$(( max_visible + 5 ))
843
-
844
- printf "\\033[?25l" >&2
845
- local old_stty
846
- old_stty=$(stty -g < /dev/tty 2>/dev/null)
847
- stty -echo -icanon min 1 < /dev/tty 2>/dev/null
848
-
849
- _swap_cleanup() {
850
- printf "\\033[?25h" >&2
851
- stty "$old_stty" < /dev/tty 2>/dev/null
852
- }
853
- trap '_swap_cleanup' INT TERM
854
-
855
- _swap_render
856
-
857
- local swap_done=0
858
- local swap_cancelled=0
859
- while [ "$swap_done" = "0" ]; do
860
- local skey
861
- skey=$(dd bs=1 count=1 2>/dev/null < /dev/tty) || break
862
-
863
- if [ "$skey" = $'\033' ]; then
864
- local sbracket sdir
865
- sbracket=$(dd bs=1 count=1 2>/dev/null < /dev/tty) || true
866
- sdir=$(dd bs=1 count=1 2>/dev/null < /dev/tty) || true
867
- if [ "$sbracket" = "[" ]; then
868
- case "$sdir" in
869
- A) scursor=$(( scursor - 1 )); [ "$scursor" -lt 0 ] && scursor=0 ;;
870
- B) scursor=$(( scursor + 1 )) ;;
871
- esac
872
- else
873
- swap_cancelled=1
874
- swap_done=1
875
- fi
876
- elif [ "$skey" = " " ]; then
877
- local ft=()
878
- local lcs
879
- lcs=$(echo "$search" | tr '[:upper:]' '[:lower:]')
880
- for (( i = 0; i < ${#all_model_ids[@]}; i++ )); do
881
- if [ -z "$search" ]; then
882
- ft+=("$i")
883
- else
884
- local lci
885
- lci=$(echo "${all_model_ids[$i]}" | tr '[:upper:]' '[:lower:]')
886
- [[ "$lci" == *"$lcs"* ]] && ft+=("$i")
887
- fi
888
- done
889
- if [ "$scursor" -lt "${#ft[@]}" ]; then
890
- local tidx=${ft[$scursor]}
891
- [ "${sselected[$tidx]}" = "0" ] && sselected[$tidx]=1 || sselected[$tidx]=0
892
- fi
893
- elif [ "$skey" = "" ]; then
894
- swap_done=1
895
- elif [ "$skey" = $'\177' ] || [ "$skey" = $'\010' ]; then
896
- if [ -n "$search" ]; then
897
- search="${search%?}"
898
- scursor=0
899
- fi
900
- else
901
- if [[ "$skey" =~ [[:print:]] ]]; then
902
- search="${search}${skey}"
903
- scursor=0
904
- fi
905
- fi
906
-
907
- printf "\\033[%dA" "$swap_lines" >&2
908
- _swap_render
909
- done
910
-
911
- _swap_cleanup
912
- trap - INT TERM
913
- printf "\\n\\n" >&2
914
-
915
- if [ "$swap_cancelled" = "1" ]; then
782
+ if data.get('cancelled', False):
783
+ print('cancelled=1')
784
+ else:
785
+ print('cancelled=0')
786
+ names = []
787
+ models = []
788
+ for mid in data.get('gateway_models', []):
789
+ prov = mid.split('/')[0] if '/' in mid else 'unknown'
790
+ mname = mid.split('/', 1)[1] if '/' in mid else mid
791
+ slug = (prov + '-' + mname).lower().replace(' ', '-').replace('.', '-')
792
+ slug = ''.join(c for c in slug if c.isalnum() or c == '-') + '-gw'
793
+ names.append(slug)
794
+ models.append(mid)
795
+ print('new_gw_names=(' + ' '.join(shlex.quote(n) for n in names) + ')')
796
+ print('new_gw_models=(' + ' '.join(shlex.quote(m) for m in models) + ')')
797
+ " <<< "$swap_output")"
798
+
799
+ if [ "$cancelled" = "1" ]; then
916
800
  echo "Cancelled — no changes made."
917
801
  return 0
918
802
  fi
919
803
 
920
- # Collect selected models
921
- local new_gw_names=()
922
- local new_gw_models=()
923
- for (( i = 0; i < ${#sselected[@]}; i++ )); do
924
- [ "${sselected[$i]}" != "1" ] && continue
925
- local mid="${all_model_ids[$i]}"
926
- local prov="${mid%%/*}"
927
- local mname="${mid#*/}"
928
- local slug
929
- slug=$(echo "${prov}-${mname}" | tr '[:upper:]' '[:lower:]' | tr ' .' '-' | tr -cd 'a-z0-9-')
930
- slug="${slug}-gw"
931
- new_gw_names+=("$slug")
932
- new_gw_models+=("$mid")
933
- done
934
-
935
804
  local total_agents=$(( ${#cli_agents[@]} + ${#new_gw_names[@]} ))
936
805
  if [ "$total_agents" -lt 2 ]; then
937
806
  echo "ERROR: Need at least 2 agents total (${#cli_agents[@]} CLI + ${#new_gw_names[@]} gateway = $total_agents)." >&2
@@ -27,10 +27,21 @@ if (!MODEL) { process.stderr.write('ERROR: GATEWAY_MODEL not set\n'); process.ex
27
27
  const prompt = process.argv[2];
28
28
  if (!prompt) { process.stderr.write('ERROR: No prompt provided\n'); process.exit(1); }
29
29
 
30
+ // ---- Path safety ----
31
+
32
+ function safePath(p) {
33
+ const resolved = resolve(CWD, p);
34
+ if (resolved !== CWD && !resolved.startsWith(CWD + '/')) {
35
+ return null;
36
+ }
37
+ return resolved;
38
+ }
39
+
30
40
  // ---- Tool implementations ----
31
41
 
32
42
  function toolReadFile(args) {
33
- const filepath = resolve(CWD, args.path);
43
+ const filepath = safePath(args.path);
44
+ if (!filepath) return `Error: Path escapes project root: ${args.path}`;
34
45
  if (!existsSync(filepath)) return `Error: File not found: ${args.path}`;
35
46
  const content = readFileSync(filepath, 'utf8');
36
47
  const lines = content.split('\n');
@@ -41,14 +52,16 @@ function toolReadFile(args) {
41
52
  }
42
53
 
43
54
  function toolWriteFile(args) {
44
- const filepath = resolve(CWD, args.path);
55
+ const filepath = safePath(args.path);
56
+ if (!filepath) return `Error: Path escapes project root: ${args.path}`;
45
57
  mkdirSync(dirname(filepath), { recursive: true });
46
58
  writeFileSync(filepath, args.content, 'utf8');
47
59
  return `Written ${args.content.length} bytes to ${args.path}`;
48
60
  }
49
61
 
50
62
  function toolAppendFile(args) {
51
- const filepath = resolve(CWD, args.path);
63
+ const filepath = safePath(args.path);
64
+ if (!filepath) return `Error: Path escapes project root: ${args.path}`;
52
65
  mkdirSync(dirname(filepath), { recursive: true });
53
66
  const existing = existsSync(filepath) ? readFileSync(filepath, 'utf8') : '';
54
67
  writeFileSync(filepath, existing + args.content, 'utf8');
@@ -56,7 +69,8 @@ function toolAppendFile(args) {
56
69
  }
57
70
 
58
71
  function toolEditFile(args) {
59
- const filepath = resolve(CWD, args.path);
72
+ const filepath = safePath(args.path);
73
+ if (!filepath) return `Error: Path escapes project root: ${args.path}`;
60
74
  if (!existsSync(filepath)) return `Error: File not found: ${args.path}`;
61
75
  const content = readFileSync(filepath, 'utf8');
62
76
  const count = content.split(args.old_string).length - 1;
@@ -90,7 +104,8 @@ function toolRunCommand(args) {
90
104
  }
91
105
 
92
106
  function toolListDirectory(args) {
93
- const dirpath = resolve(CWD, args.path || '.');
107
+ const dirpath = safePath(args.path || '.');
108
+ if (!dirpath) return `Error: Path escapes project root: ${args.path}`;
94
109
  if (!existsSync(dirpath)) return `Error: Directory not found: ${args.path}`;
95
110
  const entries = readdirSync(dirpath, { withFileTypes: true });
96
111
  return entries.map(e => {
@@ -100,7 +115,8 @@ function toolListDirectory(args) {
100
115
  }
101
116
 
102
117
  function toolGlob(args) {
103
- const base = resolve(CWD, args.path || '.');
118
+ const base = safePath(args.path || '.');
119
+ if (!base) return `Error: Path escapes project root: ${args.path}`;
104
120
  const regex = globToRegex(args.pattern);
105
121
  const matches = [];
106
122
  walkDir(base, base, regex, matches, 0);
@@ -445,6 +461,42 @@ function chatCompletion(messages) {
445
461
  res.on('end', () => {
446
462
  if (idleTimer) clearTimeout(idleTimer);
447
463
 
464
+ // Process any remaining data in buffer (API may not send trailing newline)
465
+ if (buffer.trim()) {
466
+ const trimmed = buffer.trim();
467
+ if (trimmed.startsWith('data: ')) {
468
+ const data = trimmed.slice(6);
469
+ if (data !== '[DONE]') {
470
+ try {
471
+ const parsed = JSON.parse(data);
472
+ if (parsed.usage) usage = parsed.usage;
473
+ const choice = parsed.choices && parsed.choices[0];
474
+ if (choice && choice.delta) {
475
+ if (choice.delta.content) {
476
+ content += choice.delta.content;
477
+ process.stdout.write(choice.delta.content);
478
+ }
479
+ if (choice.delta.tool_calls) {
480
+ for (const tc of choice.delta.tool_calls) {
481
+ const idx = tc.index;
482
+ if (!toolCalls.has(idx)) {
483
+ toolCalls.set(idx, { id: tc.id || '', name: '', arguments: '' });
484
+ }
485
+ const entry = toolCalls.get(idx);
486
+ if (tc.id) entry.id = tc.id;
487
+ if (tc.function) {
488
+ if (tc.function.name) entry.name = tc.function.name;
489
+ if (tc.function.arguments) entry.arguments += tc.function.arguments;
490
+ }
491
+ }
492
+ }
493
+ }
494
+ } catch { /* malformed final chunk — skip */ }
495
+ }
496
+ }
497
+ buffer = '';
498
+ }
499
+
448
500
  // Convert Map to sorted array
449
501
  const sortedTools = [];
450
502
  const indices = Array.from(toolCalls.keys()).sort((a, b) => a - b);