companion-for-agy 1.2.0-alpha.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ ## [1.2.0-alpha.1] - 2026-06-07
4
+
5
+ ### Changed
6
+ - Package renamed to `companion-for-agy` (legal/trademark distancing via "for" pattern)
7
+ - Added "Unofficial" disclaimer to README and package description
8
+
9
+ ### Fixed
10
+ - Short responses (≤2 chars like "4", "42", "ja") were incorrectly filtered as noise
11
+ - Prompt-echo bug in `--no-tools` mode: the permission prefix ("IMPORTANT: Do not use...") was returned as the response instead of the actual answer
12
+ - ConPTY space-loss in prompt echo: whitespace-normalized matching now handles "Donotuse" vs "Do not use"
13
+
14
+ ### Added
15
+ - `stripPromptEcho()` — whitespace-tolerant prompt echo removal (word-by-word regex)
16
+ - `extractResponse()` now accepts 4th parameter `effectiveFilter` for full prompt echo stripping
17
+ - 5-phase state machine: Trust dialog auto-confirmation phase
18
+ - Banner model detection: JSON reports actual model from agy's banner
19
+ - 26 new tests (107 total): short answer extraction, prompt echo regression, stripPromptEcho unit tests
20
+ - `companion-for-agy` CLI alias (alongside `agy-companion` for backward compatibility)
21
+
22
+ ## [1.1.0] - 2026-06-06
23
+
24
+ ### Changed
25
+ - Cross-platform support: Windows, macOS, Linux (node-pty handles platform-specific PTY)
26
+ - Auto-detection of agy binary via PATH, common install locations, env var fallback
27
+ - node-pty loaded as standard npm dependency (no hardcoded path to gemini-cli internals)
28
+ - Debug log writes to `./agy-debug.log` (CWD) instead of `~/.claude/scripts/`
29
+ - CLI messages and usage text in English for international audience
30
+ - Added English INIT_DONE_PATTERNS alongside German ones
31
+ - Removed `"os": ["win32"]` restriction from package.json
32
+
33
+ ### Added
34
+ - `findAgyPath()` exported function for programmatic agy detection
35
+ - Test suite: 81 tests (unit, fixture, smoke) via `node:test`
36
+ - `npm run deploy` and `npm run sync` scripts for local copy management
37
+ - Comprehensive README with installation, troubleshooting, and usage docs
38
+
39
+ ## [1.0.0] - 2026-06-06
40
+
41
+ ### Hinzugefügt
42
+ - ConPTY-basierter Wrapper für agy (Antigravity CLI)
43
+ - ANSI-Color-basierte Response-Extraktion (RGB 232,234,237)
44
+ - Fallback: Zeilen-basierte Noise-Filterung
45
+ - 4-Phasen State-Machine (Startup → Init → Question → Response)
46
+ - Adaptives Timing (10s während Generierung, 2.5s nach Abschluss)
47
+ - Permission-System mit 5 Modi: sandbox, skip-permissions, no-tools, researcher, read-only
48
+ - Custom allow/deny Regeln (kompatibel mit agys settings.json-Format)
49
+ - JSON-Output-Modus (--json)
50
+ - Konfigurierbare Pfade via Umgebungsvariablen
51
+ - Prompt-Sanitisierung gegen PTY-Injection
52
+ - Graceful Shutdown (Ctrl+C → Grace-Period → kill)
53
+ - Debug-Modus mit PTY-Output-Log
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lukas Geiger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # companion-for-agy
2
+
3
+ <p align="center">
4
+ <img src="assets/logo.png" alt="companion-for-agy Logo" width="200" height="200" />
5
+ </p>
6
+
7
+ [![Deutsch](https://img.shields.io/badge/lang-Deutsch-blue)](README_de.md)
8
+
9
+ > **Unofficial** — not affiliated with or endorsed by Google.
10
+
11
+ PTY-based wrapper for **agy** (Antigravity CLI / Gemini CLI) that captures Gemini responses from subprocesses.
12
+
13
+ ## Problem
14
+
15
+ `agy -p` (print mode) returns exit 0 but writes no output to stdout — the TUI text-drip renderer (`text_drip.go`) writes to the terminal buffer instead. This is a known bug:
16
+
17
+ - [antigravity-cli#76](https://github.com/google-antigravity/antigravity-cli/issues/76)
18
+ - [gemini-cli#27466](https://github.com/google-gemini/gemini-cli/issues/27466)
19
+ - [antigravity-cli#115](https://github.com/google-antigravity/antigravity-cli/issues/115)
20
+
21
+ This means no other agent (Claude Code, Codex, CI/CD) can programmatically read agy's responses.
22
+
23
+ ## Solution
24
+
25
+ `companion-for-agy` spawns agy inside a virtual terminal via `node-pty` (ConPTY on Windows, forkpty on macOS/Linux) and extracts the response from the ANSI color stream. agy's response text uses `RGB(232,234,237)` — the wrapper tracks ANSI color state and collects only text in that color.
26
+
27
+ > **Platform note:** The ANSI color extraction (`RGB(232,234,237)`) and the `--model` flag have been verified on **Windows** with agy >= 1.1. macOS and Linux are expected to work via `node-pty` since agy uses the same Go TUI renderer. However:
28
+ > - **agy v1.0.x** (Homebrew `antigravity-cli`) does not support `--model` — the tool will fail with "flags provided but not defined". Update agy or omit `--model` (requires code change).
29
+ > - The exact RGB response color has not been independently confirmed on non-Windows platforms. If color extraction returns empty results, try `--debug` and check `agy-debug.log` for the actual color codes.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ npm install -g companion-for-agy
35
+ ```
36
+
37
+ ### Prerequisites
38
+
39
+ - **Node.js >= 18**
40
+ - **agy** ([Gemini CLI](https://github.com/google-gemini/gemini-cli)) installed and authenticated
41
+ - **C/C++ build tools** for `node-pty` native compilation:
42
+ - **Windows:** [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) + Python 3
43
+ - **macOS:** `xcode-select --install`
44
+ - **Linux:** `sudo apt install build-essential python3` (Debian/Ubuntu)
45
+
46
+ ### Troubleshooting `node-pty` build errors
47
+
48
+ If `npm install` fails with native compilation errors:
49
+
50
+ ```bash
51
+ # All platforms: rebuild native modules
52
+ npm rebuild node-pty
53
+
54
+ # Windows: if cl.exe is not found, install Visual Studio Build Tools
55
+ # then open "Developer Command Prompt" or run from "x64 Native Tools"
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ ```bash
61
+ companion-for-agy [options] "prompt"
62
+ ```
63
+
64
+ ### Permission Modes
65
+
66
+ | Flag | Description |
67
+ |------|-------------|
68
+ | `--sandbox` | Sandbox mode (default) — tools in container |
69
+ | `--skip-permissions` | All tools without confirmation (YOLO) |
70
+ | `--no-tools` | Pure chat — no tool execution |
71
+ | `--researcher` | Web search allowed, no file changes |
72
+ | `--read-only` | Read-only, no modifications |
73
+
74
+ ### Custom Rules
75
+
76
+ ```bash
77
+ --allow "read_file(/path)" # Allowlist rule (repeatable)
78
+ --deny "command(rm)" # Denylist rule (repeatable)
79
+ ```
80
+
81
+ Formats match agy's own permission system (`settings.json`).
82
+
83
+ ### Options
84
+
85
+ | Flag | Description |
86
+ |------|-------------|
87
+ | `--model <model>` | Gemini model (default: `gemini-3.5-flash`) |
88
+ | `--timeout <ms>` | Timeout in ms (default: 120000) |
89
+ | `--json` | Output as JSON object |
90
+ | `--debug` | Save raw PTY output to `agy-debug.log` |
91
+
92
+ ### Environment Variables
93
+
94
+ | Variable | Description |
95
+ |----------|-------------|
96
+ | `AGY_COMPANION_AGY_PATH` | Path to agy binary (auto-detected if not set) |
97
+ | `AGY_PATH` | Alternative path to agy binary |
98
+
99
+ ### Examples
100
+
101
+ ```bash
102
+ # Simple question
103
+ companion-for-agy "What is the capital of Bavaria?"
104
+
105
+ # As advisor (no tool use)
106
+ companion-for-agy --no-tools "Review this code: ..."
107
+
108
+ # Web research
109
+ companion-for-agy --researcher "Latest info on Node.js 24"
110
+
111
+ # Read-only with additional git permission
112
+ companion-for-agy --read-only --allow "command(git log)" "prompt"
113
+
114
+ # JSON output for programmatic use
115
+ companion-for-agy --json --model gemini-3.5-pro "prompt"
116
+ # → {"response":"...","model":"Gemini 3.5 Pro (High)","requestedModel":"gemini-3.5-pro","permissionMode":"sandbox"}
117
+ ```
118
+
119
+ > **JSON fields:** `model` reports the actual model detected from agy's banner (e.g., `"Gemini 3.5 Flash (Medium)"`). `requestedModel` is what was passed via `--model`. If banner detection fails, `model` falls back to `requestedModel`.
120
+
121
+ ## How It Works
122
+
123
+ ```
124
+ ┌────────────────────┐ PTY ┌─────────────┐
125
+ │ companion-for-agy │ ─────────────▸ │ agy │
126
+ │ (Node.js) │ ◂────────────── │ (Go TUI) │
127
+ │ │ ANSI stream │ │
128
+ │ Color-Based │ │ text_drip.go│
129
+ │ Extraction │ │ RGB(232,234,│
130
+ │ │ │ 237) │
131
+ └────────┬───────────┘ └─────────────┘
132
+
133
+ ▼ stdout
134
+ Response text
135
+ ```
136
+
137
+ **5-Phase State Machine:**
138
+
139
+ 1. **Trust** — detect and auto-confirm workspace trust dialog
140
+ 2. **Startup** — detect main UI readiness (`? for shortcuts`)
141
+ 3. **Init** — wait for GEMINI.md initialization (pattern matching or 20s fallback)
142
+ 4. **Question** — send prompt, set response marker
143
+ 5. **Response** — read response via ANSI color extraction, adaptive idle timer
144
+
145
+ ## Use Cases
146
+
147
+ - **Multi-agent orchestration**: Claude Code, Codex, or other agents querying Gemini via agy
148
+ - **CI/CD pipelines**: automated Gemini queries in build scripts
149
+ - **Scripting**: any scenario where agy's response needs to be captured as text
150
+
151
+ ## Background
152
+
153
+ This tool was built because three CLI agents — **Claude Code**, **Codex**, and **agy** — need to call each other as fallback advisors. While Claude → Codex and agy → Claude/Codex already work, Claude → agy was blocked by the TUI stdout bug.
154
+
155
+ ## License
156
+
157
+ MIT
package/README_de.md ADDED
@@ -0,0 +1,126 @@
1
+ # companion-for-agy
2
+
3
+ <p align="center">
4
+ <img src="assets/logo.png" alt="companion-for-agy Logo" width="200" height="200" />
5
+ </p>
6
+
7
+ [![English](https://img.shields.io/badge/lang-English-blue)](README.md)
8
+
9
+ > **Inoffiziell** — nicht verbunden mit oder empfohlen von Google.
10
+
11
+ PTY-basierter Wrapper für **agy** (Antigravity CLI / Gemini CLI), der Gemini-Antworten aus Subprozessen erfasst.
12
+
13
+ ## Problem
14
+
15
+ `agy -p` (Print-Modus) gibt Exit 0 zurück, schreibt aber keinen Output nach stdout — der TUI Text-Drip-Renderer (`text_drip.go`) schreibt in den Terminal-Buffer. Das ist ein bekannter Bug:
16
+
17
+ - [antigravity-cli#76](https://github.com/google-antigravity/antigravity-cli/issues/76)
18
+ - [gemini-cli#27466](https://github.com/google-gemini/gemini-cli/issues/27466)
19
+ - [antigravity-cli#115](https://github.com/google-antigravity/antigravity-cli/issues/115)
20
+
21
+ Kein anderer Agent (Claude Code, Codex, CI/CD) kann dadurch agys Antworten programmatisch lesen.
22
+
23
+ ## Lösung
24
+
25
+ `companion-for-agy` startet agy in einem virtuellen Terminal via `node-pty` (ConPTY unter Windows, forkpty unter macOS/Linux) und extrahiert die Antwort aus dem ANSI-Farbstream. agys Antworttext nutzt `RGB(232,234,237)` — der Wrapper verfolgt den ANSI-Farbstatus und sammelt nur Text in dieser Farbe.
26
+
27
+ > **Plattformhinweis:** Die ANSI-Farbextraktion (`RGB(232,234,237)`) wurde unter **Windows** (ConPTY) verifiziert. macOS und Linux sollten über `node-pty` funktionieren, da agy denselben Go-TUI-Renderer verwendet, aber die exakten RGB-Werte wurden auf diesen Plattformen noch nicht unabhängig bestätigt. Falls die Farbextraktion leere Ergebnisse liefert, `--debug` verwenden und `agy-debug.log` auf die tatsächlichen Farbcodes prüfen.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ npm install -g companion-for-agy
33
+ ```
34
+
35
+ ### Voraussetzungen
36
+
37
+ - **Node.js >= 18**
38
+ - **agy** ([Gemini CLI](https://github.com/google-gemini/gemini-cli)) installiert und authentifiziert
39
+ - **C/C++ Build-Tools** für `node-pty` Native-Kompilierung:
40
+ - **Windows:** [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) + Python 3
41
+ - **macOS:** `xcode-select --install`
42
+ - **Linux:** `sudo apt install build-essential python3` (Debian/Ubuntu)
43
+
44
+ ## Verwendung
45
+
46
+ ```bash
47
+ companion-for-agy [optionen] "prompt"
48
+ ```
49
+
50
+ ### Berechtigungsmodi
51
+
52
+ | Flag | Beschreibung |
53
+ |------|-------------|
54
+ | `--sandbox` | Sandbox-Modus (Standard) — Tools im Container |
55
+ | `--skip-permissions` | Alle Tools ohne Bestätigung (YOLO) |
56
+ | `--no-tools` | Reiner Chat — keine Tool-Ausführung |
57
+ | `--researcher` | Web-Suche erlaubt, keine Dateiänderungen |
58
+ | `--read-only` | Nur Lesen, keine Änderungen |
59
+
60
+ ### Eigene Regeln
61
+
62
+ ```bash
63
+ --allow "read_file(/pfad)" # Erlaubnisregel (wiederholbar)
64
+ --deny "command(rm)" # Verbots-Regel (wiederholbar)
65
+ ```
66
+
67
+ ### Optionen
68
+
69
+ | Flag | Beschreibung |
70
+ |------|-------------|
71
+ | `--model <modell>` | Gemini-Modell (Standard: `gemini-3.5-flash`) |
72
+ | `--timeout <ms>` | Timeout in ms (Standard: 120000) |
73
+ | `--json` | Ausgabe als JSON-Objekt |
74
+ | `--debug` | Rohen PTY-Output in `agy-debug.log` speichern |
75
+
76
+ ### Beispiele
77
+
78
+ ```bash
79
+ # Einfache Frage
80
+ companion-for-agy "Was ist die Hauptstadt von Bayern?"
81
+
82
+ # Als Berater (keine Tool-Nutzung)
83
+ companion-for-agy --no-tools "Überprüfe diesen Code: ..."
84
+
85
+ # Web-Recherche
86
+ companion-for-agy --researcher "Neueste Infos zu Node.js 24"
87
+
88
+ # JSON-Output für programmatische Nutzung
89
+ companion-for-agy --json --model gemini-3.5-pro "prompt"
90
+ ```
91
+
92
+ > **JSON-Felder:** `model` meldet das tatsächliche Modell aus agys Banner (z.B. `"Gemini 3.5 Flash (Medium)"`). `requestedModel` ist was via `--model` übergeben wurde.
93
+
94
+ ## Funktionsweise
95
+
96
+ ```
97
+ ┌────────────────────┐ PTY ┌─────────────┐
98
+ │ companion-for-agy │ ─────────────▸ │ agy │
99
+ │ (Node.js) │ ◂────────────── │ (Go TUI) │
100
+ │ │ ANSI-Stream │ │
101
+ │ Farbbasierte │ │ text_drip.go│
102
+ │ Extraktion │ │ RGB(232,234,│
103
+ │ │ │ 237) │
104
+ └────────┬───────────┘ └─────────────┘
105
+
106
+ ▼ stdout
107
+ Antworttext
108
+ ```
109
+
110
+ **5-Phasen State Machine:**
111
+
112
+ 1. **Trust** — Workspace-Trust-Dialog erkennen und automatisch bestätigen
113
+ 2. **Startup** — Haupt-UI-Bereitschaft erkennen (`? for shortcuts`)
114
+ 3. **Init** — GEMINI.md-Initialisierung abwarten (Pattern-Matching oder 20s Fallback)
115
+ 4. **Question** — Prompt senden, Response-Marker setzen
116
+ 5. **Response** — Antwort via ANSI-Farbextraktion lesen, adaptiver Idle-Timer
117
+
118
+ ## Anwendungsfälle
119
+
120
+ - **Multi-Agent-Orchestrierung:** Claude Code, Codex oder andere Agenten fragen Gemini via agy
121
+ - **CI/CD-Pipelines:** Automatisierte Gemini-Abfragen in Build-Scripts
122
+ - **Scripting:** Jedes Szenario wo agys Antwort als Text benötigt wird
123
+
124
+ ## Lizenz
125
+
126
+ MIT
package/ROADMAP.md ADDED
@@ -0,0 +1,53 @@
1
+ # Roadmap
2
+
3
+ ## Platform Status
4
+
5
+ | Platform | Status | Notes |
6
+ |----------|--------|-------|
7
+ | **Windows** | Verified | ConPTY, agy >= 1.1, RGB(232,234,237) confirmed |
8
+ | **macOS** | Untested | node-pty (forkpty) expected to work, color values unconfirmed |
9
+ | **Linux** | Untested | node-pty (forkpty) expected to work, color values unconfirmed |
10
+
11
+ ## Planned
12
+
13
+ ### macOS / Linux Support
14
+
15
+ The tool is currently **Windows-only verified**. macOS and Linux are expected to work via `node-pty` (which uses forkpty instead of ConPTY), but the following items need verification:
16
+
17
+ **TODOs:**
18
+ - [ ] Verify ANSI response color on macOS (is it still `RGB(232,234,237)` or does agy use a different palette?)
19
+ - [ ] Verify ANSI response color on Linux
20
+ - [ ] Handle agy v1.0.x (Homebrew `antigravity-cli`) which lacks `--model` flag — make `--model` conditional or skip it when agy version < 1.1
21
+ - [ ] Test node-pty spawn-helper permissions after `npm install` on macOS (prebuilt binaries need +x)
22
+ - [ ] Test trust dialog auto-confirmation flow on macOS/Linux
23
+ - [ ] Add platform-specific CI smoke tests (requires agy authentication in CI — may need to remain manual)
24
+
25
+ **Diagnostics available now:**
26
+ - `--debug` flag saves raw PTY output to `agy-debug.log` — inspect for actual ANSI color codes on any platform
27
+ - `AGY_COMPANION_RESPONSE_RGB` environment variable override (planned, not yet implemented)
28
+
29
+ ### Color Fallback / Auto-Probe
30
+ The current ANSI color extraction relies on `RGB(232,234,237)` as the response color. This has been verified on Windows (ConPTY). If agy changes its color scheme or uses different values on macOS/Linux, extraction silently fails.
31
+
32
+ **Ideas:**
33
+ - `--probe-color`: Run a known-answer prompt ("What is 2+2?"), scan the raw ANSI stream for the color that wraps "4", and cache it per platform
34
+ - Platform-specific RGB override via environment variable (`AGY_COMPANION_RESPONSE_RGB`)
35
+ - Heuristic: find the most frequent non-UI color in the stream
36
+
37
+ ### Multi-Turn Mode
38
+ Currently, each invocation spawns a fresh agy process (one question, one answer). A persistent mode that keeps the PTY alive across multiple prompts would reduce startup overhead for batch workloads.
39
+
40
+ ### Streaming Output
41
+ Emit response tokens as they arrive (line-by-line or chunk-by-chunk) instead of buffering until completion. Useful for long responses where the caller wants progressive output.
42
+
43
+ ### Response Format Detection
44
+ Detect whether agy's response is Markdown, JSON, or plain text and expose this in the JSON output (`"format": "markdown"`).
45
+
46
+ ## Completed (v1.2.0-alpha.1)
47
+
48
+ - Trust dialog auto-confirmation (5-phase state machine)
49
+ - Banner model detection (actual model from agy's banner)
50
+ - Short response noise filter fix (answers like "4" or "42")
51
+ - Prompt-echo stripping in no-tools mode (ConPTY space-loss tolerant)
52
+ - Cross-platform agy binary auto-detection
53
+ - node-pty as standard npm dependency
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "companion-for-agy",
3
+ "version": "1.2.0-alpha.1",
4
+ "description": "Unofficial PTY-based wrapper for agy (Antigravity CLI / Gemini CLI) — captures Gemini responses that agy writes to the terminal buffer instead of stdout",
5
+ "type": "module",
6
+ "main": "src/agy-companion.mjs",
7
+ "bin": {
8
+ "companion-for-agy": "src/agy-companion.mjs",
9
+ "agy-companion": "src/agy-companion.mjs"
10
+ },
11
+ "files": [
12
+ "src/agy-companion.mjs",
13
+ "README.md",
14
+ "README_de.md",
15
+ "CHANGELOG.md",
16
+ "ROADMAP.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "start": "node src/agy-companion.mjs",
21
+ "test": "node --test _tests/unit.test.mjs _tests/fixture.test.mjs",
22
+ "test:unit": "node --test _tests/unit.test.mjs",
23
+ "test:fixture": "node --test _tests/fixture.test.mjs",
24
+ "test:smoke": "node --test _tests/smoke.test.mjs",
25
+ "test:all": "node --test _tests/unit.test.mjs _tests/fixture.test.mjs _tests/smoke.test.mjs",
26
+ "deploy": "node -e \"require('fs').copyFileSync('src/agy-companion.mjs', require('path').join(require('os').homedir(), '.claude', 'scripts', 'agy-companion.mjs'));console.log('Deployed to ~/.claude/scripts/agy-companion.mjs')\"",
27
+ "sync": "git pull && npm run deploy"
28
+ },
29
+ "keywords": [
30
+ "agy",
31
+ "antigravity",
32
+ "gemini",
33
+ "gemini-cli",
34
+ "cli",
35
+ "wrapper",
36
+ "pty",
37
+ "conpty",
38
+ "claude-code",
39
+ "multi-agent",
40
+ "llm"
41
+ ],
42
+ "author": "Lukas Geiger",
43
+ "license": "MIT",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/dev-bricks/companion-for-agy.git"
47
+ },
48
+ "bugs": {
49
+ "url": "https://github.com/dev-bricks/companion-for-agy/issues"
50
+ },
51
+ "homepage": "https://github.com/dev-bricks/companion-for-agy#readme",
52
+ "engines": {
53
+ "node": ">=18.0.0"
54
+ },
55
+ "dependencies": {
56
+ "node-pty": "^1.0.0"
57
+ }
58
+ }
@@ -0,0 +1,670 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * companion-for-agy — Unofficial PTY-based wrapper for agy (Antigravity CLI / Gemini CLI)
4
+ *
5
+ * Problem: agy's TUI text-drip renderer writes to the terminal buffer, not stdout.
6
+ * From subprocesses (Claude Code, Codex, CI/CD), no output is capturable.
7
+ * Additionally, GEMINI.md triggers a "first action" that consumes the
8
+ * single -p turn for session initialization.
9
+ *
10
+ * Solution: 1. node-pty creates a virtual terminal (ConPTY on Windows, forkpty on Unix)
11
+ * 2. Interactive mode: wait for init, THEN send the actual question
12
+ * 3. Extract response via ANSI color ([38;2;232;234;237m = response color)
13
+ * 4. Fallback: line-based noise filtering
14
+ *
15
+ * Usage:
16
+ * agy-companion [options] "prompt"
17
+ * node src/agy-companion.mjs [options] "prompt"
18
+ */
19
+
20
+ import { createRequire } from 'node:module';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { execFileSync, execSync } from 'node:child_process';
23
+ import path from 'node:path';
24
+ import fs from 'node:fs';
25
+ import os from 'node:os';
26
+ import process from 'node:process';
27
+
28
+ // ---------- Defaults ----------
29
+
30
+ export const DEFAULT_MODEL = 'gemini-3.5-flash';
31
+ export const DEFAULT_TIMEOUT_MS = 120000;
32
+ export const RESPONSE_IDLE_MS = 10000;
33
+ export const RESPONSE_DONE_IDLE_MS = 2500;
34
+
35
+ const require = createRequire(import.meta.url);
36
+
37
+ // ---------- Auto-Detection ----------
38
+
39
+ export function findAgyPath() {
40
+ if (process.env.AGY_COMPANION_AGY_PATH) return process.env.AGY_COMPANION_AGY_PATH;
41
+ if (process.env.AGY_PATH) return process.env.AGY_PATH;
42
+
43
+ const agyName = process.platform === 'win32' ? 'agy.exe' : 'agy';
44
+
45
+ try {
46
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
47
+ const result = execFileSync(cmd, [agyName], {
48
+ encoding: 'utf8',
49
+ stdio: ['pipe', 'pipe', 'pipe'],
50
+ }).trim();
51
+ const firstLine = result.split(/\r?\n/)[0].trim();
52
+ if (firstLine && fs.existsSync(firstLine)) return firstLine;
53
+ } catch (_) {}
54
+
55
+ if (process.platform === 'win32') {
56
+ const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
57
+ const candidate = path.join(localApp, 'agy', 'bin', 'agy.exe');
58
+ if (fs.existsSync(candidate)) return candidate;
59
+ } else {
60
+ for (const p of [
61
+ path.join(os.homedir(), '.local', 'bin', 'agy'),
62
+ '/usr/local/bin/agy',
63
+ '/opt/homebrew/bin/agy',
64
+ ]) {
65
+ if (fs.existsSync(p)) return p;
66
+ }
67
+ }
68
+
69
+ return null;
70
+ }
71
+
72
+ export const AGY_PATH = findAgyPath();
73
+
74
+ // ---------- Permission-Presets ----------
75
+
76
+ export const PERMISSION_PRESETS = {
77
+ sandbox: {
78
+ agyFlags: ['--sandbox'],
79
+ allow: [],
80
+ deny: [],
81
+ },
82
+ 'skip-permissions': {
83
+ agyFlags: ['--dangerously-skip-permissions'],
84
+ allow: [],
85
+ deny: [],
86
+ },
87
+ 'no-tools': {
88
+ agyFlags: ['--sandbox'],
89
+ allow: [],
90
+ deny: ['command(*)', 'write_file(*)', 'edit_file(*)', 'read_file(*)'],
91
+ promptPrefix: 'IMPORTANT: Do not use any tools. Answer based on your knowledge only.\n\n',
92
+ },
93
+ researcher: {
94
+ agyFlags: ['--sandbox'],
95
+ allow: ['google_search(*)', 'web_search(*)', 'web_fetch(*)', 'read_file(*)'],
96
+ deny: ['write_file(*)', 'edit_file(*)', 'command(rm *)', 'command(del *)'],
97
+ },
98
+ 'read-only': {
99
+ agyFlags: ['--sandbox'],
100
+ allow: ['read_file(*)'],
101
+ deny: ['write_file(*)', 'edit_file(*)', 'command(rm *)', 'command(del *)'],
102
+ },
103
+ };
104
+
105
+ // ---------- State-Machine-Patterns ----------
106
+
107
+ export const TRUST_DIALOG_PATTERN = /Do you trust/;
108
+ export const BANNER_MODEL_PATTERN = /Gemini \d[\d.]* \w+(?:\s*\([^)]*\))?/;
109
+
110
+ export const STARTUP_DONE_PATTERNS = [
111
+ /\? for shortcuts/,
112
+ ];
113
+
114
+ export const INIT_DONE_PATTERNS = [
115
+ /Zusammenfassung der Arbeit/,
116
+ /Ich (bin|verwende|laufe) (derzeit |gerade )?(das Modell|auf dem Modell)/i,
117
+ /Das aktive Modell/i,
118
+ /Modell wurde als/i,
119
+ /aktive Modell/i,
120
+ /active model/i,
121
+ /using model/i,
122
+ /session (initialized|started|ready)/i,
123
+ ];
124
+
125
+ export const INIT_FALLBACK_MS = 20000;
126
+
127
+ // ---------- Prompt-Sanitisierung ----------
128
+
129
+ export function sanitizeForPty(text) {
130
+ return text
131
+ .replace(/[\x00-\x08\x0b\x0e-\x1f]/g, '')
132
+ .replace(/\x03/g, '')
133
+ .replace(/\r\n|\r|\n/g, ' ');
134
+ }
135
+
136
+ // ---------- ANSI/VT100 Bereinigung ----------
137
+
138
+ export function stripAnsi(raw) {
139
+ return raw
140
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
141
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
142
+ .replace(/\x1b[()][AB012]/g, '')
143
+ .replace(/\x1b[=><NOMlmiHI78DEHMNO]/g, '')
144
+ .replace(/\x1b./g, '')
145
+ .replace(/[\x00-\x08\x0b\x0e-\x1f\x7f]/g, '')
146
+ .replace(/\r\n/g, '\n')
147
+ .replace(/\r/g, '\n');
148
+ }
149
+
150
+ export function isNoiseLine(line, promptFilter = '') {
151
+ const t = line.trim();
152
+ if (!t) return true;
153
+ if (/^[│┌└┐┘├┤┬┴┼─═╔╗╚╝╠╣╦╩╬▸►◉●▲▼◆□■╭╮╯╰]+$/.test(t)) return true;
154
+ if (t.startsWith('>')) return true;
155
+ if (/[⣾⣷⣯⣟⡿⢿⣻⣽⠿⠾⠽⠼⠻⠺⠹⠸⠷⠶⠵⠴⠳⠲⠱⠰]/.test(t)) return true;
156
+ if (/Generating|esc to cancel|for shortcuts|tokens/i.test(t)) return true;
157
+ if (t === '?' || t === '? for shortcuts') return true;
158
+ if (/^Gemini \d/.test(t)) return true;
159
+ if (/^\d+\s*tokens$/.test(t)) return true;
160
+ if (/^▸\s/.test(t)) return true;
161
+ if (/^(Checking|Reading|Writing|Searching|Fetching|Analyzing|Executing)\b/i.test(t)) return true;
162
+ if (t.includes('@googlemail.com') || t.includes('@gmail.com')) return true;
163
+ if (promptFilter && t.includes(promptFilter.slice(0, 20))) return true;
164
+ return false;
165
+ }
166
+
167
+ // ---------- Response-Extraktion via ANSI-Farbe ----------
168
+
169
+ export function extractByResponseColor(rawSection) {
170
+ const segments = [];
171
+ let inResponseColor = false;
172
+ let pos = 0;
173
+ const src = rawSection;
174
+
175
+ while (pos < src.length) {
176
+ if (src[pos] === '\x1b') {
177
+ if (src[pos + 1] === '[') {
178
+ let end = pos + 2;
179
+ while (end < src.length && !/[A-Za-z]/.test(src[end])) end++;
180
+ const cmd = src[end];
181
+ const params = src.slice(pos + 2, end);
182
+ if (params === '38;2;232;234;237' && cmd === 'm') {
183
+ inResponseColor = true;
184
+ } else if (cmd === 'm') {
185
+ inResponseColor = false;
186
+ }
187
+ pos = end + 1;
188
+ } else if (src[pos + 1] === ']') {
189
+ let end = pos + 2;
190
+ while (end < src.length && src[end] !== '\x07' &&
191
+ !(src[end] === '\x1b' && src[end + 1] === '\\')) end++;
192
+ pos = src[end] === '\x07' ? end + 1 : end + 2;
193
+ } else {
194
+ pos += 2;
195
+ }
196
+ } else if (inResponseColor) {
197
+ let textEnd = pos;
198
+ while (textEnd < src.length && src[textEnd] !== '\x1b') textEnd++;
199
+ const rawText = src.slice(pos, textEnd);
200
+ const text = rawText.replace(/[\x00-\x08\x0b\x0e-\x1f\x7f]/g, '');
201
+ if (text.length > 0) segments.push(text);
202
+ pos = textEnd;
203
+ } else {
204
+ pos++;
205
+ }
206
+ }
207
+
208
+ if (segments.length === 0) return null;
209
+
210
+ const deduped = segments.filter((s, i) =>
211
+ !segments.some((o, j) => j !== i && o.length > s.length && o.startsWith(s))
212
+ );
213
+
214
+ const combined = deduped.join('').trim();
215
+ return combined.length > 0 ? combined : null;
216
+ }
217
+
218
+ export function escapeRegex(s) {
219
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
220
+ }
221
+
222
+ export function stripPromptEcho(text, filter) {
223
+ if (!filter || !text) return null;
224
+ const normText = text.replace(/\s+/g, '');
225
+ const normFilter = filter.replace(/\s+/g, '');
226
+ const matchLen = Math.min(30, normFilter.length);
227
+ if (matchLen < 5 || !normText.includes(normFilter.slice(0, matchLen))) return null;
228
+ const words = filter.split(/\s+/).filter(w => w.length > 0);
229
+ const regexStr = words.map(w => escapeRegex(w)).join('\\s*');
230
+ const clean = text.replace(new RegExp(regexStr), '').trim();
231
+ return clean.length > 0 ? clean : '';
232
+ }
233
+
234
+ export function extractResponse(stripped, rawSection, promptFilter = '', effectiveFilter = '') {
235
+ if (rawSection) {
236
+ const colorResult = extractByResponseColor(rawSection);
237
+ if (colorResult && colorResult.length > 0) {
238
+ const echoFilter = effectiveFilter || promptFilter;
239
+ if (echoFilter) {
240
+ const cleaned = stripPromptEcho(colorResult, echoFilter);
241
+ if (cleaned !== null) return cleaned || null;
242
+ }
243
+ if (promptFilter && promptFilter !== echoFilter) {
244
+ const cleaned = stripPromptEcho(colorResult, promptFilter);
245
+ if (cleaned !== null) return cleaned || null;
246
+ }
247
+ return colorResult;
248
+ }
249
+ }
250
+
251
+ const lines = stripped.split('\n');
252
+ const meaningful = lines.filter(l => !isNoiseLine(l, promptFilter));
253
+ if (meaningful.length === 0) return null;
254
+
255
+ let best = meaningful.join('\n').trim();
256
+ if (promptFilter) {
257
+ const promptWords = promptFilter.split(/\s+/).slice(0, 5).join('');
258
+ best = best.split('\n')
259
+ .filter(l => !l.replace(/\s/g, '').startsWith(promptWords.replace(/\s/g, '')))
260
+ .join('\n')
261
+ .trim();
262
+ }
263
+ return best || null;
264
+ }
265
+
266
+ // ---------- CLI Main ----------
267
+
268
+ const __filename = fileURLToPath(import.meta.url);
269
+
270
+ function isMainModule() {
271
+ try {
272
+ if (!process.argv[1]) return false;
273
+ return fs.realpathSync(path.resolve(process.argv[1])) === fs.realpathSync(path.resolve(__filename));
274
+ } catch (_) {
275
+ return path.resolve(process.argv[1] || '') === path.resolve(__filename);
276
+ }
277
+ }
278
+
279
+ if (isMainModule()) {
280
+
281
+ function printUsage() {
282
+ process.stderr.write([
283
+ 'companion-for-agy — Unofficial PTY wrapper for agy (Antigravity CLI / Gemini CLI)',
284
+ '',
285
+ 'Usage: companion-for-agy [options] "prompt"',
286
+ '',
287
+ 'Permission modes (mutually exclusive):',
288
+ ' --sandbox Sandbox mode (default) — tools in container',
289
+ ' --skip-permissions All tools without confirmation (YOLO)',
290
+ ' --no-tools Pure chat — no tool execution',
291
+ ' --researcher Web search allowed, no file changes',
292
+ ' --read-only Read-only, no modifications',
293
+ '',
294
+ 'Custom rules (combinable with modes):',
295
+ ' --allow <pattern> Allowlist rule (repeatable)',
296
+ ' --deny <pattern> Denylist rule (repeatable)',
297
+ ' Formats: read_file(/path), command(git), write_file(*)',
298
+ '',
299
+ 'Options:',
300
+ ' --model <model> Gemini model (default: gemini-3.5-flash)',
301
+ ' --timeout <ms> Timeout in ms (default: 120000)',
302
+ ' --json Output as JSON object',
303
+ ' --debug Save raw PTY output to agy-debug.log',
304
+ ' --help Show this help',
305
+ '',
306
+ 'Models:',
307
+ ' gemini-1.5-flash gemini-1.5-pro',
308
+ ' gemini-2.0-flash gemini-2.0-pro',
309
+ ' gemini-3.5-flash gemini-3.5-pro',
310
+ '',
311
+ 'Environment variables:',
312
+ ' AGY_COMPANION_AGY_PATH Path to agy binary',
313
+ ' AGY_PATH Alternative path to agy binary',
314
+ '',
315
+ 'Examples:',
316
+ ' companion-for-agy "What is the capital of Bavaria?"',
317
+ ' companion-for-agy --no-tools "Review this code: ..."',
318
+ ' companion-for-agy --researcher "Latest info on Node.js 24"',
319
+ ' companion-for-agy --read-only --allow "command(git log)" "prompt"',
320
+ ' companion-for-agy --json --model gemini-3.5-pro "prompt"',
321
+ ].join('\n') + '\n');
322
+ }
323
+
324
+ const rawArgs = process.argv.slice(2);
325
+ if (rawArgs.length === 0 || rawArgs[0] === '--help' || rawArgs[0] === '-h') {
326
+ printUsage();
327
+ process.exit(0);
328
+ }
329
+
330
+ let model = DEFAULT_MODEL;
331
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
332
+ let debug = false;
333
+ let jsonOutput = false;
334
+ let permissionMode = 'sandbox';
335
+ const customAllow = [];
336
+ const customDeny = [];
337
+ let userPromptForFilter = '';
338
+ let effectivePromptForFilter = '';
339
+ const promptParts = [];
340
+
341
+ for (let i = 0; i < rawArgs.length; i++) {
342
+ const arg = rawArgs[i];
343
+ if ((arg === '--model' || arg === '-m') && rawArgs[i + 1]) {
344
+ model = rawArgs[++i];
345
+ } else if (arg === '--timeout' && rawArgs[i + 1]) {
346
+ const t = parseInt(rawArgs[++i], 10);
347
+ if (!isNaN(t) && t > 0) timeoutMs = t;
348
+ } else if (arg === '--debug') {
349
+ debug = true;
350
+ } else if (arg === '--json') {
351
+ jsonOutput = true;
352
+ } else if (arg === '--sandbox') {
353
+ permissionMode = 'sandbox';
354
+ } else if (arg === '--skip-permissions') {
355
+ permissionMode = 'skip-permissions';
356
+ } else if (arg === '--no-tools') {
357
+ permissionMode = 'no-tools';
358
+ } else if (arg === '--researcher') {
359
+ permissionMode = 'researcher';
360
+ } else if (arg === '--read-only') {
361
+ permissionMode = 'read-only';
362
+ } else if (arg === '--allow' && rawArgs[i + 1]) {
363
+ customAllow.push(rawArgs[++i]);
364
+ } else if (arg === '--deny' && rawArgs[i + 1]) {
365
+ customDeny.push(rawArgs[++i]);
366
+ } else if (!arg.startsWith('-')) {
367
+ promptParts.push(arg);
368
+ }
369
+ }
370
+
371
+ const userPrompt = promptParts.join(' ').trim();
372
+ if (!userPrompt) {
373
+ process.stderr.write('Error: No prompt provided.\n\n');
374
+ printUsage();
375
+ process.exit(1);
376
+ }
377
+ userPromptForFilter = userPrompt;
378
+
379
+ // ---------- Permission-Setup ----------
380
+
381
+ const preset = PERMISSION_PRESETS[permissionMode];
382
+ const allAllow = [...preset.allow, ...customAllow];
383
+ const allDeny = [...preset.deny, ...customDeny];
384
+
385
+ const tempWorkspace = path.join(os.tmpdir(), `agy-companion-${process.pid}`);
386
+ let tempSettingsCreated = false;
387
+
388
+ if (allAllow.length > 0 || allDeny.length > 0) {
389
+ const geminiDir = path.join(tempWorkspace, '.gemini');
390
+ fs.mkdirSync(geminiDir, { recursive: true });
391
+ const settings = { permissions: {} };
392
+ if (allAllow.length > 0) settings.permissions.allow = allAllow;
393
+ if (allDeny.length > 0) settings.permissions.deny = allDeny;
394
+ fs.writeFileSync(path.join(geminiDir, 'settings.json'), JSON.stringify(settings, null, 2));
395
+ tempSettingsCreated = true;
396
+ } else {
397
+ fs.mkdirSync(tempWorkspace, { recursive: true });
398
+ }
399
+
400
+ const promptPrefix = preset.promptPrefix || '';
401
+ const effectivePrompt = promptPrefix + userPrompt;
402
+ effectivePromptForFilter = effectivePrompt;
403
+
404
+ // ---------- node-pty ----------
405
+
406
+ let pty;
407
+ const nodePtyOverride = process.env.AGY_COMPANION_PTY_PATH;
408
+
409
+ if (nodePtyOverride) {
410
+ try {
411
+ pty = require(nodePtyOverride);
412
+ } catch (err) {
413
+ process.stderr.write(`[agy-companion] Failed to load node-pty from ${nodePtyOverride}:\n ${err.message}\n`);
414
+ process.exit(1);
415
+ }
416
+ } else {
417
+ try {
418
+ pty = require('node-pty');
419
+ } catch (_) {
420
+ let loaded = false;
421
+ const geminiPtySuffix = path.join('@google', 'gemini-cli', 'node_modules', 'node-pty');
422
+ const candidates = [];
423
+
424
+ if (process.platform === 'win32' && process.env.APPDATA) {
425
+ candidates.push(path.join(process.env.APPDATA, 'npm', 'node_modules', geminiPtySuffix));
426
+ }
427
+ try {
428
+ const globalRoot = execSync('npm root -g', {
429
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
430
+ }).trim();
431
+ candidates.push(path.join(globalRoot, geminiPtySuffix));
432
+ } catch (_) { /* npm not available */ }
433
+
434
+ for (const candidate of candidates) {
435
+ try {
436
+ pty = require(candidate);
437
+ loaded = true;
438
+ break;
439
+ } catch (_) { /* try next */ }
440
+ }
441
+
442
+ if (!loaded) {
443
+ process.stderr.write(
444
+ `[agy-companion] Failed to load node-pty.\n` +
445
+ `[agy-companion] Install via: npm install -g companion-for-agy\n` +
446
+ `[agy-companion] Or set AGY_COMPANION_PTY_PATH environment variable.\n`
447
+ );
448
+ process.exit(1);
449
+ }
450
+ }
451
+ }
452
+
453
+ // ---------- Resolve agy path ----------
454
+
455
+ const resolvedAgyPath = AGY_PATH;
456
+ if (!resolvedAgyPath) {
457
+ process.stderr.write(
458
+ `[agy-companion] agy not found.\n` +
459
+ `[agy-companion] Install agy: https://github.com/google-gemini/gemini-cli\n` +
460
+ `[agy-companion] Or set AGY_PATH / AGY_COMPANION_AGY_PATH environment variable.\n`
461
+ );
462
+ process.exit(1);
463
+ }
464
+
465
+ if (!fs.existsSync(resolvedAgyPath)) {
466
+ process.stderr.write(`[agy-companion] agy not found at: ${resolvedAgyPath}\n`);
467
+ process.exit(1);
468
+ }
469
+
470
+ // ---------- Start agy ----------
471
+
472
+ const agyArgs = ['--model', model, ...preset.agyFlags];
473
+
474
+ process.stderr.write(
475
+ `[agy-companion] Starting agy ${agyArgs.join(' ')} (${permissionMode})...\n`
476
+ );
477
+
478
+ const ptyProc = pty.spawn(resolvedAgyPath, agyArgs, {
479
+ name: 'xterm-256color',
480
+ cols: 220,
481
+ rows: 50,
482
+ cwd: tempWorkspace,
483
+ env: { ...process.env, TERM: 'xterm-256color' },
484
+ });
485
+
486
+ let rawBuffer = '';
487
+ let detectedModel = null;
488
+ let trustHandled = false;
489
+ let startupComplete = false;
490
+ let initDone = false;
491
+ let questionSent = false;
492
+ let responseStartMark = 0;
493
+ let initIdleTimer = null;
494
+ let responseIdleTimer = null;
495
+ let finished = false;
496
+
497
+ const globalTimeout = setTimeout(() => {
498
+ if (!finished) {
499
+ process.stderr.write(`[agy-companion] Global timeout (${timeoutMs}ms). Aborting.\n`);
500
+ shutdown(2);
501
+ }
502
+ }, timeoutMs);
503
+
504
+ function cleanupTemp() {
505
+ try {
506
+ if (tempSettingsCreated) {
507
+ fs.rmSync(tempWorkspace, { recursive: true, force: true });
508
+ } else {
509
+ fs.rmdirSync(tempWorkspace);
510
+ }
511
+ } catch (_) {}
512
+ }
513
+
514
+ function shutdown(code) {
515
+ if (finished) return;
516
+ finished = true;
517
+ clearTimeout(globalTimeout);
518
+ clearTimeout(initIdleTimer);
519
+ clearTimeout(responseIdleTimer);
520
+
521
+ try { ptyProc.write('\x03'); } catch (_) {}
522
+ setTimeout(() => {
523
+ try { ptyProc.kill(); } catch (_) {}
524
+
525
+ if (debug) {
526
+ const debugPath = path.resolve('agy-debug.log');
527
+ try { fs.writeFileSync(debugPath, rawBuffer, 'utf8'); } catch (_) {}
528
+ process.stderr.write(`[agy-companion] Debug log: ${debugPath}\n`);
529
+ }
530
+
531
+ cleanupTemp();
532
+ process.exit(code);
533
+ }, 500);
534
+ }
535
+
536
+ function deliverResponse() {
537
+ const responsePart = rawBuffer.slice(responseStartMark);
538
+ const stripped = stripAnsi(responsePart);
539
+ const response = extractResponse(stripped, responsePart, userPromptForFilter, effectivePromptForFilter);
540
+
541
+ if (response) {
542
+ outputResult(response);
543
+ shutdown(0);
544
+ } else {
545
+ const fullStripped = stripAnsi(rawBuffer);
546
+ const fallback = extractResponse(fullStripped, rawBuffer, userPromptForFilter, effectivePromptForFilter);
547
+ if (fallback) {
548
+ outputResult(fallback);
549
+ } else {
550
+ process.stderr.write('[agy-companion] No usable response received.\n');
551
+ }
552
+ shutdown(0);
553
+ }
554
+ }
555
+
556
+ function outputResult(text) {
557
+ if (jsonOutput) {
558
+ const result = { response: text, model: detectedModel || model, requestedModel: model, permissionMode };
559
+ process.stdout.write(JSON.stringify(result) + '\n');
560
+ } else {
561
+ process.stdout.write(text + '\n');
562
+ }
563
+ }
564
+
565
+ function sendQuestion() {
566
+ if (questionSent) return;
567
+ questionSent = true;
568
+ responseStartMark = rawBuffer.length;
569
+ process.stderr.write(`[agy-companion] Init complete. Sending question...\n`);
570
+ ptyProc.write(sanitizeForPty(effectivePrompt) + '\r');
571
+
572
+ responseIdleTimer = setTimeout(() => {
573
+ process.stderr.write(`[agy-companion] Response idle timeout. Extracting...\n`);
574
+ deliverResponse();
575
+ }, RESPONSE_IDLE_MS);
576
+ }
577
+
578
+ ptyProc.onData(chunk => {
579
+ rawBuffer += chunk;
580
+ const recentStripped = stripAnsi(rawBuffer.slice(-3000));
581
+
582
+ if (!questionSent && !finished) {
583
+ if (!detectedModel) {
584
+ const modelMatch = recentStripped.match(BANNER_MODEL_PATTERN);
585
+ if (modelMatch) {
586
+ detectedModel = modelMatch[0];
587
+ process.stderr.write(`[agy-companion] Detected model: ${detectedModel}\n`);
588
+ }
589
+ }
590
+
591
+ if (!trustHandled && TRUST_DIALOG_PATTERN.test(recentStripped)) {
592
+ trustHandled = true;
593
+ process.stderr.write(`[agy-companion] Trust dialog detected. Auto-confirming...\n`);
594
+ ptyProc.write('\r');
595
+ }
596
+
597
+ if (!startupComplete) {
598
+ if (STARTUP_DONE_PATTERNS.some(p => p.test(recentStripped))) {
599
+ startupComplete = true;
600
+ process.stderr.write(`[agy-companion] Startup complete. Waiting for init...\n`);
601
+ clearTimeout(initIdleTimer);
602
+ initIdleTimer = setTimeout(() => {
603
+ if (!initDone && !questionSent) {
604
+ initDone = true;
605
+ process.stderr.write(`[agy-companion] Init fallback timeout (${INIT_FALLBACK_MS}ms).\n`);
606
+ sendQuestion();
607
+ }
608
+ }, INIT_FALLBACK_MS);
609
+ }
610
+ }
611
+
612
+ if (startupComplete && !initDone) {
613
+ if (INIT_DONE_PATTERNS.some(p => p.test(recentStripped))) {
614
+ initDone = true;
615
+ clearTimeout(initIdleTimer);
616
+ process.stderr.write(`[agy-companion] Init detected. Brief pause...\n`);
617
+ setTimeout(sendQuestion, 1000);
618
+ } else {
619
+ clearTimeout(initIdleTimer);
620
+ initIdleTimer = setTimeout(() => {
621
+ if (!initDone && !questionSent) {
622
+ initDone = true;
623
+ process.stderr.write(`[agy-companion] Init idle timeout.\n`);
624
+ sendQuestion();
625
+ }
626
+ }, INIT_FALLBACK_MS);
627
+ }
628
+ }
629
+ } else if (questionSent && !finished) {
630
+ clearTimeout(responseIdleTimer);
631
+
632
+ const responseSoFar = stripAnsi(rawBuffer.slice(responseStartMark));
633
+ const respLines = responseSoFar.split('\n');
634
+ let seenQuestionEcho = false;
635
+ let responseComplete = false;
636
+ for (const line of respLines) {
637
+ const t = line.trim();
638
+ if (!seenQuestionEcho && userPromptForFilter && t.includes(userPromptForFilter.slice(0, 15))) {
639
+ seenQuestionEcho = true;
640
+ } else if (seenQuestionEcho && t === '>') {
641
+ responseComplete = true;
642
+ break;
643
+ }
644
+ }
645
+
646
+ if (responseComplete) {
647
+ responseIdleTimer = setTimeout(() => {
648
+ process.stderr.write(`[agy-companion] Response complete.\n`);
649
+ deliverResponse();
650
+ }, RESPONSE_DONE_IDLE_MS);
651
+ } else {
652
+ responseIdleTimer = setTimeout(() => {
653
+ process.stderr.write(`[agy-companion] Response idle timeout.\n`);
654
+ deliverResponse();
655
+ }, RESPONSE_IDLE_MS);
656
+ }
657
+ }
658
+ });
659
+
660
+ ptyProc.onExit(({ exitCode }) => {
661
+ if (!finished) {
662
+ if (questionSent) {
663
+ deliverResponse();
664
+ } else {
665
+ process.stderr.write('[agy-companion] agy exited before init completed.\n');
666
+ shutdown(exitCode ?? 1);
667
+ }
668
+ }
669
+ });
670
+ }