ax-agents 0.0.1-alpha.7 → 0.0.1-alpha.9

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 +218 -48
  2. package/package.json +1 -1
package/ax.js CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  readSync,
28
28
  closeSync,
29
29
  } from "node:fs";
30
- import { randomUUID } from "node:crypto";
30
+ import { randomUUID, createHash } from "node:crypto";
31
31
  import { fileURLToPath } from "node:url";
32
32
  import path from "node:path";
33
33
  import os from "node:os";
@@ -305,6 +305,18 @@ const TRUNCATE_THINKING_LEN = 300;
305
305
  const ARCHANGEL_GIT_CONTEXT_HOURS = 4;
306
306
  const ARCHANGEL_GIT_CONTEXT_MAX_LINES = 200;
307
307
  const ARCHANGEL_PARENT_CONTEXT_ENTRIES = 10;
308
+ const ARCHANGEL_PREAMBLE = `## Guidelines
309
+
310
+ - Investigate before speaking. If uncertain, read more code and trace the logic until you're confident.
311
+ - Explain WHY something is an issue, not just that it is.
312
+ - Focus on your area of expertise.
313
+ - Calibrate to the task or plan. Don't suggest refactors during a bug fix.
314
+ - Be clear. Brief is fine, but never sacrifice clarity.
315
+ - For critical issues, request for them to be added to the todo list.
316
+ - Don't repeat observations you've already made unless you have more to say or better clarity.
317
+ - Make judgment calls - don't ask questions.
318
+
319
+ "No issues found." is a valid response when there's nothing significant to report.`;
308
320
 
309
321
  /**
310
322
  * @param {string} session
@@ -544,6 +556,16 @@ function generateSessionName(tool) {
544
556
  return `${tool}-partner-${randomUUID()}`;
545
557
  }
546
558
 
559
+ /**
560
+ * Quick hash for change detection (not cryptographic).
561
+ * @param {string | null | undefined} str
562
+ * @returns {string | null}
563
+ */
564
+ function quickHash(str) {
565
+ if (!str) return null;
566
+ return createHash("md5").update(str).digest("hex").slice(0, 8);
567
+ }
568
+
547
569
  /**
548
570
  * @param {string} cwd
549
571
  * @returns {string}
@@ -671,6 +693,93 @@ function findCodexLogPath(sessionName) {
671
693
  }
672
694
  }
673
695
 
696
+ /**
697
+ * @typedef {Object} SessionMeta
698
+ * @property {string | null} slug - Plan identifier (if plan is active)
699
+ * @property {Array<{content: string, status: string, id?: string}> | null} todos - Current todos
700
+ * @property {string | null} permissionMode - "default", "acceptEdits", "plan"
701
+ * @property {string | null} gitBranch - Current git branch
702
+ * @property {string | null} cwd - Working directory
703
+ */
704
+
705
+ /**
706
+ * Get metadata from a Claude session's JSONL file.
707
+ * Returns null for Codex sessions (different format, no equivalent metadata).
708
+ * @param {string} sessionName - The tmux session name
709
+ * @returns {SessionMeta | null}
710
+ */
711
+ function getSessionMeta(sessionName) {
712
+ const parsed = parseSessionName(sessionName);
713
+ if (!parsed) return null;
714
+
715
+ // Only Claude sessions have this metadata
716
+ if (parsed.tool !== "claude") return null;
717
+ if (!parsed.uuid) return null;
718
+
719
+ const logPath = findClaudeLogPath(parsed.uuid, sessionName);
720
+ if (!logPath || !existsSync(logPath)) return null;
721
+
722
+ try {
723
+ const content = readFileSync(logPath, "utf-8");
724
+ const lines = content.trim().split("\n").filter(Boolean);
725
+
726
+ // Read from end to find most recent entry with metadata
727
+ for (let i = lines.length - 1; i >= 0; i--) {
728
+ try {
729
+ const entry = JSON.parse(lines[i]);
730
+ // User entries typically have the metadata fields
731
+ if (entry.type === "user" || entry.slug || entry.gitBranch) {
732
+ return {
733
+ slug: entry.slug || null,
734
+ todos: entry.todos || null,
735
+ permissionMode: entry.permissionMode || null,
736
+ gitBranch: entry.gitBranch || null,
737
+ cwd: entry.cwd || null,
738
+ };
739
+ }
740
+ } catch {
741
+ // Skip malformed lines
742
+ }
743
+ }
744
+ return null;
745
+ } catch (err) {
746
+ debugError("getSessionMeta", err);
747
+ return null;
748
+ }
749
+ }
750
+
751
+ /**
752
+ * Read a plan file by its slug.
753
+ * @param {string} slug - The plan slug (e.g., "curious-roaming-pascal")
754
+ * @returns {string | null} The plan content or null if not found
755
+ */
756
+ function readPlanFile(slug) {
757
+ const planPath = path.join(CLAUDE_CONFIG_DIR, "plans", `${slug}.md`);
758
+ try {
759
+ if (existsSync(planPath)) {
760
+ return readFileSync(planPath, "utf-8");
761
+ }
762
+ } catch (err) {
763
+ debugError("readPlanFile", err);
764
+ }
765
+ return null;
766
+ }
767
+
768
+ /**
769
+ * Format todos for display in a prompt.
770
+ * @param {Array<{content: string, status: string, id?: string}>} todos
771
+ * @returns {string}
772
+ */
773
+ function formatTodos(todos) {
774
+ if (!todos || todos.length === 0) return "";
775
+ return todos
776
+ .map((t) => {
777
+ const status = t.status === "completed" ? "[x]" : t.status === "in_progress" ? "[>]" : "[ ]";
778
+ return `${status} ${t.content || "(no content)"}`;
779
+ })
780
+ .join("\n");
781
+ }
782
+
674
783
  /**
675
784
  * Extract assistant text responses from a JSONL log file.
676
785
  * This provides clean text without screen-scraped artifacts.
@@ -1725,8 +1834,8 @@ function detectState(screen, config) {
1725
1834
  // Larger range for confirmation detection (catches dialogs that scrolled slightly)
1726
1835
  const recentLines = lines.slice(-15).join("\n");
1727
1836
 
1728
- // Rate limited - check full screen (rate limit messages can appear anywhere)
1729
- if (config.rateLimitPattern && config.rateLimitPattern.test(screen)) {
1837
+ // Rate limited - check recent lines (not full screen to avoid matching historical output)
1838
+ if (config.rateLimitPattern && config.rateLimitPattern.test(recentLines)) {
1730
1839
  return State.RATE_LIMITED;
1731
1840
  }
1732
1841
 
@@ -1743,7 +1852,22 @@ function detectState(screen, config) {
1743
1852
  }
1744
1853
  }
1745
1854
 
1746
- // Thinking - spinners (check last lines only to avoid false positives from timing messages like "✻ Crunched for 32s")
1855
+ // Ready - check BEFORE thinking to avoid false positives from timing messages like "✻ Worked for 45s"
1856
+ // If the prompt symbol is visible, the agent is ready regardless of spinner characters in timing messages
1857
+ if (lastLines.includes(config.promptSymbol)) {
1858
+ // Check if any line has the prompt followed by pasted content indicator
1859
+ // "[Pasted text" indicates user has pasted content and Claude is still processing
1860
+ const linesArray = lastLines.split("\n");
1861
+ const promptWithPaste = linesArray.some(
1862
+ (l) => l.includes(config.promptSymbol) && l.includes("[Pasted text"),
1863
+ );
1864
+ if (!promptWithPaste) {
1865
+ return State.READY;
1866
+ }
1867
+ // If prompt has pasted content, Claude is still processing - not ready yet
1868
+ }
1869
+
1870
+ // Thinking - spinners (check last lines only)
1747
1871
  const spinners = config.spinners || [];
1748
1872
  if (spinners.some((s) => lastLines.includes(s))) {
1749
1873
  return State.THINKING;
@@ -1768,20 +1892,6 @@ function detectState(screen, config) {
1768
1892
  }
1769
1893
  }
1770
1894
 
1771
- // Ready - only if prompt symbol is visible AND not followed by pasted content
1772
- // "[Pasted text" indicates user has pasted content and Claude is still processing
1773
- if (lastLines.includes(config.promptSymbol)) {
1774
- // Check if any line has the prompt followed by pasted content indicator
1775
- const linesArray = lastLines.split("\n");
1776
- const promptWithPaste = linesArray.some(
1777
- (l) => l.includes(config.promptSymbol) && l.includes("[Pasted text"),
1778
- );
1779
- if (!promptWithPaste) {
1780
- return State.READY;
1781
- }
1782
- // If prompt has pasted content, Claude is still processing - not ready yet
1783
- }
1784
-
1785
1895
  return State.STARTING;
1786
1896
  }
1787
1897
 
@@ -2508,19 +2618,22 @@ function cmdAgents() {
2508
2618
  const agent = parsed.tool === "claude" ? ClaudeAgent : CodexAgent;
2509
2619
  const screen = tmuxCapture(session);
2510
2620
  const state = agent.getState(screen);
2511
- const logPath = agent.findLogPath(session);
2512
2621
  const type = parsed.archangelName ? "archangel" : "-";
2513
2622
  const isDefault =
2514
2623
  (parsed.tool === "claude" && session === claudeDefault) ||
2515
2624
  (parsed.tool === "codex" && session === codexDefault);
2516
2625
 
2626
+ // Get session metadata (Claude only)
2627
+ const meta = getSessionMeta(session);
2628
+
2517
2629
  return {
2518
2630
  session,
2519
2631
  tool: parsed.tool,
2520
2632
  state: state || "unknown",
2521
2633
  target: isDefault ? "*" : "",
2522
2634
  type,
2523
- log: logPath || "-",
2635
+ plan: meta?.slug || "-",
2636
+ branch: meta?.gitBranch || "-",
2524
2637
  };
2525
2638
  });
2526
2639
 
@@ -2530,13 +2643,14 @@ function cmdAgents() {
2530
2643
  const maxState = Math.max(5, ...agents.map((a) => a.state.length));
2531
2644
  const maxTarget = Math.max(6, ...agents.map((a) => a.target.length));
2532
2645
  const maxType = Math.max(4, ...agents.map((a) => a.type.length));
2646
+ const maxPlan = Math.max(4, ...agents.map((a) => a.plan.length));
2533
2647
 
2534
2648
  console.log(
2535
- `${"SESSION".padEnd(maxSession)} ${"TOOL".padEnd(maxTool)} ${"STATE".padEnd(maxState)} ${"TARGET".padEnd(maxTarget)} ${"TYPE".padEnd(maxType)} LOG`,
2649
+ `${"SESSION".padEnd(maxSession)} ${"TOOL".padEnd(maxTool)} ${"STATE".padEnd(maxState)} ${"TARGET".padEnd(maxTarget)} ${"TYPE".padEnd(maxType)} ${"PLAN".padEnd(maxPlan)} BRANCH`,
2536
2650
  );
2537
2651
  for (const a of agents) {
2538
2652
  console.log(
2539
- `${a.session.padEnd(maxSession)} ${a.tool.padEnd(maxTool)} ${a.state.padEnd(maxState)} ${a.target.padEnd(maxTarget)} ${a.type.padEnd(maxType)} ${a.log}`,
2653
+ `${a.session.padEnd(maxSession)} ${a.tool.padEnd(maxTool)} ${a.state.padEnd(maxState)} ${a.target.padEnd(maxTarget)} ${a.type.padEnd(maxType)} ${a.plan.padEnd(maxPlan)} ${a.branch}`,
2540
2654
  );
2541
2655
  }
2542
2656
 
@@ -2685,6 +2799,13 @@ async function cmdArchangel(agentName) {
2685
2799
  let isProcessing = false;
2686
2800
  const intervalMs = config.interval * 1000;
2687
2801
 
2802
+ // Hash tracking for incremental context updates
2803
+ /** @type {string | null} */
2804
+ let lastPlanHash = null;
2805
+ /** @type {string | null} */
2806
+ let lastTodosHash = null;
2807
+ let isFirstTrigger = true;
2808
+
2688
2809
  async function processChanges() {
2689
2810
  clearTimeout(debounceTimer);
2690
2811
  clearTimeout(maxWaitTimer);
@@ -2702,6 +2823,21 @@ async function cmdArchangel(agentName) {
2702
2823
  const parent = findParentSession();
2703
2824
  const logPath = parent ? findClaudeLogPath(parent.uuid, parent.session) : null;
2704
2825
 
2826
+ // Get orientation context (plan and todos) from parent session
2827
+ const meta = parent?.session ? getSessionMeta(parent.session) : null;
2828
+ const planContent = meta?.slug ? readPlanFile(meta.slug) : null;
2829
+ const todosContent = meta?.todos?.length ? formatTodos(meta.todos) : null;
2830
+
2831
+ // Check if plan/todos have changed since last trigger
2832
+ const planHash = quickHash(planContent);
2833
+ const todosHash = quickHash(todosContent);
2834
+ const includePlan = planHash !== lastPlanHash;
2835
+ const includeTodos = todosHash !== lastTodosHash;
2836
+
2837
+ // Update tracking for next trigger
2838
+ lastPlanHash = planHash;
2839
+ lastTodosHash = todosHash;
2840
+
2705
2841
  // Build file-specific context from JSONL
2706
2842
  const fileContexts = [];
2707
2843
  for (const file of files.slice(0, 5)) {
@@ -2713,7 +2849,18 @@ async function cmdArchangel(agentName) {
2713
2849
  }
2714
2850
 
2715
2851
  // Build the prompt
2716
- let prompt = basePrompt;
2852
+ // First trigger: include intro, guidelines, and focus (archangel has memory)
2853
+ let prompt = isFirstTrigger
2854
+ ? `You are the archangel of ${agentName}.\n\n${ARCHANGEL_PREAMBLE}\n\n## Focus\n\n${basePrompt}\n\n---`
2855
+ : "";
2856
+
2857
+ // Add orientation context (plan and todos) only if changed since last trigger
2858
+ if (includePlan && planContent) {
2859
+ prompt += (prompt ? "\n\n" : "") + "## Current Plan\n\n" + planContent;
2860
+ }
2861
+ if (includeTodos && todosContent) {
2862
+ prompt += (prompt ? "\n\n" : "") + "## Current Todos\n\n" + todosContent;
2863
+ }
2717
2864
 
2718
2865
  if (fileContexts.length > 0) {
2719
2866
  prompt += "\n\n## Recent Edits (from parent session)\n";
@@ -2747,8 +2894,7 @@ async function cmdArchangel(agentName) {
2747
2894
  prompt += "\n\n## Git Context\n\n" + gitContext;
2748
2895
  }
2749
2896
 
2750
- prompt +=
2751
- '\n\nReview these changes in the context of what the user is working on. Report any issues found. Keep your response concise.\nIf there are no significant issues, respond with just "No issues found."';
2897
+ prompt += "\n\nReview these changes.";
2752
2898
  } else {
2753
2899
  // Fallback: no JSONL context available, use conversation + git context
2754
2900
  const parentContext = getParentSessionContext(ARCHANGEL_PARENT_CONTEXT_ENTRIES);
@@ -2768,8 +2914,7 @@ async function cmdArchangel(agentName) {
2768
2914
  prompt += "\n\n## Git Context\n\n" + gitContext;
2769
2915
  }
2770
2916
 
2771
- prompt +=
2772
- '\n\nReview these changes in the context of what the user is working on. Report any issues found. Keep your response concise.\nIf there are no significant issues, respond with just "No issues found."';
2917
+ prompt += "\n\nReview these changes.";
2773
2918
  }
2774
2919
 
2775
2920
  // Check session still exists
@@ -2798,6 +2943,7 @@ async function cmdArchangel(agentName) {
2798
2943
  await sleep(200); // Allow time for large prompts to be processed
2799
2944
  tmuxSend(sessionName, "Enter");
2800
2945
  await sleep(100); // Ensure Enter is processed
2946
+ isFirstTrigger = false;
2801
2947
 
2802
2948
  // Wait for response
2803
2949
  const { state: endState, screen: afterScreen } = await waitForResponse(
@@ -4016,20 +4162,43 @@ async function cmdSelect(agent, session, n, { wait = false, timeoutMs } = {}) {
4016
4162
  // =============================================================================
4017
4163
 
4018
4164
  /**
4019
- * @returns {Agent}
4165
+ * Resolve the agent to use based on (in priority order):
4166
+ * 1. Explicit --tool flag
4167
+ * 2. Session name (e.g., "claude-archangel-..." → ClaudeAgent)
4168
+ * 3. CLI invocation name (axclaude, axcodex)
4169
+ * 4. AX_DEFAULT_TOOL environment variable
4170
+ * 5. Default to CodexAgent
4171
+ *
4172
+ * @param {{toolFlag?: string, sessionName?: string | null}} options
4173
+ * @returns {{agent: Agent, error?: string}}
4020
4174
  */
4021
- function getAgentFromInvocation() {
4175
+ function resolveAgent({ toolFlag, sessionName } = {}) {
4176
+ // 1. Explicit --tool flag takes highest priority
4177
+ if (toolFlag) {
4178
+ if (toolFlag === "claude") return { agent: ClaudeAgent };
4179
+ if (toolFlag === "codex") return { agent: CodexAgent };
4180
+ return { agent: CodexAgent, error: `unknown tool '${toolFlag}'` };
4181
+ }
4182
+
4183
+ // 2. Infer from session name (e.g., "claude-archangel-..." or "codex-partner-...")
4184
+ if (sessionName) {
4185
+ const parsed = parseSessionName(sessionName);
4186
+ if (parsed?.tool === "claude") return { agent: ClaudeAgent };
4187
+ if (parsed?.tool === "codex") return { agent: CodexAgent };
4188
+ }
4189
+
4190
+ // 3. CLI invocation name
4022
4191
  const invoked = path.basename(process.argv[1], ".js");
4023
- if (invoked === "axclaude" || invoked === "claude") return ClaudeAgent;
4024
- if (invoked === "axcodex" || invoked === "codex") return CodexAgent;
4192
+ if (invoked === "axclaude" || invoked === "claude") return { agent: ClaudeAgent };
4193
+ if (invoked === "axcodex" || invoked === "codex") return { agent: CodexAgent };
4025
4194
 
4026
- // Default based on AX_DEFAULT_TOOL env var, or codex if not set
4195
+ // 4. AX_DEFAULT_TOOL environment variable
4027
4196
  const defaultTool = process.env.AX_DEFAULT_TOOL;
4028
- if (defaultTool === "claude") return ClaudeAgent;
4029
- if (defaultTool === "codex" || !defaultTool) return CodexAgent;
4197
+ if (defaultTool === "claude") return { agent: ClaudeAgent };
4198
+ if (defaultTool === "codex" || !defaultTool) return { agent: CodexAgent };
4030
4199
 
4031
4200
  console.error(`WARNING: invalid AX_DEFAULT_TOOL="${defaultTool}", using codex`);
4032
- return CodexAgent;
4201
+ return { agent: CodexAgent };
4033
4202
  }
4034
4203
 
4035
4204
  /**
@@ -4133,19 +4302,8 @@ async function main() {
4133
4302
  // Extract flags into local variables for convenience
4134
4303
  const { wait, noWait, yolo, fresh, reasoning, follow, all, orphans, force } = flags;
4135
4304
 
4136
- // Agent selection
4137
- let agent = getAgentFromInvocation();
4138
- if (flags.tool) {
4139
- if (flags.tool === "claude") agent = ClaudeAgent;
4140
- else if (flags.tool === "codex") agent = CodexAgent;
4141
- else {
4142
- console.log(`ERROR: unknown tool '${flags.tool}'`);
4143
- process.exit(1);
4144
- }
4145
- }
4146
-
4147
- // Session resolution
4148
- let session = agent.getDefaultSession();
4305
+ // Session resolution (must happen before agent resolution so we can infer tool from session name)
4306
+ let session = null;
4149
4307
  if (flags.session) {
4150
4308
  if (flags.session === "self") {
4151
4309
  const current = tmuxCurrentSession();
@@ -4160,6 +4318,18 @@ async function main() {
4160
4318
  }
4161
4319
  }
4162
4320
 
4321
+ // Agent resolution (considers --tool flag, session name, invocation, and env vars)
4322
+ const { agent, error: agentError } = resolveAgent({ toolFlag: flags.tool, sessionName: session });
4323
+ if (agentError) {
4324
+ console.log(`ERROR: ${agentError}`);
4325
+ process.exit(1);
4326
+ }
4327
+
4328
+ // If no explicit session, use agent's default
4329
+ if (!session) {
4330
+ session = agent.getDefaultSession();
4331
+ }
4332
+
4163
4333
  // Timeout (convert seconds to milliseconds)
4164
4334
  let timeoutMs = DEFAULT_TIMEOUT_MS;
4165
4335
  if (flags.timeout !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ax-agents",
3
- "version": "0.0.1-alpha.7",
3
+ "version": "0.0.1-alpha.9",
4
4
  "description": "A CLI for orchestrating AI coding agents via tmux",
5
5
  "bin": {
6
6
  "ax": "ax.js",