acpx 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -9,12 +9,15 @@ One command surface for Codex, Claude, Gemini, OpenCode, Pi, or custom ACP serve
9
9
  - **Persistent sessions**: multi-turn conversations that survive across invocations, scoped per repo
10
10
  - **Named sessions**: run parallel workstreams in the same repo (`-s backend`, `-s frontend`)
11
11
  - **Prompt queueing**: submit prompts while one is already running, they execute in order
12
+ - **Soft-close lifecycle**: close sessions without deleting history from disk
13
+ - **Queue owner TTL**: keep queue owners alive briefly for follow-up prompts (`--ttl`)
12
14
  - **Fire-and-forget**: `--no-wait` queues a prompt and returns immediately
13
15
  - **Structured output**: typed ACP messages (thinking, tool calls, diffs) instead of ANSI scraping
14
16
  - **Any ACP agent**: built-in registry + `--agent` escape hatch for custom servers
15
17
  - **One-shot mode**: `exec` for stateless fire-and-forget tasks
16
18
 
17
19
  ```bash
20
+ $ acpx codex sessions new
18
21
  $ acpx codex "find the flaky test and fix it"
19
22
 
20
23
  [thinking] Investigating test suite for flaky failures
@@ -42,6 +45,34 @@ reading stale state from the previous run.
42
45
  [done] end_turn
43
46
  ```
44
47
 
48
+ ## Quick setup — tell your agent about acpx
49
+
50
+ Copy the block below and paste it into your OpenClaw, Pi, Claude Code, or similar agent harness. It will install acpx, read the skill reference, and know how to use ACP for all future coding agent tasks.
51
+
52
+ ```text
53
+ I want you to use acpx to run coding agents over the Agent Client Protocol
54
+ (ACP) instead of scraping PTY sessions. Please do the following:
55
+
56
+ 1. Install acpx globally:
57
+ npm i -g acpx
58
+
59
+ 2. Install the acpx skill so you have the full reference available:
60
+ acpx --skill export acpx | npx skillflag install
61
+
62
+ 3. Read the acpx skill reference so you know every command, flag, and
63
+ workflow pattern:
64
+ https://raw.githubusercontent.com/janitrai/acpx/main/skills/acpx/SKILL.md
65
+
66
+ 4. If you need the full CLI reference with all options and examples:
67
+ https://raw.githubusercontent.com/janitrai/acpx/main/docs/CLI.md
68
+
69
+ From now on, when you need to delegate work to a coding agent, use acpx
70
+ instead of spawning raw terminal sessions. For example:
71
+ acpx codex "fix the failing tests"
72
+ acpx claude "refactor the auth module"
73
+ acpx codex exec "one-shot: summarize this repo"
74
+ ```
75
+
45
76
  ## Install
46
77
 
47
78
  ```bash
@@ -65,17 +96,22 @@ The only prerequisite is the underlying coding agent you want to use:
65
96
  ## Usage examples
66
97
 
67
98
  ```bash
68
- acpx codex 'fix the tests' # implicit prompt (persistent session)
99
+ acpx codex sessions new # create a session (explicit) for this project dir
100
+ acpx codex 'fix the tests' # implicit prompt (routes via directory-walk)
69
101
  acpx codex prompt 'fix the tests' # explicit prompt subcommand
70
102
  acpx codex --no-wait 'draft test migration plan' # enqueue without waiting if session is busy
71
103
  acpx exec 'summarize this repo' # default agent shortcut (codex)
72
104
  acpx codex exec 'what does this repo do?' # one-shot, no saved session
73
105
 
74
- acpx codex -s api 'implement cursor pagination' # named session
106
+ acpx codex sessions new --name api # create named session
107
+ acpx codex -s api 'implement cursor pagination' # prompt in named session
108
+ acpx codex sessions new --name docs # create another named session
75
109
  acpx codex -s docs 'rewrite API docs' # parallel work in another named session
76
110
 
77
111
  acpx codex sessions # list sessions for codex command
78
112
  acpx codex sessions list # explicit list
113
+ acpx codex sessions new # create fresh cwd-scoped default session
114
+ acpx codex sessions new --name api # create fresh named session
79
115
  acpx codex sessions close # close cwd-scoped default session
80
116
  acpx codex sessions close api # close cwd-scoped named session
81
117
 
@@ -111,6 +147,7 @@ acpx --format json codex exec 'review changed files'
111
147
  acpx --format quiet codex 'final recommendation only'
112
148
 
113
149
  acpx --timeout 90 codex 'investigate intermittent test timeout'
150
+ acpx --ttl 30 codex 'keep queue owner alive for quick follow-ups'
114
151
  acpx --verbose codex 'debug why adapter startup is failing'
115
152
  ```
116
153
 
@@ -148,9 +185,15 @@ acpx --agent ./my-custom-acp-server 'do something'
148
185
 
149
186
  ## Session behavior
150
187
 
151
- - Prompt commands use saved sessions scoped to `(agent command, cwd, optional name)`.
152
- - `-s <name>` creates/selects a parallel named session in the same repo.
188
+ - Prompt commands require an existing saved session record (created via `sessions new`).
189
+ - Prompts route by walking up from `cwd` (or `--cwd`) to the nearest git root (inclusive) and selecting the nearest active session matching `(agent command, dir, optional name)`.
190
+ - If no git root is found, prompts only match an exact `cwd` session (no parent-directory walk).
191
+ - `-s <name>` selects a parallel named session during that directory walk.
192
+ - `sessions new [--name <name>]` creates a fresh session for that scope and soft-closes the prior one.
193
+ - `sessions close [name]` soft-closes the session: queue owner/processes are terminated, record is kept with `closed: true`.
194
+ - Auto-resume for cwd scope skips sessions marked closed.
153
195
  - Prompt submissions are queue-aware per session. If a prompt is already running, new prompts are queued and drained by the running `acpx` process.
196
+ - Queue owners use an idle TTL (default 300s). `--ttl <seconds>` overrides it; `--ttl 0` keeps owners alive indefinitely.
154
197
  - `--no-wait` submits to that queue and returns immediately.
155
198
  - `exec` is always one-shot and does not reuse saved sessions.
156
199
  - Session metadata is stored under `~/.acpx/sessions/`.
package/dist/cli.d.ts CHANGED
@@ -1 +1,23 @@
1
1
  #!/usr/bin/env node
2
+ import { AgentCapabilities } from '@agentclientprotocol/sdk';
3
+
4
+ type SessionRecord = {
5
+ id: string;
6
+ sessionId: string;
7
+ agentCommand: string;
8
+ cwd: string;
9
+ name?: string;
10
+ createdAt: string;
11
+ lastUsedAt: string;
12
+ closed?: boolean;
13
+ closedAt?: string;
14
+ pid?: number;
15
+ protocolVersion?: number;
16
+ agentCapabilities?: AgentCapabilities;
17
+ };
18
+
19
+ declare function parseTtlSeconds(value: string): number;
20
+ declare function formatPromptSessionBannerLine(record: SessionRecord, currentCwd: string): string;
21
+ declare function main(argv?: string[]): Promise<void>;
22
+
23
+ export { formatPromptSessionBannerLine, main, parseTtlSeconds };
package/dist/cli.js CHANGED
@@ -2,7 +2,10 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { Command, CommanderError, InvalidArgumentError } from "commander";
5
+ import { realpathSync } from "fs";
5
6
  import path3 from "path";
7
+ import { pathToFileURL } from "url";
8
+ import { findSkillsRoot, maybeHandleSkillflag } from "skillflag";
6
9
 
7
10
  // src/agent-registry.ts
8
11
  var AGENT_REGISTRY = {
@@ -703,6 +706,7 @@ function createOutputFormatter(format, options = {}) {
703
706
 
704
707
  // src/session.ts
705
708
  import { createHash, randomUUID as randomUUID2 } from "crypto";
709
+ import { statSync } from "fs";
706
710
  import fs2 from "fs/promises";
707
711
  import net from "net";
708
712
  import os from "os";
@@ -1300,7 +1304,7 @@ var PROCESS_EXIT_GRACE_MS = 1500;
1300
1304
  var PROCESS_POLL_MS = 50;
1301
1305
  var QUEUE_CONNECT_ATTEMPTS = 40;
1302
1306
  var QUEUE_CONNECT_RETRY_MS = 50;
1303
- var QUEUE_IDLE_DRAIN_WAIT_MS = 150;
1307
+ var DEFAULT_QUEUE_OWNER_TTL_MS = 3e5;
1304
1308
  var TimeoutError = class extends Error {
1305
1309
  constructor(timeoutMs) {
1306
1310
  super(`Timed out after ${timeoutMs}ms`);
@@ -1375,7 +1379,9 @@ function parseSessionRecord(raw) {
1375
1379
  const record = raw;
1376
1380
  const name = record.name == null ? void 0 : typeof record.name === "string" && record.name.trim().length > 0 ? record.name.trim() : null;
1377
1381
  const pid = record.pid == null ? void 0 : Number.isInteger(record.pid) && record.pid > 0 ? record.pid : null;
1378
- if (typeof record.id !== "string" || typeof record.sessionId !== "string" || typeof record.agentCommand !== "string" || typeof record.cwd !== "string" || name === null || typeof record.createdAt !== "string" || typeof record.lastUsedAt !== "string" || pid === null) {
1382
+ const closed = record.closed == null ? false : typeof record.closed === "boolean" ? record.closed : null;
1383
+ const closedAt = record.closedAt == null ? void 0 : typeof record.closedAt === "string" ? record.closedAt : null;
1384
+ if (typeof record.id !== "string" || typeof record.sessionId !== "string" || typeof record.agentCommand !== "string" || typeof record.cwd !== "string" || name === null || typeof record.createdAt !== "string" || typeof record.lastUsedAt !== "string" || pid === null || closed === null || closedAt === null) {
1379
1385
  return null;
1380
1386
  }
1381
1387
  return {
@@ -1387,6 +1393,8 @@ function parseSessionRecord(raw) {
1387
1393
  name,
1388
1394
  createdAt: record.createdAt,
1389
1395
  lastUsedAt: record.lastUsedAt,
1396
+ closed,
1397
+ closedAt,
1390
1398
  pid
1391
1399
  };
1392
1400
  }
@@ -1441,6 +1449,35 @@ function toPromptResult(stopReason, sessionId, client) {
1441
1449
  function absolutePath(value) {
1442
1450
  return path2.resolve(value);
1443
1451
  }
1452
+ function hasGitDirectory(dir) {
1453
+ const gitPath = path2.join(dir, ".git");
1454
+ try {
1455
+ return statSync(gitPath).isDirectory();
1456
+ } catch {
1457
+ return false;
1458
+ }
1459
+ }
1460
+ function isWithinBoundary(boundary, target) {
1461
+ const relative = path2.relative(boundary, target);
1462
+ return relative.length === 0 || !relative.startsWith("..") && !path2.isAbsolute(relative);
1463
+ }
1464
+ function findGitRepositoryRoot(startDir) {
1465
+ let current = absolutePath(startDir);
1466
+ const root = path2.parse(current).root;
1467
+ for (; ; ) {
1468
+ if (hasGitDirectory(current)) {
1469
+ return current;
1470
+ }
1471
+ if (current === root) {
1472
+ return void 0;
1473
+ }
1474
+ const parent = path2.dirname(current);
1475
+ if (parent === current) {
1476
+ return void 0;
1477
+ }
1478
+ current = parent;
1479
+ }
1480
+ }
1444
1481
  function normalizeName(value) {
1445
1482
  if (value == null) {
1446
1483
  return void 0;
@@ -1451,6 +1488,15 @@ function normalizeName(value) {
1451
1488
  function isoNow() {
1452
1489
  return (/* @__PURE__ */ new Date()).toISOString();
1453
1490
  }
1491
+ function normalizeQueueOwnerTtlMs(ttlMs) {
1492
+ if (ttlMs == null) {
1493
+ return DEFAULT_QUEUE_OWNER_TTL_MS;
1494
+ }
1495
+ if (!Number.isFinite(ttlMs) || ttlMs < 0) {
1496
+ return DEFAULT_QUEUE_OWNER_TTL_MS;
1497
+ }
1498
+ return Math.round(ttlMs);
1499
+ }
1454
1500
  function formatError(error) {
1455
1501
  if (error instanceof Error) {
1456
1502
  return error.message;
@@ -1906,7 +1952,8 @@ var SessionQueueOwner = class _SessionQueueOwner {
1906
1952
  return void 0;
1907
1953
  }
1908
1954
  return await new Promise((resolve) => {
1909
- const timer = setTimeout(
1955
+ const shouldTimeout = timeoutMs != null;
1956
+ const timer = shouldTimeout && setTimeout(
1910
1957
  () => {
1911
1958
  const index = this.waiters.indexOf(waiter);
1912
1959
  if (index >= 0) {
@@ -1917,7 +1964,9 @@ var SessionQueueOwner = class _SessionQueueOwner {
1917
1964
  Math.max(0, timeoutMs)
1918
1965
  );
1919
1966
  const waiter = (task) => {
1920
- clearTimeout(timer);
1967
+ if (timer) {
1968
+ clearTimeout(timer);
1969
+ }
1921
1970
  resolve(task);
1922
1971
  };
1923
1972
  this.waiters.push(waiter);
@@ -2268,6 +2317,8 @@ async function runSessionPrompt(options) {
2268
2317
  output.onDone(response.stopReason);
2269
2318
  output.flush();
2270
2319
  record.lastUsedAt = isoNow();
2320
+ record.closed = false;
2321
+ record.closedAt = void 0;
2271
2322
  record.protocolVersion = client.initializeResult?.protocolVersion;
2272
2323
  record.agentCapabilities = client.initializeResult?.agentCapabilities;
2273
2324
  await writeSessionRecord(record);
@@ -2343,6 +2394,8 @@ async function createSession(options) {
2343
2394
  name: normalizeName(options.name),
2344
2395
  createdAt: now,
2345
2396
  lastUsedAt: now,
2397
+ closed: false,
2398
+ closedAt: void 0,
2346
2399
  pid: client.getAgentPid(),
2347
2400
  protocolVersion: client.initializeResult?.protocolVersion,
2348
2401
  agentCapabilities: client.initializeResult?.agentCapabilities
@@ -2360,6 +2413,7 @@ async function createSession(options) {
2360
2413
  }
2361
2414
  async function sendSession(options) {
2362
2415
  const waitForCompletion = options.waitForCompletion !== false;
2416
+ const queueOwnerTtlMs = normalizeQueueOwnerTtlMs(options.ttlMs);
2363
2417
  const queuedToOwner = await trySubmitToRunningOwner({
2364
2418
  sessionId: options.sessionId,
2365
2419
  message: options.message,
@@ -2401,9 +2455,16 @@ async function sendSession(options) {
2401
2455
  timeoutMs: options.timeoutMs,
2402
2456
  verbose: options.verbose
2403
2457
  });
2458
+ const idleWaitMs = queueOwnerTtlMs === 0 ? void 0 : Math.max(0, queueOwnerTtlMs);
2404
2459
  while (true) {
2405
- const task = await owner.nextTask(QUEUE_IDLE_DRAIN_WAIT_MS);
2460
+ const task = await owner.nextTask(idleWaitMs);
2406
2461
  if (!task) {
2462
+ if (queueOwnerTtlMs > 0 && options.verbose) {
2463
+ process.stderr.write(
2464
+ `[acpx] queue owner TTL expired after ${Math.round(queueOwnerTtlMs / 1e3)}s for session ${options.sessionId}; shutting down
2465
+ `
2466
+ );
2467
+ }
2407
2468
  break;
2408
2469
  }
2409
2470
  await runQueuedTask(options.sessionId, task, options.verbose);
@@ -2450,12 +2511,49 @@ async function findSession(options) {
2450
2511
  if (session.cwd !== normalizedCwd) {
2451
2512
  return false;
2452
2513
  }
2514
+ if (!options.includeClosed && session.closed) {
2515
+ return false;
2516
+ }
2453
2517
  if (normalizedName == null) {
2454
2518
  return session.name == null;
2455
2519
  }
2456
2520
  return session.name === normalizedName;
2457
2521
  });
2458
2522
  }
2523
+ async function findSessionByDirectoryWalk(options) {
2524
+ const normalizedName = normalizeName(options.name);
2525
+ const normalizedStart = absolutePath(options.cwd);
2526
+ const normalizedBoundary = absolutePath(options.boundary ?? normalizedStart);
2527
+ const walkBoundary = isWithinBoundary(normalizedBoundary, normalizedStart) ? normalizedBoundary : normalizedStart;
2528
+ const sessions = await listSessionsForAgent(options.agentCommand);
2529
+ const matchesScope = (session, dir2) => {
2530
+ if (session.cwd !== dir2) {
2531
+ return false;
2532
+ }
2533
+ if (session.closed) {
2534
+ return false;
2535
+ }
2536
+ if (normalizedName == null) {
2537
+ return session.name == null;
2538
+ }
2539
+ return session.name === normalizedName;
2540
+ };
2541
+ let dir = normalizedStart;
2542
+ for (; ; ) {
2543
+ const match = sessions.find((session) => matchesScope(session, dir));
2544
+ if (match) {
2545
+ return match;
2546
+ }
2547
+ if (dir === walkBoundary) {
2548
+ return void 0;
2549
+ }
2550
+ const parent = path2.dirname(dir);
2551
+ if (parent === dir) {
2552
+ return void 0;
2553
+ }
2554
+ dir = parent;
2555
+ }
2556
+ }
2459
2557
  async function terminateQueueOwnerForSession(sessionId) {
2460
2558
  const owner = await readQueueOwnerRecord(sessionId);
2461
2559
  if (!owner) {
@@ -2472,8 +2570,10 @@ async function closeSession(sessionId) {
2472
2570
  if (record.pid != null && isProcessAlive(record.pid) && await isLikelyMatchingProcess(record.pid, record.agentCommand)) {
2473
2571
  await terminateProcess(record.pid);
2474
2572
  }
2475
- const file = sessionFilePath(record.id);
2476
- await fs2.unlink(file);
2573
+ record.pid = void 0;
2574
+ record.closed = true;
2575
+ record.closedAt = isoNow();
2576
+ await writeSessionRecord(record);
2477
2577
  return record;
2478
2578
  }
2479
2579
 
@@ -2483,12 +2583,19 @@ var EXIT_CODES = {
2483
2583
  ERROR: 1,
2484
2584
  USAGE: 2,
2485
2585
  TIMEOUT: 3,
2486
- PERMISSION_DENIED: 4,
2586
+ NO_SESSION: 4,
2587
+ PERMISSION_DENIED: 5,
2487
2588
  INTERRUPTED: 130
2488
2589
  };
2489
2590
  var OUTPUT_FORMATS = ["text", "json", "quiet"];
2490
2591
 
2491
2592
  // src/cli.ts
2593
+ var NoSessionError = class extends Error {
2594
+ constructor(message) {
2595
+ super(message);
2596
+ this.name = "NoSessionError";
2597
+ }
2598
+ };
2492
2599
  var TOP_LEVEL_VERBS = /* @__PURE__ */ new Set(["prompt", "exec", "sessions", "help"]);
2493
2600
  function parseOutputFormat(value) {
2494
2601
  if (!OUTPUT_FORMATS.includes(value)) {
@@ -2505,6 +2612,13 @@ function parseTimeoutSeconds(value) {
2505
2612
  }
2506
2613
  return Math.round(parsed * 1e3);
2507
2614
  }
2615
+ function parseTtlSeconds(value) {
2616
+ const parsed = Number(value);
2617
+ if (!Number.isFinite(parsed) || parsed < 0) {
2618
+ throw new InvalidArgumentError("TTL must be a non-negative number of seconds");
2619
+ }
2620
+ return Math.round(parsed * 1e3);
2621
+ }
2508
2622
  function parseSessionName(value) {
2509
2623
  const trimmed = value.trim();
2510
2624
  if (trimmed.length === 0) {
@@ -2569,6 +2683,10 @@ function addGlobalFlags(command) {
2569
2683
  "--timeout <seconds>",
2570
2684
  "Maximum time to wait for agent response",
2571
2685
  parseTimeoutSeconds
2686
+ ).option(
2687
+ "--ttl <seconds>",
2688
+ "Queue owner idle TTL before shutdown (0 = keep alive forever) (default: 300)",
2689
+ parseTtlSeconds
2572
2690
  ).option("--verbose", "Enable verbose debug logs");
2573
2691
  }
2574
2692
  function addSessionOption(command) {
@@ -2587,6 +2705,7 @@ function resolveGlobalFlags(command) {
2587
2705
  agent: opts.agent,
2588
2706
  cwd: opts.cwd ?? process.cwd(),
2589
2707
  timeout: opts.timeout,
2708
+ ttl: opts.ttl ?? DEFAULT_QUEUE_OWNER_TTL_MS,
2590
2709
  verbose: opts.verbose,
2591
2710
  format: opts.format ?? "text",
2592
2711
  approveAll: opts.approveAll,
@@ -2617,7 +2736,8 @@ function printSessionsByFormat(sessions, format) {
2617
2736
  }
2618
2737
  if (format === "quiet") {
2619
2738
  for (const session of sessions) {
2620
- process.stdout.write(`${session.id}
2739
+ const closedMarker = session.closed ? " [closed]" : "";
2740
+ process.stdout.write(`${session.id}${closedMarker}
2621
2741
  `);
2622
2742
  }
2623
2743
  return;
@@ -2627,8 +2747,9 @@ function printSessionsByFormat(sessions, format) {
2627
2747
  return;
2628
2748
  }
2629
2749
  for (const session of sessions) {
2750
+ const closedMarker = session.closed ? " [closed]" : "";
2630
2751
  process.stdout.write(
2631
- `${session.id} ${session.name ?? "-"} ${session.cwd} ${session.lastUsedAt}
2752
+ `${session.id}${closedMarker} ${session.name ?? "-"} ${session.cwd} ${session.lastUsedAt}
2632
2753
  `
2633
2754
  );
2634
2755
  }
@@ -2652,6 +2773,33 @@ function printClosedSessionByFormat(record, format) {
2652
2773
  process.stdout.write(`${record.id}
2653
2774
  `);
2654
2775
  }
2776
+ function printNewSessionByFormat(record, replaced, format) {
2777
+ if (format === "json") {
2778
+ process.stdout.write(
2779
+ `${JSON.stringify({
2780
+ type: "session_created",
2781
+ id: record.id,
2782
+ sessionId: record.sessionId,
2783
+ name: record.name,
2784
+ replacedSessionId: replaced?.id
2785
+ })}
2786
+ `
2787
+ );
2788
+ return;
2789
+ }
2790
+ if (format === "quiet") {
2791
+ process.stdout.write(`${record.id}
2792
+ `);
2793
+ return;
2794
+ }
2795
+ if (replaced) {
2796
+ process.stdout.write(`${record.id} (replaced ${replaced.id})
2797
+ `);
2798
+ return;
2799
+ }
2800
+ process.stdout.write(`${record.id}
2801
+ `);
2802
+ }
2655
2803
  function printQueuedPromptByFormat(result, format) {
2656
2804
  if (format === "json") {
2657
2805
  process.stdout.write(
@@ -2670,38 +2818,74 @@ function printQueuedPromptByFormat(result, format) {
2670
2818
  process.stdout.write(`[queued] ${result.requestId}
2671
2819
  `);
2672
2820
  }
2821
+ function formatSessionLabel(record) {
2822
+ return record.name ?? "cwd";
2823
+ }
2824
+ function formatRoutedFrom(sessionCwd, currentCwd) {
2825
+ const relative = path3.relative(sessionCwd, currentCwd);
2826
+ if (!relative || relative === ".") {
2827
+ return void 0;
2828
+ }
2829
+ return relative.startsWith(".") ? relative : `.${path3.sep}${relative}`;
2830
+ }
2831
+ function formatPromptSessionBannerLine(record, currentCwd) {
2832
+ const label = formatSessionLabel(record);
2833
+ const normalizedSessionCwd = path3.resolve(record.cwd);
2834
+ const normalizedCurrentCwd = path3.resolve(currentCwd);
2835
+ const routedFrom = normalizedSessionCwd === normalizedCurrentCwd ? void 0 : formatRoutedFrom(normalizedSessionCwd, normalizedCurrentCwd);
2836
+ if (routedFrom) {
2837
+ return `[acpx] session ${label} (${record.id}) \xB7 ${normalizedSessionCwd} (routed from ${routedFrom})`;
2838
+ }
2839
+ return `[acpx] session ${label} (${record.id}) \xB7 ${normalizedSessionCwd}`;
2840
+ }
2841
+ function printPromptSessionBanner(record, currentCwd, format) {
2842
+ if (format === "quiet") {
2843
+ return;
2844
+ }
2845
+ process.stderr.write(`${formatPromptSessionBannerLine(record, currentCwd)}
2846
+ `);
2847
+ }
2848
+ function printCreatedSessionBanner(record, agentName, format) {
2849
+ if (format === "quiet") {
2850
+ return;
2851
+ }
2852
+ const label = formatSessionLabel(record);
2853
+ process.stderr.write(`[acpx] created session ${label} (${record.id})
2854
+ `);
2855
+ process.stderr.write(`[acpx] agent: ${agentName}
2856
+ `);
2857
+ process.stderr.write(`[acpx] cwd: ${record.cwd}
2858
+ `);
2859
+ }
2673
2860
  async function handlePrompt(explicitAgentName, promptParts, flags, command) {
2674
2861
  const globalFlags = resolveGlobalFlags(command);
2675
2862
  const permissionMode = resolvePermissionMode(globalFlags);
2676
2863
  const prompt = await readPrompt(promptParts);
2677
2864
  const outputFormatter = createOutputFormatter(globalFlags.format);
2678
2865
  const agent = resolveAgentInvocation(explicitAgentName, globalFlags);
2679
- let record = await findSession({
2866
+ const gitRoot = findGitRepositoryRoot(agent.cwd);
2867
+ const walkBoundary = gitRoot ?? agent.cwd;
2868
+ const record = await findSessionByDirectoryWalk({
2680
2869
  agentCommand: agent.agentCommand,
2681
2870
  cwd: agent.cwd,
2682
- name: flags.session
2871
+ name: flags.session,
2872
+ boundary: walkBoundary
2683
2873
  });
2684
2874
  if (!record) {
2685
- record = await createSession({
2686
- agentCommand: agent.agentCommand,
2687
- cwd: agent.cwd,
2688
- name: flags.session,
2689
- permissionMode,
2690
- timeoutMs: globalFlags.timeout,
2691
- verbose: globalFlags.verbose
2692
- });
2693
- if (globalFlags.verbose) {
2694
- const scope = flags.session ? `named session "${flags.session}"` : "cwd session";
2695
- process.stderr.write(`[acpx] created ${scope}: ${record.id}
2696
- `);
2697
- }
2875
+ const createCmd = flags.session ? `acpx ${agent.agentName} sessions new --name ${flags.session}` : `acpx ${agent.agentName} sessions new`;
2876
+ throw new NoSessionError(
2877
+ `\u26A0 No acpx session found (searched up to ${walkBoundary}).
2878
+ Create one: ${createCmd}`
2879
+ );
2698
2880
  }
2881
+ printPromptSessionBanner(record, agent.cwd, globalFlags.format);
2699
2882
  const result = await sendSession({
2700
2883
  sessionId: record.id,
2701
2884
  message: prompt,
2702
2885
  permissionMode,
2703
2886
  outputFormatter,
2704
2887
  timeoutMs: globalFlags.timeout,
2888
+ ttlMs: globalFlags.ttl,
2705
2889
  verbose: globalFlags.verbose,
2706
2890
  waitForCompletion: flags.wait !== false
2707
2891
  });
@@ -2759,14 +2943,49 @@ async function handleSessionsClose(explicitAgentName, sessionName, command) {
2759
2943
  const closed = await closeSession(record.id);
2760
2944
  printClosedSessionByFormat(closed, globalFlags.format);
2761
2945
  }
2946
+ async function handleSessionsNew(explicitAgentName, flags, command) {
2947
+ const globalFlags = resolveGlobalFlags(command);
2948
+ const permissionMode = resolvePermissionMode(globalFlags);
2949
+ const agent = resolveAgentInvocation(explicitAgentName, globalFlags);
2950
+ const replaced = await findSession({
2951
+ agentCommand: agent.agentCommand,
2952
+ cwd: agent.cwd,
2953
+ name: flags.name
2954
+ });
2955
+ if (replaced) {
2956
+ await closeSession(replaced.id);
2957
+ if (globalFlags.verbose) {
2958
+ process.stderr.write(`[acpx] soft-closed prior session: ${replaced.id}
2959
+ `);
2960
+ }
2961
+ }
2962
+ const created = await createSession({
2963
+ agentCommand: agent.agentCommand,
2964
+ cwd: agent.cwd,
2965
+ name: flags.name,
2966
+ permissionMode,
2967
+ timeoutMs: globalFlags.timeout,
2968
+ verbose: globalFlags.verbose
2969
+ });
2970
+ printCreatedSessionBanner(created, agent.agentName, globalFlags.format);
2971
+ if (globalFlags.verbose) {
2972
+ const scope = flags.name ? `named session "${flags.name}"` : "cwd session";
2973
+ process.stderr.write(`[acpx] created ${scope}: ${created.id}
2974
+ `);
2975
+ }
2976
+ printNewSessionByFormat(created, replaced, globalFlags.format);
2977
+ }
2762
2978
  function registerSessionsCommand(parent, explicitAgentName) {
2763
- const sessionsCommand = parent.command("sessions").description("List or close sessions for this agent");
2979
+ const sessionsCommand = parent.command("sessions").description("List, create, or close sessions for this agent");
2764
2980
  sessionsCommand.action(async function() {
2765
2981
  await handleSessionsList(explicitAgentName, this);
2766
2982
  });
2767
2983
  sessionsCommand.command("list").description("List sessions").action(async function() {
2768
2984
  await handleSessionsList(explicitAgentName, this);
2769
2985
  });
2986
+ sessionsCommand.command("new").description("Create a fresh session for current cwd").option("--name <name>", "Session name", parseSessionName).action(async function(flags) {
2987
+ await handleSessionsNew(explicitAgentName, flags, this);
2988
+ });
2770
2989
  sessionsCommand.command("close").description("Close session for current cwd").argument("[name]", "Session name", parseSessionName).action(async function(name) {
2771
2990
  await handleSessionsClose(explicitAgentName, name, this);
2772
2991
  });
@@ -2817,11 +3036,11 @@ function detectAgentToken(argv) {
2817
3036
  hasAgentOverride = true;
2818
3037
  continue;
2819
3038
  }
2820
- if (token === "--cwd" || token === "--format" || token === "--timeout") {
3039
+ if (token === "--cwd" || token === "--format" || token === "--timeout" || token === "--ttl") {
2821
3040
  index += 1;
2822
3041
  continue;
2823
3042
  }
2824
- if (token.startsWith("--cwd=") || token.startsWith("--format=") || token.startsWith("--timeout=")) {
3043
+ if (token.startsWith("--cwd=") || token.startsWith("--format=") || token.startsWith("--timeout=") || token.startsWith("--ttl=")) {
2825
3044
  continue;
2826
3045
  }
2827
3046
  if (token === "--approve-all" || token === "--approve-reads" || token === "--deny-all" || token === "--verbose") {
@@ -2831,7 +3050,11 @@ function detectAgentToken(argv) {
2831
3050
  }
2832
3051
  return { hasAgentOverride };
2833
3052
  }
2834
- async function main() {
3053
+ async function main(argv = process.argv) {
3054
+ await maybeHandleSkillflag(argv, {
3055
+ skillsRoot: findSkillsRoot(import.meta.url),
3056
+ includeBundledSkill: false
3057
+ });
2835
3058
  const program = new Command();
2836
3059
  program.name("acpx").description("Headless CLI client for the Agent Client Protocol").showHelpAfterError();
2837
3060
  addGlobalFlags(program);
@@ -2840,7 +3063,7 @@ async function main() {
2840
3063
  registerAgentCommand(program, agentName);
2841
3064
  }
2842
3065
  registerDefaultCommands(program);
2843
- const scan = detectAgentToken(process.argv.slice(2));
3066
+ const scan = detectAgentToken(argv.slice(2));
2844
3067
  if (!scan.hasAgentOverride && scan.token && !TOP_LEVEL_VERBS.has(scan.token) && !builtInAgents.includes(scan.token)) {
2845
3068
  registerAgentCommand(program, scan.token);
2846
3069
  }
@@ -2855,13 +3078,16 @@ async function main() {
2855
3078
  "after",
2856
3079
  `
2857
3080
  Examples:
3081
+ acpx codex sessions new
2858
3082
  acpx codex "fix the tests"
2859
3083
  acpx codex prompt "fix the tests"
2860
3084
  acpx codex --no-wait "queue follow-up task"
2861
3085
  acpx codex exec "what does this repo do"
2862
3086
  acpx codex -s backend "fix the API"
2863
3087
  acpx codex sessions
3088
+ acpx codex sessions new --name backend
2864
3089
  acpx codex sessions close backend
3090
+ acpx --ttl 30 codex "investigate flaky tests"
2865
3091
  acpx claude "refactor auth"
2866
3092
  acpx gemini "add logging"
2867
3093
  acpx --agent ./my-custom-server "do something"`
@@ -2870,7 +3096,7 @@ Examples:
2870
3096
  throw error;
2871
3097
  });
2872
3098
  try {
2873
- await program.parseAsync(process.argv);
3099
+ await program.parseAsync(argv);
2874
3100
  } catch (error) {
2875
3101
  if (error instanceof CommanderError) {
2876
3102
  if (error.code === "commander.helpDisplayed" || error.code === "commander.version") {
@@ -2886,10 +3112,34 @@ Examples:
2886
3112
  `);
2887
3113
  process.exit(EXIT_CODES.TIMEOUT);
2888
3114
  }
3115
+ if (error instanceof NoSessionError) {
3116
+ process.stderr.write(`${error.message}
3117
+ `);
3118
+ process.exit(EXIT_CODES.NO_SESSION);
3119
+ }
2889
3120
  const message = error instanceof Error ? error.message : String(error);
2890
3121
  process.stderr.write(`${message}
2891
3122
  `);
2892
3123
  process.exit(EXIT_CODES.ERROR);
2893
3124
  }
2894
3125
  }
2895
- void main();
3126
+ function isCliEntrypoint(argv) {
3127
+ const entry = argv[1];
3128
+ if (!entry) {
3129
+ return false;
3130
+ }
3131
+ try {
3132
+ const resolved = pathToFileURL(realpathSync(entry)).href;
3133
+ return import.meta.url === resolved;
3134
+ } catch {
3135
+ return false;
3136
+ }
3137
+ }
3138
+ if (isCliEntrypoint(process.argv)) {
3139
+ void main(process.argv);
3140
+ }
3141
+ export {
3142
+ formatPromptSessionBannerLine,
3143
+ main,
3144
+ parseTtlSeconds
3145
+ };
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "acpx",
3
- "version": "0.1.2",
4
- "description": "Headless CLI client for the Agent Client Protocol (ACP) \u2014 talk to coding agents from the command line",
3
+ "version": "0.1.4",
4
+ "description": "Headless CLI client for the Agent Client Protocol (ACP) talk to coding agents from the command line",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist",
8
+ "skills",
8
9
  "README.md",
9
10
  "LICENSE"
10
11
  ],
@@ -13,6 +14,10 @@
13
14
  },
14
15
  "scripts": {
15
16
  "build": "tsup src/cli.ts --format esm --dts --clean",
17
+ "build:test": "tsc -p tsconfig.test.json",
18
+ "test": "npm run build:test && node --test dist-test/test/*.test.js",
19
+ "prepare": "husky",
20
+ "precommit": "npm exec -- lint-staged && npm run -s build",
16
21
  "prepack": "npm run build",
17
22
  "dev": "tsx src/cli.ts",
18
23
  "typecheck": "tsc --noEmit",
@@ -42,13 +47,16 @@
42
47
  },
43
48
  "dependencies": {
44
49
  "@agentclientprotocol/sdk": "^0.14.1",
45
- "commander": "^13.0.0"
50
+ "commander": "^13.0.0",
51
+ "skillflag": "^0.1.3"
46
52
  },
47
53
  "devDependencies": {
48
54
  "@eslint/js": "^10.0.1",
49
55
  "@types/node": "^22.0.0",
50
56
  "eslint": "^10.0.0",
51
57
  "globals": "^17.3.0",
58
+ "husky": "^9.1.7",
59
+ "lint-staged": "^16.2.7",
52
60
  "prettier": "^3.8.1",
53
61
  "release-it": "^19.2.4",
54
62
  "tsup": "^8.0.0",
@@ -56,21 +64,13 @@
56
64
  "typescript": "^5.7.0",
57
65
  "typescript-eslint": "^8.56.0"
58
66
  },
59
- "release-it": {
60
- "git": {
61
- "requireCleanWorkingDir": true,
62
- "requireBranch": "main",
63
- "commitMessage": "chore(release): ${version}",
64
- "tagName": "v${version}",
65
- "push": true
66
- },
67
- "npm": {
68
- "publish": true,
69
- "skipChecks": true
70
- },
71
- "github": {
72
- "release": true,
73
- "autoGenerate": true
74
- }
67
+ "lint-staged": {
68
+ "*.{js,ts}": [
69
+ "prettier --write --ignore-unknown",
70
+ "eslint --fix"
71
+ ],
72
+ "*.{json,md}": [
73
+ "prettier --write --ignore-unknown"
74
+ ]
75
75
  }
76
76
  }
@@ -0,0 +1,254 @@
1
+ ---
2
+ name: acpx
3
+ description: Use acpx as a headless ACP CLI for agent-to-agent communication, including prompt/exec/sessions workflows, session scoping, queueing, permissions, and output formats.
4
+ ---
5
+
6
+ # acpx
7
+
8
+ ## When to use this skill
9
+
10
+ Use this skill when you need to run coding agents through `acpx`, manage persistent ACP sessions, queue prompts, or consume structured agent output from scripts.
11
+
12
+ ## What acpx is
13
+
14
+ `acpx` is a headless, scriptable CLI client for the Agent Client Protocol (ACP). It is built for agent-to-agent communication over the command line and avoids PTY scraping.
15
+
16
+ Core capabilities:
17
+
18
+ - Persistent multi-turn sessions per repo/cwd
19
+ - One-shot execution mode (`exec`)
20
+ - Named parallel sessions (`-s/--session`)
21
+ - Queue-aware prompt submission with optional fire-and-forget (`--no-wait`)
22
+ - Structured streaming output (`text`, `json`, `quiet`)
23
+ - Built-in agent registry plus raw `--agent` escape hatch
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ npm i -g acpx
29
+ ```
30
+
31
+ For normal session reuse, prefer a global install over `npx`.
32
+
33
+ ## Command model
34
+
35
+ `prompt` is the default verb.
36
+
37
+ ```bash
38
+ acpx [global_options] [prompt_text...]
39
+ acpx [global_options] prompt [prompt_options] [prompt_text...]
40
+ acpx [global_options] exec [prompt_text...]
41
+ acpx [global_options] sessions [list | new [--name <name>] | close [name]]
42
+
43
+ acpx [global_options] <agent> [prompt_options] [prompt_text...]
44
+ acpx [global_options] <agent> prompt [prompt_options] [prompt_text...]
45
+ acpx [global_options] <agent> exec [prompt_text...]
46
+ acpx [global_options] <agent> sessions [list | new [--name <name>] | close [name]]
47
+ ```
48
+
49
+ If prompt text is omitted and stdin is piped, `acpx` reads prompt text from stdin.
50
+
51
+ ## Built-in agent registry
52
+
53
+ Friendly agent names resolve to commands:
54
+
55
+ - `codex` -> `npx @zed-industries/codex-acp`
56
+ - `claude` -> `npx @zed-industries/claude-agent-acp`
57
+ - `gemini` -> `gemini`
58
+ - `opencode` -> `npx opencode-ai`
59
+ - `pi` -> `npx pi-acp`
60
+
61
+ Rules:
62
+
63
+ - Default agent is `codex` for top-level `prompt`, `exec`, and `sessions`.
64
+ - Unknown positional agent tokens are treated as raw agent commands.
65
+ - `--agent <command>` explicitly sets a raw ACP adapter command.
66
+ - Do not combine a positional agent and `--agent` in the same command.
67
+
68
+ ## Commands
69
+
70
+ ### Prompt (default, persistent session)
71
+
72
+ Implicit:
73
+
74
+ ```bash
75
+ acpx codex 'fix flaky tests'
76
+ ```
77
+
78
+ Explicit:
79
+
80
+ ```bash
81
+ acpx codex prompt 'fix flaky tests'
82
+ acpx prompt 'fix flaky tests' # defaults to codex
83
+ ```
84
+
85
+ Behavior:
86
+
87
+ - Uses a saved session for the session scope key
88
+ - Auto-resumes prior session when one exists for that scope
89
+ - Creates a new session record when none exists
90
+ - Is queue-aware when another prompt is already running for the same session
91
+
92
+ Prompt options:
93
+
94
+ - `-s, --session <name>`: use a named session within the same cwd
95
+ - `--no-wait`: enqueue and return immediately when session is already busy
96
+
97
+ ### Exec (one-shot)
98
+
99
+ ```bash
100
+ acpx exec 'summarize this repo'
101
+ acpx codex exec 'summarize this repo'
102
+ ```
103
+
104
+ Behavior:
105
+
106
+ - Runs a single prompt in a temporary ACP session
107
+ - Does not reuse or save persistent session state
108
+
109
+ ### Sessions
110
+
111
+ ```bash
112
+ acpx sessions
113
+ acpx sessions list
114
+ acpx sessions new
115
+ acpx sessions new --name backend
116
+ acpx sessions close
117
+ acpx sessions close backend
118
+
119
+ acpx codex sessions
120
+ acpx codex sessions new --name backend
121
+ acpx codex sessions close backend
122
+ ```
123
+
124
+ Behavior:
125
+
126
+ - `sessions` and `sessions list` are equivalent
127
+ - `new` creates a fresh session for the current `(agentCommand, cwd, optional name)` scope
128
+ - `new --name <name>` targets a named session scope
129
+ - when `new` replaces an existing open session in that scope, the old one is soft-closed
130
+ - `close` targets current cwd default session
131
+ - `close <name>` targets current cwd named session
132
+
133
+ ## Global options
134
+
135
+ - `--agent <command>`: raw ACP agent command (escape hatch)
136
+ - `--cwd <dir>`: working directory for session scope (default: current directory)
137
+ - `--approve-all`: auto-approve all permission requests
138
+ - `--approve-reads`: auto-approve reads/searches, prompt for writes (default mode)
139
+ - `--deny-all`: deny all permission requests
140
+ - `--format <fmt>`: output format (`text`, `json`, `quiet`)
141
+ - `--timeout <seconds>`: max wait time (positive number)
142
+ - `--ttl <seconds>`: queue owner idle TTL before shutdown (default `300`, `0` disables TTL)
143
+ - `--verbose`: verbose ACP/debug logs to stderr
144
+
145
+ Permission flags are mutually exclusive.
146
+
147
+ ## Session behavior
148
+
149
+ Persistent prompt sessions are scoped by:
150
+
151
+ - `agentCommand`
152
+ - absolute `cwd`
153
+ - optional session `name`
154
+
155
+ Persistence:
156
+
157
+ - Session records are stored in `~/.acpx/sessions/*.json`.
158
+ - `-s/--session` creates parallel named conversations in the same repo.
159
+ - Changing `--cwd` changes scope and therefore session lookup.
160
+ - closed sessions are retained on disk with `closed: true` and `closedAt`.
161
+ - auto-resume by scope skips closed sessions.
162
+
163
+ Resume behavior:
164
+
165
+ - Prompt mode attempts to reconnect to saved session.
166
+ - If adapter-side session is invalid/not found, `acpx` creates a fresh session and updates the saved record.
167
+ - explicitly selected session records can still be resumed via `loadSession` even if previously closed.
168
+
169
+ ## Prompt queueing and `--no-wait`
170
+
171
+ Queueing is per persistent session.
172
+
173
+ - The active `acpx` process for a running prompt becomes the queue owner.
174
+ - Other invocations submit prompts over local IPC.
175
+ - On Unix-like systems, queue IPC uses a Unix socket under `~/.acpx/queues/<hash>.sock`.
176
+ - Ownership is coordinated with a lock file under `~/.acpx/queues/<hash>.lock`.
177
+ - On Windows, named pipes are used instead of Unix sockets.
178
+ - after the queue drains, owner shutdown is governed by TTL (default 300s, configurable with `--ttl`).
179
+
180
+ Submission behavior:
181
+
182
+ - Default: enqueue and wait for queued prompt completion, streaming updates back.
183
+ - `--no-wait`: enqueue and return after queue acknowledgement.
184
+
185
+ ## Output formats
186
+
187
+ Use `--format <fmt>`:
188
+
189
+ - `text` (default): human-readable stream with updates/tool status and done line
190
+ - `json`: NDJSON event stream (good for automation)
191
+ - `quiet`: final assistant text only
192
+
193
+ Example automation:
194
+
195
+ ```bash
196
+ acpx --format json codex exec 'review changed files' \
197
+ | jq -r 'select(.type=="tool_call") | [.status, .title] | @tsv'
198
+ ```
199
+
200
+ ## Permission modes
201
+
202
+ - `--approve-all`: no interactive permission prompts
203
+ - `--approve-reads` (default): approve reads/searches, prompt for writes
204
+ - `--deny-all`: deny all permission requests
205
+
206
+ If every permission request is denied/cancelled and none approved, `acpx` exits with permission-denied status.
207
+
208
+ ## Practical workflows
209
+
210
+ Persistent repo assistant:
211
+
212
+ ```bash
213
+ acpx codex 'inspect failing tests and propose a fix plan'
214
+ acpx codex 'apply the smallest safe fix and run tests'
215
+ ```
216
+
217
+ Parallel named streams:
218
+
219
+ ```bash
220
+ acpx codex -s backend 'fix API pagination bug'
221
+ acpx codex -s docs 'draft changelog entry for release'
222
+ ```
223
+
224
+ Queue follow-up without waiting:
225
+
226
+ ```bash
227
+ acpx codex 'run full test suite and investigate failures'
228
+ acpx codex --no-wait 'after tests, summarize root causes and next steps'
229
+ ```
230
+
231
+ One-shot script step:
232
+
233
+ ```bash
234
+ acpx --format quiet exec 'summarize repo purpose in 3 lines'
235
+ ```
236
+
237
+ Machine-readable output for orchestration:
238
+
239
+ ```bash
240
+ acpx --format json codex 'review current branch changes' > events.ndjson
241
+ ```
242
+
243
+ Raw custom adapter command:
244
+
245
+ ```bash
246
+ acpx --agent './bin/custom-acp-server --profile ci' 'run validation checks'
247
+ ```
248
+
249
+ Repo-scoped review with permissive mode:
250
+
251
+ ```bash
252
+ acpx --cwd ~/repos/shop --approve-all codex -s pr-842 \
253
+ 'review PR #842 for regressions and propose minimal patch'
254
+ ```