@zgeoff/atc 3.4.1 → 3.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "3.4.1",
3
+ "version": "3.4.2",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -48,7 +48,7 @@ import { loadClaudeConfigBundle } from './load-claude-config-bundle';
48
48
  import { makeClaudeHeadlessRunner } from './make-claude-headless-runner';
49
49
  import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
50
50
  import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
51
- import { planPastedLineInput } from './plan-pasted-line-input';
51
+ import { planClaudeLineInput } from './plan-claude-line-input';
52
52
  import { resolveAgentHome } from './resolve-agent-home';
53
53
  import { resolveClaudeGlobalConfigPath } from './resolve-claude-global-config-path';
54
54
  import { resolveClaudePermissionMode } from './resolve-claude-permission-mode';
@@ -88,8 +88,9 @@ export class ClaudeAdapter implements AgentAdapter {
88
88
  readonly parseTranscriptLine = parseClaudeTranscriptLine;
89
89
 
90
90
  // Claude's TUI takes a long burst of input as a paste and keeps its
91
- // newline in the composer, so a line is pasted and then submitted.
92
- readonly planLineInput = planPastedLineInput;
91
+ // newline in the composer, so a line is pasted and then submitted, with a
92
+ // leading slash command's name typed so the command still runs.
93
+ readonly planLineInput = planClaudeLineInput;
93
94
 
94
95
  readonly profile: AgentProfile;
95
96
 
@@ -38,7 +38,7 @@ import { findFlagValue } from './find-flag-value';
38
38
  import { makeClaudeHeadlessRunner } from './make-claude-headless-runner';
39
39
  import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
40
40
  import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
41
- import { planPastedLineInput } from './plan-pasted-line-input';
41
+ import { planClaudeLineInput } from './plan-claude-line-input';
42
42
  import { resolveClaudePermissionMode } from './resolve-claude-permission-mode';
43
43
  import { writeATCBridge } from './write-atc-bridge';
44
44
  import { writeHookSettings } from './write-hook-settings';
@@ -68,7 +68,7 @@ export class GatewayAdapter implements AgentAdapter {
68
68
  readonly parseTranscriptLine = parseClaudeTranscriptLine;
69
69
 
70
70
  // The gateway runs the Claude CLI, whose TUI takes a line the same way.
71
- readonly planLineInput = planPastedLineInput;
71
+ readonly planLineInput = planClaudeLineInput;
72
72
 
73
73
  readonly takesMessages = true;
74
74
 
@@ -0,0 +1,44 @@
1
+ import type { TerminalInputModes } from './agent-adapter';
2
+ import { planPastedLineInput } from './plan-pasted-line-input';
3
+
4
+ const PASTE_START = '\u001B[200~';
5
+ const PASTE_END = '\u001B[201~';
6
+
7
+ /**
8
+ * Pastes a line into the Claude TUI and submits it, as the pasted line
9
+ * planner does, except for a line that opens with a slash command and an
10
+ * argument. Claude takes a long paste as pasted text and sends it as a
11
+ * message, so such a line has its command name and the spaces after it typed
12
+ * as keys and only the argument pasted, then one carriage return. The
13
+ * command then runs at any argument length. A TUI that has not turned
14
+ * bracketed paste on gets every line as the pasted line planner writes it.
15
+ */
16
+ export function planClaudeLineInput(text: string, modes: TerminalInputModes): readonly string[] {
17
+ const unmarked = text.replaceAll(PASTE_START, '').replaceAll(PASTE_END, '');
18
+ const command = modes.bracketedPaste ? findSlashCommand(unmarked) : null;
19
+
20
+ if (command === null) {
21
+ return planPastedLineInput(text, modes);
22
+ }
23
+
24
+ return [command.typed, `${PASTE_START}${command.argument}${PASTE_END}`, '\r'];
25
+ }
26
+
27
+ // A slash, a command name that may hold a plugin namespace, at least one
28
+ // space, then the argument. A path such as `/tmp/out` has a slash after its
29
+ // first name, so it never matches.
30
+ const SLASH_COMMAND = /^(?<typed>\/[A-Za-z0-9][\w:-]* +)(?<argument>.+)$/su;
31
+
32
+ function findSlashCommand(text: string): { typed: string; argument: string } | null {
33
+ const groups = SLASH_COMMAND.exec(text)?.groups;
34
+ const typed = groups?.['typed'];
35
+ const argument = groups?.['argument'];
36
+
37
+ // An argument of spaces alone leaves a bare command, which the line
38
+ // pastes whole like any short line.
39
+ if (typed === undefined || argument === undefined || argument.trim() === '') {
40
+ return null;
41
+ }
42
+
43
+ return { typed, argument };
44
+ }
@@ -428,7 +428,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
428
428
  annotations: AGENT_FACING_DESTRUCTIVE,
429
429
  scope: 'spawn',
430
430
  description:
431
- "Type a line of text into a running session and submit it, as if the operator typed it and pressed enter. atc submits the line the way the session's agent accepts one. Use it to answer a session that is waiting on input. A result of sent means atc wrote the line and its submit key to the session; it does not confirm that the agent took the line or answered it. Read the session's screen or events for that. The tool sends no raw keystrokes.",
431
+ "Type a line of text into a running session and submit it, as if the operator typed it and pressed enter. atc submits the line the way the session's agent accepts one. Use it to answer a session that is waiting on input. atc pastes the line, so its newlines stay in it, and Claude takes a line of about 800 characters or more as pasted text, not as typed words. On a Claude session, a line that starts with a slash command and an argument, such as /goal finish the release, has the command name typed and only the argument pasted, so the command runs at any length. A result of sent means atc wrote the line and its submit key to the session; it does not confirm that the agent took the line or answered it. Read the session's screen or events for that. The tool sends no raw keystrokes.",
432
432
  inputSchema: {
433
433
  type: 'object',
434
434
  properties: {
@@ -1,18 +1,16 @@
1
1
  /**
2
2
  * The script of a stand-in `gh` whose repository listing waits for the
3
- * test. `gh config …` prints `https`. Any other command touches
4
- * `$HOME/gh-held` and waits while `$HOME/gh-hold` exists, then prints one
5
- * repository, `me/dots`, in the JSON shape `gh repo list` prints.
3
+ * test. `gh config …` prints `https`. Any other command waits while
4
+ * `$HOME/gh-hold` exists, touching `$HOME/gh-held` on each pass of the
5
+ * wait, so the marker appears only once the stand-in is held, then prints
6
+ * one repository, `me/dots`, in the JSON shape `gh repo list` prints.
6
7
  */
7
8
  export function buildStubHeldGH(): string {
8
9
  return `#!/bin/sh
9
10
  case "$1" in
10
11
  config) echo https ;;
11
12
  *)
12
- if [ -f "$HOME/gh-hold" ]; then
13
- touch "$HOME/gh-held"
14
- while [ -f "$HOME/gh-hold" ]; do sleep 0.05; done
15
- fi
13
+ while [ -f "$HOME/gh-hold" ]; do touch "$HOME/gh-held"; sleep 0.05; done
16
14
  echo '[{"nameWithOwner":"me/dots","description":"dotfiles","isPrivate":false,"url":"https://github.com/me/dots","sshUrl":"git@github.com:me/dots.git"}]' ;;
17
15
  esac
18
16
  `;
@@ -1,13 +1,11 @@
1
1
  /**
2
2
  * The script of a stand-in `zoxide` that lists no directories, and waits
3
- * for the test first: while `$HOME/zoxide-hold` exists, it touches
4
- * `$HOME/zoxide-held` and waits for the file to go.
3
+ * for the test first: while `$HOME/zoxide-hold` exists, it waits for the
4
+ * file to go, touching `$HOME/zoxide-held` on each pass of the wait, so the
5
+ * marker appears only once the stand-in is held.
5
6
  */
6
7
  export function buildStubHeldZoxide(): string {
7
8
  return `#!/bin/sh
8
- if [ -f "$HOME/zoxide-hold" ]; then
9
- touch "$HOME/zoxide-held"
10
- while [ -f "$HOME/zoxide-hold" ]; do sleep 0.05; done
11
- fi
9
+ while [ -f "$HOME/zoxide-hold" ]; do touch "$HOME/zoxide-held"; sleep 0.05; done
12
10
  `;
13
11
  }
@@ -29,7 +29,8 @@ interface StubClaudeConfig {
29
29
  * first stub to find it removes it; `fake-claude-tap` runs `atc tap` into
30
30
  * `$HOME/tap.jsonl`; `fake-claude-events.jsonl` holds more hook payloads to
31
31
  * report, one per line, after the `Notification`; `fake-claude-exit` exits
32
- * once they are reported.
32
+ * once they are reported; `fake-claude-composer-last` runs the composer once
33
+ * they are reported.
33
34
  */
34
35
  export function createStubClaude(dir: string, config: StubClaudeConfig): string {
35
36
  const atc = config.atc.map((part) => `"${part}"`).join(' ');
@@ -69,6 +70,7 @@ if [ -f "$HOME/fake-claude-events.jsonl" ]; then
69
70
  done < "$HOME/fake-claude-events.jsonl"
70
71
  fi
71
72
  if [ -f "$HOME/fake-claude-exit" ]; then exit 0; fi
73
+ if [ -f "$HOME/fake-claude-composer-last" ]; then exec "${process.execPath}" "${config.composer}"; fi
72
74
  while read -r line; do echo "GOT:$line"; done
73
75
  `,
74
76
  );
@@ -121,6 +121,9 @@ class StubImpPort implements ImpPort {
121
121
  // Feature reads still to fail as an unreachable impd before they answer.
122
122
  private featureFailures = 0;
123
123
 
124
+ // Feature reads that failed as an unreachable impd.
125
+ private failedFeatureReads = 0;
126
+
124
127
  // Session connections whose opening still throws before it returns, as
125
128
  // an invalid authorization header makes the socket's constructor throw.
126
129
  private openFailures = 0;
@@ -198,6 +201,7 @@ class StubImpPort implements ImpPort {
198
201
 
199
202
  if (this.featureFailures > 0) {
200
203
  this.featureFailures -= 1;
204
+ this.failedFeatureReads += 1;
201
205
 
202
206
  return Promise.reject(new ImpPortError('UNREACHABLE', 'impd did not answer'));
203
207
  }
@@ -1109,6 +1113,11 @@ class StubImpPort implements ImpPort {
1109
1113
  this.featureFailures = count;
1110
1114
  }
1111
1115
 
1116
+ // How many feature reads failed as an unreachable impd.
1117
+ countFailedFeatureReads(): number {
1118
+ return this.failedFeatureReads;
1119
+ }
1120
+
1112
1121
  /**
1113
1122
  * Refuses the next session request with an impd code and its data, or the
1114
1123
  * message of the refusal when it carries one instead.
@@ -20,7 +20,7 @@ function main() {
20
20
  const stateDir = process.argv.at(3);
21
21
 
22
22
  if (socketPath === undefined || stateDir === undefined) {
23
- throw new Error('usage: run-legacy-daemon.ts <socket path> <state dir>');
23
+ throw new Error('usage: run-stub-legacy-daemon.ts <socket path> <state dir>');
24
24
  }
25
25
 
26
26
  startStubLegacyDaemon(socketPath, { protocol: PROTOCOL_V + 1, owner: 'caller' });
@@ -13,7 +13,7 @@ import { registerTestCleanup } from './register-test-cleanup';
13
13
  * a test; disposal stops it sooner, and a second stop does nothing.
14
14
  */
15
15
  export async function startStubSourceDaemon(env: Readonly<Record<string, string | undefined>>) {
16
- const daemon = Bun.spawn([process.execPath, join(import.meta.dir, 'run-source-daemon.ts')], {
16
+ const daemon = Bun.spawn([process.execPath, join(import.meta.dir, 'run-stub-source-daemon.ts')], {
17
17
  env: { ATC_TEST_SOURCES: 'fixture', ...env },
18
18
  stdout: 'pipe',
19
19
  stderr: 'ignore',