ax-agents 0.1.0 → 0.1.2

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/ax.js +124 -20
  2. package/package.json +1 -1
package/ax.js CHANGED
@@ -131,7 +131,7 @@ const VERSION = packageJson.version;
131
131
  /**
132
132
  * @typedef {{matcher: string, hooks: Array<{type: string, command: string, timeout?: number}>}} ClaudeHookEntry
133
133
  * @typedef {Object} ClaudeSettings
134
- * @property {{UserPromptSubmit?: ClaudeHookEntry[], PostToolUse?: ClaudeHookEntry[], Stop?: ClaudeHookEntry[], [key: string]: ClaudeHookEntry[] | undefined}} [hooks]
134
+ * @property {{UserPromptSubmit?: ClaudeHookEntry[], PreToolUse?: ClaudeHookEntry[], Stop?: ClaudeHookEntry[], [key: string]: ClaudeHookEntry[] | undefined}} [hooks]
135
135
  */
136
136
 
137
137
  const DEBUG = process.env.AX_DEBUG === "1";
@@ -977,11 +977,12 @@ function tailJsonl(logPath, fromOffset) {
977
977
  */
978
978
 
979
979
  /**
980
- * Format a JSONL entry for streaming display.
980
+ * Format a Claude Code JSONL log entry for streaming display.
981
+ * Claude format: {type: "assistant", message: {content: [...]}}
981
982
  * @param {{type?: string, message?: {content?: Array<{type?: string, text?: string, name?: string, input?: ToolInput, tool?: string, arguments?: ToolInput}>}}} entry
982
983
  * @returns {string | null}
983
984
  */
984
- function formatEntry(entry) {
985
+ function formatClaudeLogEntry(entry) {
985
986
  // Skip tool_result entries (they can be very verbose)
986
987
  if (entry.type === "tool_result") return null;
987
988
 
@@ -1012,6 +1013,59 @@ function formatEntry(entry) {
1012
1013
  return output.length > 0 ? output.join("\n") : null;
1013
1014
  }
1014
1015
 
1016
+ /**
1017
+ * Format a Codex JSONL log entry for streaming display.
1018
+ * Codex format:
1019
+ * - {type: "response_item", payload: {type: "message", role: "assistant", content: [{type: "output_text", text: "..."}]}}
1020
+ * - {type: "response_item", payload: {type: "function_call", name: "...", arguments: "{...}"}}
1021
+ * - {type: "event_msg", payload: {type: "agent_message", message: "..."}}
1022
+ * @param {{type?: string, payload?: {type?: string, role?: string, name?: string, arguments?: string, message?: string, content?: Array<{type?: string, text?: string}>}}} entry
1023
+ * @returns {string | null}
1024
+ */
1025
+ function formatCodexLogEntry(entry) {
1026
+ // Skip function_call_output entries (equivalent to tool_result - can be verbose)
1027
+ if (entry.type === "response_item" && entry.payload?.type === "function_call_output") {
1028
+ return null;
1029
+ }
1030
+
1031
+ // Handle function calls
1032
+ if (entry.type === "response_item" && entry.payload?.type === "function_call") {
1033
+ const name = entry.payload.name || "tool";
1034
+ let summary = "";
1035
+ try {
1036
+ const args = JSON.parse(entry.payload.arguments || "{}");
1037
+ if (name === "shell_command" && args.command) {
1038
+ summary = args.command.slice(0, 50);
1039
+ } else {
1040
+ const target = args.file_path || args.path || args.pattern || "";
1041
+ summary = target.split("/").pop() || target.slice(0, 30);
1042
+ }
1043
+ } catch {
1044
+ summary = "...";
1045
+ }
1046
+ return `> ${name}(${summary})`;
1047
+ }
1048
+
1049
+ // Handle assistant messages (final response)
1050
+ if (entry.type === "response_item" && entry.payload?.role === "assistant") {
1051
+ const parts = entry.payload.content || [];
1052
+ const output = [];
1053
+ for (const part of parts) {
1054
+ if ((part.type === "output_text" || part.type === "text") && part.text) {
1055
+ output.push(part.text);
1056
+ }
1057
+ }
1058
+ return output.length > 0 ? output.join("\n") : null;
1059
+ }
1060
+
1061
+ // Handle streaming agent messages
1062
+ if (entry.type === "event_msg" && entry.payload?.type === "agent_message") {
1063
+ return entry.payload.message || null;
1064
+ }
1065
+
1066
+ return null;
1067
+ }
1068
+
1015
1069
  /**
1016
1070
  * Extract pending tool from confirmation screen.
1017
1071
  * @param {string} screen
@@ -2027,19 +2081,21 @@ function detectState(screen, config) {
2027
2081
  }
2028
2082
  }
2029
2083
 
2084
+ // Check for active work patterns first (agent shows prompt even while working)
2085
+ const activeWorkPatterns = config.activeWorkPatterns || [];
2086
+ if (
2087
+ activeWorkPatterns.some((p) => {
2088
+ if (p instanceof RegExp) return p.test(lastLines);
2089
+ return lastLines.includes(p);
2090
+ })
2091
+ ) {
2092
+ return State.THINKING;
2093
+ }
2094
+
2030
2095
  // Ready - check BEFORE thinking to avoid false positives from timing messages like "✻ Worked for 45s"
2031
2096
  // If the prompt symbol is visible, the agent is ready regardless of spinner characters in timing messages
2032
2097
  if (lastLines.includes(config.promptSymbol)) {
2033
- // Check if any line has the prompt followed by pasted content indicator
2034
- // "[Pasted text" indicates user has pasted content and Claude is still processing
2035
- const linesArray = lastLines.split("\n");
2036
- const promptWithPaste = linesArray.some(
2037
- (l) => l.includes(config.promptSymbol) && l.includes("[Pasted text"),
2038
- );
2039
- if (!promptWithPaste) {
2040
- return State.READY;
2041
- }
2042
- // If prompt has pasted content, Claude is still processing - not ready yet
2098
+ return State.READY;
2043
2099
  }
2044
2100
 
2045
2101
  // Thinking - spinners (check last lines only)
@@ -2094,6 +2150,7 @@ function detectState(screen, config) {
2094
2150
  * @property {string[]} [spinners]
2095
2151
  * @property {RegExp} [rateLimitPattern]
2096
2152
  * @property {(string | RegExp | ((lines: string) => boolean))[]} [thinkingPatterns]
2153
+ * @property {(string | RegExp)[]} [activeWorkPatterns]
2097
2154
  * @property {ConfirmPattern[]} [confirmPatterns]
2098
2155
  * @property {UpdatePromptPatterns | null} [updatePromptPatterns]
2099
2156
  * @property {string[]} [responseMarkers]
@@ -2105,6 +2162,7 @@ function detectState(screen, config) {
2105
2162
  * @property {string} [safeAllowedTools]
2106
2163
  * @property {string | null} [sessionIdFlag]
2107
2164
  * @property {((sessionName: string) => string | null) | null} [logPathFinder]
2165
+ * @property {((entry: object) => string | null) | null} [logEntryFormatter]
2108
2166
  */
2109
2167
 
2110
2168
  class Agent {
@@ -2128,6 +2186,8 @@ class Agent {
2128
2186
  this.rateLimitPattern = config.rateLimitPattern;
2129
2187
  /** @type {(string | RegExp | ((lines: string) => boolean))[]} */
2130
2188
  this.thinkingPatterns = config.thinkingPatterns || [];
2189
+ /** @type {(string | RegExp)[]} */
2190
+ this.activeWorkPatterns = config.activeWorkPatterns || [];
2131
2191
  /** @type {ConfirmPattern[]} */
2132
2192
  this.confirmPatterns = config.confirmPatterns || [];
2133
2193
  /** @type {UpdatePromptPatterns | null} */
@@ -2150,6 +2210,8 @@ class Agent {
2150
2210
  this.sessionIdFlag = config.sessionIdFlag || null;
2151
2211
  /** @type {((sessionName: string) => string | null) | null} */
2152
2212
  this.logPathFinder = config.logPathFinder || null;
2213
+ /** @type {((entry: object) => string | null) | null} */
2214
+ this.logEntryFormatter = config.logEntryFormatter || null;
2153
2215
  }
2154
2216
 
2155
2217
  /**
@@ -2289,6 +2351,18 @@ class Agent {
2289
2351
  return null;
2290
2352
  }
2291
2353
 
2354
+ /**
2355
+ * Format a log entry for streaming display.
2356
+ * @param {object} entry
2357
+ * @returns {string | null}
2358
+ */
2359
+ formatLogEntry(entry) {
2360
+ if (this.logEntryFormatter) {
2361
+ return this.logEntryFormatter(entry);
2362
+ }
2363
+ return null;
2364
+ }
2365
+
2292
2366
  /**
2293
2367
  * @param {string} screen
2294
2368
  * @returns {string}
@@ -2299,6 +2373,7 @@ class Agent {
2299
2373
  spinners: this.spinners,
2300
2374
  rateLimitPattern: this.rateLimitPattern,
2301
2375
  thinkingPatterns: this.thinkingPatterns,
2376
+ activeWorkPatterns: this.activeWorkPatterns,
2302
2377
  confirmPatterns: this.confirmPatterns,
2303
2378
  updatePromptPatterns: this.updatePromptPatterns,
2304
2379
  });
@@ -2528,11 +2603,13 @@ const CodexAgent = new Agent({
2528
2603
  screen: ["Update available"],
2529
2604
  lastLines: ["Skip"],
2530
2605
  },
2606
+ activeWorkPatterns: ["esc to interrupt"],
2531
2607
  responseMarkers: ["•", "- ", "**"],
2532
2608
  chromePatterns: ["context left", "for shortcuts"],
2533
2609
  reviewOptions: { pr: "1", uncommitted: "2", commit: "3", custom: "4" },
2534
2610
  envVar: "AX_SESSION",
2535
2611
  logPathFinder: findCodexLogPath,
2612
+ logEntryFormatter: formatCodexLogEntry,
2536
2613
  });
2537
2614
 
2538
2615
  // =============================================================================
@@ -2550,6 +2627,7 @@ const ClaudeAgent = new Agent({
2550
2627
  rateLimitPattern: /rate.?limit/i,
2551
2628
  // Claude uses whimsical verbs like "Wibbling…", "Dancing…", etc. Match any capitalized -ing word + ellipsis (… or ...)
2552
2629
  thinkingPatterns: ["Thinking", /[A-Z][a-z]+ing(…|\.\.\.)/],
2630
+ activeWorkPatterns: ["[Pasted text"],
2553
2631
  confirmPatterns: [
2554
2632
  "Do you want to make this edit",
2555
2633
  "Do you want to run this command",
@@ -2581,6 +2659,7 @@ const ClaudeAgent = new Agent({
2581
2659
  if (uuid) return findClaudeLogPath(uuid, sessionName);
2582
2660
  return null;
2583
2661
  },
2662
+ logEntryFormatter: formatClaudeLogEntry,
2584
2663
  });
2585
2664
 
2586
2665
  // =============================================================================
@@ -2718,20 +2797,33 @@ async function streamResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2718
2797
  let logPath = agent.findLogPath(session);
2719
2798
  let logOffset = logPath && existsSync(logPath) ? statSync(logPath).size : 0;
2720
2799
  let printedThinking = false;
2800
+ /** @type {string | null} Track last assistant message to dedupe final response */
2801
+ let lastAssistantMessage = null;
2721
2802
 
2722
2803
  const streamNewEntries = () => {
2723
2804
  if (!logPath) {
2724
2805
  logPath = agent.findLogPath(session);
2725
2806
  if (logPath && existsSync(logPath)) {
2726
- logOffset = statSync(logPath).size;
2807
+ // Read from beginning when file is first discovered
2808
+ // (Claude creates log file when first message is sent)
2809
+ logOffset = 0;
2727
2810
  }
2728
2811
  }
2729
2812
  if (logPath) {
2730
2813
  const { entries, newOffset } = tailJsonl(logPath, logOffset);
2731
2814
  logOffset = newOffset;
2732
2815
  for (const entry of entries) {
2733
- const formatted = formatEntry(entry);
2734
- if (formatted) console.log(formatted);
2816
+ const formatted = agent.formatLogEntry(entry);
2817
+ if (!formatted) continue;
2818
+
2819
+ // Dedupe assistant messages (streaming agent_message vs final response_item)
2820
+ // Tool calls (starting with ">") are always printed
2821
+ if (!formatted.startsWith(">")) {
2822
+ if (formatted === lastAssistantMessage) continue;
2823
+ lastAssistantMessage = formatted;
2824
+ }
2825
+
2826
+ console.log(formatted);
2735
2827
  }
2736
2828
  }
2737
2829
  };
@@ -3420,7 +3512,7 @@ async function cmdRecall(name = null) {
3420
3512
  }
3421
3513
 
3422
3514
  // Version of the hook script template - bump when making changes
3423
- const HOOK_SCRIPT_VERSION = "4";
3515
+ const HOOK_SCRIPT_VERSION = "5";
3424
3516
 
3425
3517
  function ensureMailboxHookScript() {
3426
3518
  const hooksDir = HOOKS_DIR;
@@ -3539,8 +3631,16 @@ if (relevant.length > 0) {
3539
3631
  // For Stop hook, return blocking JSON to force acknowledgment
3540
3632
  if (hookEvent === "Stop") {
3541
3633
  console.log(JSON.stringify({ decision: "block", reason: formattedMessage }));
3634
+ } else if (hookEvent === "PreToolUse") {
3635
+ // For PreToolUse, use JSON with hookSpecificOutput to inject into Claude's context
3636
+ console.log(JSON.stringify({
3637
+ hookSpecificOutput: {
3638
+ hookEventName: "PreToolUse",
3639
+ additionalContext: formattedMessage
3640
+ }
3641
+ }));
3542
3642
  } else {
3543
- // For other hooks, just output the context
3643
+ // For UserPromptSubmit, plain text stdout is automatically added to context
3544
3644
  console.log(formattedMessage);
3545
3645
  }
3546
3646
 
@@ -3566,7 +3666,7 @@ process.exit(0);
3566
3666
  console.log(`{
3567
3667
  "hooks": {
3568
3668
  "UserPromptSubmit": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node .ai/hooks/mailbox-inject.js", "timeout": 5 }] }],
3569
- "PostToolUse": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node .ai/hooks/mailbox-inject.js", "timeout": 5 }] }],
3669
+ "PreToolUse": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node .ai/hooks/mailbox-inject.js", "timeout": 5 }] }],
3570
3670
  "Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node .ai/hooks/mailbox-inject.js", "timeout": 5 }] }]
3571
3671
  }
3572
3672
  }`);
@@ -3577,7 +3677,7 @@ function ensureClaudeHookConfig() {
3577
3677
  const settingsDir = ".claude";
3578
3678
  const settingsPath = path.join(settingsDir, "settings.json");
3579
3679
  const hookCommand = "node .ai/hooks/mailbox-inject.js";
3580
- const hookEvents = ["UserPromptSubmit", "PostToolUse", "Stop"];
3680
+ const hookEvents = ["UserPromptSubmit", "PreToolUse", "Stop"];
3581
3681
 
3582
3682
  try {
3583
3683
  /** @type {ClaudeSettings} */
@@ -5031,4 +5131,8 @@ export {
5031
5131
  State,
5032
5132
  normalizeAllowedTools,
5033
5133
  computePermissionHash,
5134
+ formatClaudeLogEntry,
5135
+ formatCodexLogEntry,
5136
+ CodexAgent,
5137
+ ClaudeAgent,
5034
5138
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ax-agents",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A CLI for orchestrating AI coding agents via tmux",
5
5
  "bin": {
6
6
  "ax": "ax.js",