@lazyingart/agintiflow 0.17.1 → 0.17.3

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
@@ -79,13 +79,13 @@ For larger repositories, use `--profile large-codebase` or choose **Large codeba
79
79
 
80
80
  AgInTiFlow can also spend cheap DeepSeek calls on parallel scout notes before the main executor starts a complicated task. It first writes a bounded project map to `.aginti/codebase-map.json`, then runs scouts for architecture, implementation, review, research, context mapping, tests, git workflow, integration, symbol tracing, and dependency risks. A coordinator Swarm Board is injected for the main agent and saved as `artifacts/scout-blackboard.json` in the session. The executor still does the real file/shell/browser work itself. Disable with `--no-parallel-scouts` or set `--scout-count 1..10`.
81
81
 
82
- The next productive-agent roadmap is tracked in [docs/productive-agent-roadmap.md](docs/productive-agent-roadmap.md): durable codebase maps, stronger scout blackboards, long-run checkpoints, LSP/symbol tools, test triage, and release automation.
82
+ The next productive-agent roadmap is tracked in [docs/productive-agent-roadmap.md](docs/productive-agent-roadmap.md): durable codebase maps, stronger scout blackboards, long-run checkpoints, LSP/symbol tools, test triage, and release automation. Runtime choices, Docker persistence, host full-access tradeoffs, tmux sessions, and rolling-plan autonomy are documented in [docs/runtime-modes-and-autonomy.md](docs/runtime-modes-and-autonomy.md). The supervised self-development protocol is in [docs/self-development-supervision.md](docs/self-development-supervision.md).
83
83
 
84
84
  For current docs, install errors, package/toolchain setup, and source discovery, the agent has a guarded `web_search` tool. It returns compact search results without browser search-engine loops and respects configured domain allowlists. Disable with `--no-web-search`.
85
85
 
86
86
  For raster image work, AgInTiFlow has an optional `image_generation` skill backed by the `generate_image` tool and a local `GRSAI` key. The skill tells DeepSeek when image generation is appropriate; the tool calls GRS AI Nano Banana, saves manifests/images under `artifacts/images`, and sends the result to the canvas. See [docs/auxiliary-image-generation.md](docs/auxiliary-image-generation.md).
87
87
 
88
- For long-running shell work, AgInTiFlow exposes host-side tmux tools when the shell tool is enabled: `tmux_list_sessions`, `tmux_capture_pane`, `tmux_send_keys`, and `tmux_start_session`. Use normal prompts such as `start this test server in tmux and monitor it` or `check my tmux session`. The agent captures panes before interacting, redacts outputs, blocks secret-like sends, and avoids sending sudo passwords or destructive commands.
88
+ For long-running shell work, AgInTiFlow exposes host-side tmux tools when the shell tool is enabled: `tmux_list_sessions`, `tmux_capture_pane`, `tmux_send_keys`, and `tmux_start_session`. Use normal prompts such as `start this test server in tmux and monitor it` or `check my tmux session`. The agent captures panes before interacting, redacts outputs, blocks secret-like sends, and avoids sending sudo passwords or destructive commands. Tmux is intentionally host-side: Docker `run_command` containers are short-lived, so tmux servers started there cannot persist.
89
89
 
90
90
  AgInTiFlow now ships a Markdown skill library in `skills/<id>/SKILL.md`. Skills are prompt playbooks, while tools are deterministic actions such as `apply_patch`, `run_command`, `web_search`, `generate_image`, and `send_to_canvas`. Built-in skills cover code, websites/apps, LaTeX manuscripts, books, Word documents, image generation, GitHub, system maintenance, Android, R/Stan, Python, C/C++, shell, AAPS, and novel writing. See [docs/skills-and-tools.md](docs/skills-and-tools.md).
91
91
 
@@ -17,6 +17,8 @@ Runs can be stopped without corrupting session state. The CLI listens for Ctrl+C
17
17
 
18
18
  Default execution is Docker workspace mode with package installs approved inside the sandbox. The project is mounted at `/workspace`; persistent agent toolchain folders are mounted at `/aginti-home`, `/aginti-cache`, and `/aginti-env` from `~/.agintiflow/docker/`. Python, conda, and other language-level environments should be installed under `/aginti-env` so they survive across runs. Apt/apk package changes are ephemeral unless the Docker image is rebuilt.
19
19
 
20
+ Docker shell commands are process-ephemeral: each `run_command` call starts a new short-lived container. Durable terminals, dev servers, and external agents should use host tmux tools, or a future persistent service-container mode. See [runtime-modes-and-autonomy.md](runtime-modes-and-autonomy.md).
21
+
20
22
  Generated local websites should use `preview_workspace` or `open_workspace_file`. The preview tool serves the host workspace on an automatically selected `127.0.0.1` port and opens it in the browser. AgInTiFlow blocks common transient Docker preview commands such as `python -m http.server` because each shell tool call runs in a short-lived container with no published host port.
21
23
 
22
24
  The failed `f(f(x)) = f'(x)` LaTeX task exposed three issues: host-mode command policy blocked setup/path commands, a 15-step budget was too small for iterative numerical work plus TeX output, and follow-up input could not be queued while the agent was running. The current runtime defaults and inbox pipe address those without hardcoding that specific math task.
@@ -27,9 +27,24 @@ AgInTiFlow should become a practical coding agent, not only a chat UI around a m
27
27
  6. Patch review loop: before finalizing large edits, run a cheap reviewer scout over diffs, risks, and missing tests.
28
28
  7. Dependency doctor: detect toolchain gaps and propose Docker/project-local setup before touching host installs.
29
29
  8. Release assistant: status, diff, changelog, version bump, pack, publish, push, and rollback notes as a reusable workflow.
30
+ 9. Rolling autonomy loop: replan by phase, gate each phase with a check, checkpoint artifacts/diffs, and continue until the requested outcome is complete or genuinely blocked.
31
+ 10. Persistent service containers: add a project-scoped long-running container mode for durable tmux/dev-server/language-server processes while preserving Docker isolation.
30
32
 
31
33
  ## Swarm Design
32
34
 
33
35
  Scouts must not become noisy subagents. Each scout gets the same bounded context pack generated from the durable codebase map and one role. The coordinator produces a Swarm Board with shared context, execution order, disagreements, must-read files, checks, and stop conditions. The main agent still owns tool use and must re-read exact files before editing.
34
36
 
35
37
  Use 3 scouts for medium tasks, 5 for large tasks, and up to 10 for complex multi-language or system tasks. More scouts are only useful when their roles cover different failure modes.
38
+
39
+ ## Long-Running Work
40
+
41
+ The agent should not stop merely because the first plan was exhausted. It should maintain a rolling phase loop:
42
+
43
+ 1. Inspect the current project state.
44
+ 2. Pick the next concrete milestone.
45
+ 3. Execute with tools.
46
+ 4. Run the narrowest useful check.
47
+ 5. Save a checkpoint event, artifact, or diff.
48
+ 6. Replan from the new state.
49
+
50
+ The loop ends only when the requested outcome is complete, the user interrupts, or a real dependency blocks progress. This avoids giant brittle plans while still supporting long autonomous work.
@@ -0,0 +1,106 @@
1
+ # Runtime Modes And Long-Running Autonomy
2
+
3
+ AgInTiFlow should feel like a normal coding agent, but with an explicit execution contract. The core decision is not "safe versus useful"; it is which execution mode matches the task.
4
+
5
+ ## Evaluation Of The Tmux-In-Docker Case
6
+
7
+ The failed task was expected under the current Docker design, but the agent should have chosen a better path.
8
+
9
+ - `run_command` in Docker mode uses short-lived `docker run --rm` containers.
10
+ - A tmux server started inside that container dies when the command exits.
11
+ - Persistent folders such as `/aginti-env` and `/aginti-cache` preserve files and language environments, not running processes.
12
+ - For durable terminal sessions, the correct tool is host-side `tmux_start_session`, followed by `tmux_capture_pane` and `tmux_send_keys`.
13
+
14
+ The fix is guidance plus guardrails: raw `tmux` shell commands are blocked inside Docker `run_command`, and the model is told to use host tmux tools for durable sessions.
15
+
16
+ ## Execution Modes
17
+
18
+ | Mode | Similar Codex idea | Best use | What persists | Risk |
19
+ | --- | --- | --- | --- | --- |
20
+ | `docker-readonly` | `-s read-only` | Inspect code, run read-only checks | Session logs only | Lowest |
21
+ | `docker-workspace` | `-s workspace-write` | Normal coding, plotting, LaTeX, package setup | Workspace files, `/aginti-env`, `/aginti-cache` | Low to medium |
22
+ | `host` conservative | Workspace access with approval | Git, host tools, local previews | Host filesystem changes in project | Medium |
23
+ | `host` trusted | `-s danger-full-access -a never` style | Autonomous computer maintenance, host services, privileged workflows | Host state | High |
24
+ | Future persistent container | Externally sandboxed full-auto | Long-running services and durable container processes | Workspace, env, running processes | Medium |
25
+
26
+ Codex exposes these concerns as `--sandbox read-only|workspace-write|danger-full-access` and `--ask-for-approval untrusted|on-failure|on-request|never`. AgInTiFlow maps the same philosophy into:
27
+
28
+ - `--sandbox-mode host|docker-readonly|docker-workspace`
29
+ - `--package-install-policy block|prompt|allow`
30
+ - `--allow-shell|--no-shell`
31
+ - `--allow-destructive`
32
+
33
+ ## Docker Package Installs
34
+
35
+ Docker package installs are safe when they match the sandbox contract.
36
+
37
+ - `npm`, `pip`, `conda`, `curl`, `wget`, and `chmod` are practical inside `docker-workspace` when package policy is `allow`.
38
+ - Python and conda-style environments should live under `/aginti-env` so they survive future runs.
39
+ - Download caches should live under `/aginti-cache`.
40
+ - OS package installs such as `apt-get install htop` affect only the current short-lived command container unless the Docker image is rebuilt.
41
+ - To make OS packages portable, add them to `docker/sandbox.Dockerfile` and rebuild with `scripts/setup-agent-toolchain-docker.sh`.
42
+
43
+ This is why a task can safely install Python packages in Docker, but cannot keep an interactive tmux server alive inside a one-shot Docker command.
44
+
45
+ ## Recommended Defaults
46
+
47
+ Default daily coding:
48
+
49
+ ```bash
50
+ aginti
51
+ ```
52
+
53
+ This starts interactive chat with Docker workspace mode, file tools, shell tools, web search, and package installs allowed inside Docker.
54
+
55
+ Direct trusted host mode:
56
+
57
+ ```bash
58
+ aginti --sandbox-mode host --allow-shell --allow-destructive "fix this local service"
59
+ ```
60
+
61
+ Use this only when the task truly needs host access. The agent should inspect first, show risky commands, stop on ambiguity, and avoid secrets.
62
+
63
+ Durable tmux task:
64
+
65
+ ```bash
66
+ aginti "start a tmux session named demo, run ls in it, keep it open, and tell me how to attach"
67
+ ```
68
+
69
+ The agent should use `tmux_start_session`, `tmux_send_keys`, and `tmux_capture_pane`, not Docker `run_command`.
70
+
71
+ ## Future Persistent Container Mode
72
+
73
+ A useful next runtime mode is a service container:
74
+
75
+ - Start one named container per project, for example `agintiflow-<project-hash>`.
76
+ - Mount the workspace, `/aginti-home`, `/aginti-cache`, and `/aginti-env`.
77
+ - Keep the container alive with a shell, supervisor, or tmux server.
78
+ - Execute later commands with `docker exec` instead of `docker run --rm`.
79
+ - Provide explicit lifecycle commands: start, status, capture, stop, reset, rebuild.
80
+
81
+ This would support durable tmux, dev servers, long installs, notebooks, and language servers inside a portable container while still isolating the host.
82
+
83
+ ## Long-Running Autonomy
84
+
85
+ Large tasks should not stop because an early high-level plan ran out. AgInTiFlow should use rolling plans:
86
+
87
+ 1. Orient: inspect project instructions, git status, manifests, and current files.
88
+ 2. Plan the next phase only: choose a concrete milestone with a check.
89
+ 3. Execute: edit files, run commands, and capture artifacts.
90
+ 4. Gate quality: run the narrowest useful test, lint, compile, preview, or smoke check.
91
+ 5. Checkpoint: persist a session event, artifact, diff, or note.
92
+ 6. Replan: if the goal is not done, generate the next phase from the current state.
93
+ 7. Stop only when the requested outcome is complete, blocked by a real dependency, or the user interrupts.
94
+
95
+ This keeps the agent sober. It avoids giant low-quality plans while still allowing long autonomous work.
96
+
97
+ ## Documentation Maintenance
98
+
99
+ The documentation should be maintained as product code:
100
+
101
+ - Keep reference docs in `docs/`.
102
+ - Link important docs from `README.md` and the marketing website.
103
+ - Add smoke tests when a workflow becomes behavior, not just guidance.
104
+ - Update `AGINTI.md` for project-specific operating memory.
105
+ - Treat docs changes as part of the release checklist before publishing npm.
106
+
@@ -0,0 +1,95 @@
1
+ # Self-Development Supervision
2
+
3
+ This document defines how to let AgInTiFlow work on its own source code under Codex supervision.
4
+
5
+ ## Readiness Verdict
6
+
7
+ AgInTiFlow is ready for supervised self-development on small and medium tasks. It has the required primitives:
8
+
9
+ - Project-local sessions and web/CLI sync.
10
+ - DeepSeek flash/pro routing with mock fallback.
11
+ - Workspace file tools, deterministic `apply_patch`, and compact diffs.
12
+ - Docker workspace mode for package installs and checks.
13
+ - Host tmux tools for durable monitored sessions.
14
+ - Guardrails for secrets, npm publishing, `.git`, `node_modules`, and destructive commands.
15
+ - Full test, smoke, and pack dry-run scripts.
16
+
17
+ It is not ready for fully unsupervised release or system-level work. Codex should remain the supervisor for commits, pushes, npm publishing, high-risk host commands, and ambiguous git states.
18
+
19
+ ## Recommended Launch
20
+
21
+ Start AgInTiFlow in a separate tmux session from the source repo:
22
+
23
+ ```bash
24
+ tmux new-session -d -s agintiflow-selfdev -c /home/lachlan/ProjectsLFS/Agent/AgInTiFlow
25
+ tmux send-keys -t agintiflow-selfdev 'aginti --profile large-codebase --parallel-scouts --scout-count 5' Enter
26
+ ```
27
+
28
+ For a single supervised task:
29
+
30
+ ```bash
31
+ aginti --profile large-codebase --parallel-scouts --scout-count 5 \
32
+ "inspect this repo, implement the requested change, run focused checks, and stop before commit"
33
+ ```
34
+
35
+ Use `aginti web --port 3221` from the same folder if a browser UI is preferred. CLI and web will share `.sessions/`.
36
+
37
+ ## Supervisor Duties
38
+
39
+ Codex should supervise by:
40
+
41
+ 1. Capturing tmux output before sending input.
42
+ 2. Keeping the task scoped and testable.
43
+ 3. Reviewing diffs before commit.
44
+ 4. Running or verifying `npm test`, `npm run pack:dry-run`, and `git diff --check` when package behavior changes.
45
+ 5. Rejecting changes that weaken guardrails without explicit reasoning.
46
+ 6. Handling commit, push, and publish steps itself unless Lachlan explicitly delegates them to AgInTiFlow.
47
+
48
+ ## Safe Task Types
49
+
50
+ Good first self-development tasks:
51
+
52
+ - Add or improve docs.
53
+ - Add smoke tests around existing behavior.
54
+ - Improve prompt wording or task profiles.
55
+ - Make small CLI/web UI polish changes.
56
+ - Add capability reports or non-invasive diagnostics.
57
+
58
+ Use stronger supervision for:
59
+
60
+ - Model-client tool schemas.
61
+ - Guardrails and command policy.
62
+ - Docker runtime changes.
63
+ - Npm publishing workflow.
64
+ - Git automation.
65
+ - Session persistence and database changes.
66
+
67
+ ## Stop Conditions
68
+
69
+ AgInTiFlow should stop and ask for supervision when it sees:
70
+
71
+ - Dirty unrelated files.
72
+ - Failing tests it cannot explain.
73
+ - Git conflicts, divergence, rebase/merge choices, or reset/checkout suggestions.
74
+ - Requests to publish npm, push tags, delete files, rotate secrets, or run host sudo.
75
+ - A plan that exceeds the current step budget without a completed checkpoint.
76
+
77
+ ## Housekeeping Before Each Self-Dev Session
78
+
79
+ Run:
80
+
81
+ ```bash
82
+ git status --short
83
+ aginti doctor
84
+ aginti doctor --capabilities
85
+ npm run check
86
+ ```
87
+
88
+ For larger changes also run:
89
+
90
+ ```bash
91
+ npm test
92
+ npm run pack:dry-run
93
+ git diff --check
94
+ ```
95
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a resumable Playwright website-control agent with OpenAI-compatible tool calling.",
6
6
  "license": "Apache-2.0",
@@ -6,10 +6,13 @@ import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { authProviderKeyHelp, authProviderKeyUrl, normalizeAuthProvider } from "../src/auth-onboarding.js";
8
8
  import { getProviderDefaults } from "../src/model-routing.js";
9
- import { providerKeyStatus, setProviderKey } from "../src/project.js";
9
+ import { maskProviderKey, providerKeyPreview, providerKeyStatus, setProviderKey } from "../src/project.js";
10
10
 
11
11
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12
12
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-auth-"));
13
+ const envKeys = ["DEEPSEEK_API_KEY", "OPENAI_API_KEY", "LLM_API_KEY", "QWEN_API_KEY", "GRSAI", "GRSAI_API_KEY"];
14
+ const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
15
+ for (const key of envKeys) delete process.env[key];
13
16
 
14
17
  function assert(condition, message) {
15
18
  if (!condition) throw new Error(message);
@@ -55,6 +58,8 @@ try {
55
58
  assert(normalizeAuthProvider("qwen") === "qwen", "qwen provider did not normalize");
56
59
  assert(authProviderKeyUrl("deepseek") === "https://platform.deepseek.com/api_keys", "DeepSeek key URL is missing");
57
60
  assert(authProviderKeyHelp("openai").includes("https://platform.openai.com/api-keys"), "OpenAI key help is missing");
61
+ assert(maskProviderKey("short") === "s…t (5 chars)", "short key mask was not compact");
62
+ assert(maskProviderKey("test-openai-key") === "test…-key (15 chars)", "long key mask did not preserve prefix/suffix");
58
63
  const qwenDefaults = getProviderDefaults("qwen");
59
64
  assert(qwenDefaults.provider === "qwen" && qwenDefaults.model, "qwen provider defaults are not available");
60
65
 
@@ -62,11 +67,16 @@ try {
62
67
  let status = providerKeyStatus(tempRoot);
63
68
  assert(status.qwen, "qwen key status was not detected");
64
69
  assert(status.envVars.qwen.includes("QWEN_API_KEY"), "qwen env var name was not reported");
70
+ const qwenPreview = providerKeyPreview(tempRoot, "qwen");
71
+ assert(qwenPreview.available && qwenPreview.preview === "test…-key (13 chars)", "qwen key preview was not masked correctly");
65
72
 
66
73
  await runCli(["keys", "set", "openai", "--stdin"], "test-openai-key");
67
74
  await runCli(["keys", "set", "grsai", "--stdin"], "test-grsai-key");
68
75
  status = providerKeyStatus(tempRoot);
69
76
  assert(status.openai && status.grsai && status.qwen, "stored auth keys were not detected");
77
+ const openaiPreview = providerKeyPreview(tempRoot, "openai");
78
+ assert(openaiPreview.preview === "test…-key (15 chars)", "openai key preview was not masked correctly");
79
+ assert(openaiPreview.preview !== "test-openai-key", "openai key preview leaked raw key");
70
80
 
71
81
  const keysOutput = await runCli(["keys", "status"]);
72
82
  assert(keysOutput.includes("qwen=available"), "keys status did not include qwen");
@@ -77,12 +87,24 @@ try {
77
87
  {
78
88
  ok: true,
79
89
  projectRoot: tempRoot,
80
- checks: ["normalize-auth-provider", "provider-key-links", "qwen-defaults", "qwen-key-status", "cli-key-status-redacted"],
90
+ checks: [
91
+ "normalize-auth-provider",
92
+ "provider-key-links",
93
+ "provider-key-mask",
94
+ "provider-key-preview",
95
+ "qwen-defaults",
96
+ "qwen-key-status",
97
+ "cli-key-status-redacted",
98
+ ],
81
99
  },
82
100
  null,
83
101
  2
84
102
  )
85
103
  );
86
104
  } finally {
105
+ for (const key of envKeys) {
106
+ if (originalEnv[key] === undefined) delete process.env[key];
107
+ else process.env[key] = originalEnv[key];
108
+ }
87
109
  await fs.rm(tempRoot, { recursive: true, force: true });
88
110
  }
@@ -13,6 +13,7 @@ import {
13
13
  startTmuxSession,
14
14
  tmuxAvailable,
15
15
  } from "../src/tmux-tools.js";
16
+ import { checkToolUse } from "../src/guardrails.js";
16
17
 
17
18
  const execFile = promisify(execFileCallback);
18
19
  const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-tmux-"));
@@ -64,13 +65,35 @@ try {
64
65
  const destructive = checkTmuxToolUse("tmux_send_keys", { target: start.target, text: "rm -rf /" }, config);
65
66
  assert.equal(destructive.allowed, false, "tmux guardrail did not block destructive text");
66
67
 
68
+ const dockerTmuxCommand = checkToolUse({
69
+ toolName: "run_command",
70
+ args: { command: "tmux new-session -d -s should-not-run" },
71
+ config: { ...config, useDockerSandbox: true, sandboxMode: "docker-workspace", packageInstallPolicy: "allow" },
72
+ });
73
+ assert.equal(dockerTmuxCommand.allowed, false, "Docker run_command tmux usage should be blocked in favor of host tmux tools");
74
+ const dockerTmuxSearch = checkToolUse({
75
+ toolName: "run_command",
76
+ args: { command: "rg tmux README.md" },
77
+ config: { ...config, useDockerSandbox: true, sandboxMode: "docker-workspace", packageInstallPolicy: "allow" },
78
+ });
79
+ assert.equal(dockerTmuxSearch.allowed, true, "Docker run_command should still allow harmless tmux text searches");
80
+
67
81
  console.log(
68
82
  JSON.stringify(
69
83
  {
70
84
  ok: true,
71
85
  session,
72
86
  workspace,
73
- checks: ["start-session", "send-keys", "capture-pane", "list-sessions", "secret-guardrail", "destructive-guardrail"],
87
+ checks: [
88
+ "start-session",
89
+ "send-keys",
90
+ "capture-pane",
91
+ "list-sessions",
92
+ "secret-guardrail",
93
+ "destructive-guardrail",
94
+ "docker-run-command-tmux-guardrail",
95
+ "docker-run-command-tmux-search-allowed",
96
+ ],
74
97
  },
75
98
  null,
76
99
  2
@@ -19,14 +19,16 @@ tools:
19
19
  ---
20
20
  # Tmux Session Control
21
21
 
22
- Use tmux tools for work that should keep running while the agent remains responsive: installs, builds, tests, dev servers, external agents, and monitored shells.
22
+ Use host tmux tools for work that should keep running while the agent remains responsive: installs, builds, tests, dev servers, external agents, and monitored shells.
23
+
24
+ Important runtime distinction: `run_command` in Docker mode runs a short-lived `docker run --rm` container. A tmux server created there dies when that command exits. Do not install or start tmux inside Docker command containers for persistent sessions. Use the host-side tmux tools instead.
23
25
 
24
26
  Workflow:
25
27
 
26
28
  1. Discover with `tmux_list_sessions` unless the user gave an exact target.
27
29
  2. Capture with `tmux_capture_pane` before sending input so context is current.
28
- 3. Use `tmux_start_session` for new durable jobs rooted in the workspace.
30
+ 3. Use `tmux_start_session` for new durable host jobs rooted in the workspace.
29
31
  4. Use `tmux_send_keys` sparingly and never send secrets, sudo passwords, destructive commands, or unreviewed pasted scripts.
30
32
  5. For long commands, capture progress periodically and summarize the latest useful lines instead of flooding the chat.
31
33
 
32
- If a package or sudo install is missing, report the exact command and whether it should run in Docker, host, or a user-owned tmux session.
34
+ If host tmux is unavailable, report that limitation and suggest installing tmux on the host or using a future persistent service-container mode. If a package or sudo install is missing, report the exact command and whether it should run in Docker, host, or a user-owned tmux session.
@@ -308,7 +308,7 @@ async function createInitialState(config, sessionId) {
308
308
  : `A host shell command tool is available under the configured trust policy on ${platformLabel(platform)}. On native Windows, prefer PowerShell/cmd-compatible commands or switch to WSL/Docker for bash-like toolchains.`
309
309
  : "No shell command tool is available.",
310
310
  config.allowShellTool
311
- ? "Host tmux tools are available for long-running terminals: list sessions, capture panes, send safe keys/text, and start detached sessions. Prefer tmux for monitoring long installs/tests/dev servers without blocking; capture before sending input and never send secrets or sudo passwords."
311
+ ? "Host tmux tools are available for long-running terminals: list sessions, capture panes, send safe keys/text, and start detached sessions. Prefer these tools for monitoring long installs/tests/dev servers without blocking; capture before sending input and never send secrets or sudo passwords. Do not start or install tmux inside Docker run_command containers because those containers are short-lived."
312
312
  : "",
313
313
  config.allowFileTools
314
314
  ? `Workspace file tools are available in ${config.commandCwd}: inspect_project, list_files, read_file, search_files, write_file, apply_patch, open_workspace_file, and preview_workspace. For large or unfamiliar repositories, call inspect_project first, then search/read AGINTI.md/AGENTS.md/README/manifests as relevant before editing. apply_patch supports exact single-file replacements plus Codex-style/unified multi-file patches; prefer it for source edits after reading/searching the relevant context. Always use workspace-relative paths such as plot_fx.svg or docs/report.tex, never absolute host paths. Secret paths, .git internals, node_modules writes, and huge files are blocked. For generated local websites/pages, use open_workspace_file or preview_workspace instead of starting a localhost server inside Docker.`
@@ -339,6 +339,7 @@ async function createInitialState(config, sessionId) {
339
339
  "For research or web-search tasks, use browser tools or safe shell network tools when the current policy allows; cite or save useful sources in workspace notes when the task needs traceability.",
340
340
  "Use the canvas tunnel for outputs the user would likely want to inspect visually, such as figures, PDFs, screenshots, images, important markdown, or generated files.",
341
341
  "For environment or system-maintenance work, use the configured sandbox and package policy; Docker workspace mode is the preferred place for installs and toolchain setup.",
342
+ "For long-running work, create a durable checkpoint or artifact at each completed phase, then continue with the next concrete phase until the requested outcome is actually complete or blocked by a real dependency.",
342
343
  "If the user asks to open a generated local website or file, use open_workspace_file for a file or preview_workspace for a static site. Do not keep retrying the same localhost URL when a preview fails.",
343
344
  "Docker language/toolchain installs should prefer /aginti-env or project files so they persist across runs; apt/apk changes are ephemeral unless the image is rebuilt.",
344
345
  "If the run is close to the max-step limit, finish with the best complete artifact and honest limitations instead of starting a new approach.",
@@ -702,7 +703,7 @@ async function captureSyntheticSnapshot(store, step, config) {
702
703
  : `Shell tool available in: ${config.commandCwd} on ${platformLabel(platform)}. Use OS-compatible commands; prefer WSL/Docker for bash-heavy workflows on Windows.`
703
704
  : "Shell tool disabled.",
704
705
  config.allowShellTool
705
- ? "Host tmux tools available: tmux_list_sessions, tmux_capture_pane, tmux_send_keys, tmux_start_session. Use them for long-running jobs and agent terminals; capture before sending input."
706
+ ? "Host tmux tools available: tmux_list_sessions, tmux_capture_pane, tmux_send_keys, tmux_start_session. Use them for long-running jobs and agent terminals; capture before sending input. Docker run_command containers are ephemeral, so tmux there will not persist."
706
707
  : "",
707
708
  config.allowFileTools
708
709
  ? `Workspace file tools available in: ${config.commandCwd}. Use inspect_project first for large or unfamiliar codebases, then search/read exact files before editing. Use workspace-relative paths. Use apply_patch for code edits; it supports exact single-file replacement and multi-file Codex-style/unified patches.`
@@ -2,7 +2,24 @@ import readline from "node:readline/promises";
2
2
  import { emitKeypressEvents } from "node:readline";
3
3
  import { stdin as input, stdout as output } from "node:process";
4
4
  import { Writable } from "node:stream";
5
- import { providerKeyStatus, setProviderKey } from "./project.js";
5
+ import { providerKeyPreview, providerKeyStatus, setProviderKey } from "./project.js";
6
+
7
+ const useColor = Boolean(input.isTTY && output.isTTY && process.env.AGINTIFLOW_NO_COLOR !== "1");
8
+ const ansi = {
9
+ reset: "\x1b[0m",
10
+ bold: "\x1b[1m",
11
+ dim: "\x1b[2m",
12
+ cyan: "\x1b[36m",
13
+ green: "\x1b[32m",
14
+ yellow: "\x1b[33m",
15
+ selected: "\x1b[48;5;31m\x1b[38;5;231m",
16
+ border: "\x1b[38;5;45m",
17
+ muted: "\x1b[38;5;245m",
18
+ inputBg: "\x1b[48;5;236m\x1b[38;5;231m",
19
+ clearLine: "\x1b[2K",
20
+ cursorHide: "\x1b[?25l",
21
+ cursorShow: "\x1b[?25h",
22
+ };
6
23
 
7
24
  export const MAIN_AUTH_PROVIDERS = [
8
25
  {
@@ -57,6 +74,68 @@ function providerLabel(provider = "") {
57
74
  return match?.label || provider;
58
75
  }
59
76
 
77
+ function color(value, ...codes) {
78
+ if (!useColor || codes.length === 0) return String(value);
79
+ return `${codes.join("")}${value}${ansi.reset}`;
80
+ }
81
+
82
+ function stripAnsi(value) {
83
+ return String(value || "").replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
84
+ }
85
+
86
+ function visibleLength(value) {
87
+ return stripAnsi(value).length;
88
+ }
89
+
90
+ function terminalWidth() {
91
+ return Math.max(Number(output.columns) || 80, 50);
92
+ }
93
+
94
+ function padVisible(value, width) {
95
+ return `${value}${" ".repeat(Math.max(width - visibleLength(value), 0))}`;
96
+ }
97
+
98
+ function compactLine(value = "", limit = 92) {
99
+ const text = String(value || "").replace(/\s+/g, " ").trim();
100
+ return text.length <= limit ? text : `${text.slice(0, Math.max(limit - 1, 1))}…`;
101
+ }
102
+
103
+ function boxedLine(content, width) {
104
+ return `${color("│", ansi.border)} ${padVisible(content, width - 4)} ${color("│", ansi.border)}`;
105
+ }
106
+
107
+ function secretInputDisplay({ value, selectedPreview, placeholder }) {
108
+ if (value) return color(`${"•".repeat(Math.min(value.length, 24))} (${value.length} chars)`, ansi.inputBg);
109
+ if (selectedPreview) return `${color(` ${selectedPreview} `, ansi.selected, ansi.bold)} ${color("selected", ansi.yellow)}`;
110
+ return color(placeholder || "paste key here", ansi.dim);
111
+ }
112
+
113
+ function renderSecretBox({ title, helpText, statusText, currentPreview, value, selected, actionText }) {
114
+ const width = Math.min(Math.max(terminalWidth() - 2, 58), 96);
115
+ const inner = width - 4;
116
+ const selectedPreview = selected && currentPreview ? currentPreview : "";
117
+ const lines = [
118
+ `${color("╭", ansi.border)}${color("─", ansi.border).repeat(width - 2)}${color("╮", ansi.border)}`,
119
+ boxedLine(color(compactLine(title, inner), ansi.bold, ansi.cyan), width),
120
+ helpText ? boxedLine(`${color("key", ansi.muted)} ${compactLine(helpText, inner - 6)}`, width) : "",
121
+ statusText ? boxedLine(`${color("status", ansi.muted)} ${compactLine(statusText, inner - 8)}`, width) : "",
122
+ boxedLine(`${color("input", ansi.muted)} ${secretInputDisplay({ value, selectedPreview, placeholder: "hidden input" })}`, width),
123
+ boxedLine(color(compactLine(actionText, inner), ansi.dim), width),
124
+ `${color("╰", ansi.border)}${color("─", ansi.border).repeat(width - 2)}${color("╯", ansi.border)}`,
125
+ ].filter(Boolean);
126
+ return lines.join("\n");
127
+ }
128
+
129
+ function clearRenderedLines(count) {
130
+ if (!count) return;
131
+ output.write(`\x1b[${count - 1}A`);
132
+ for (let index = 0; index < count; index += 1) {
133
+ output.write(`\r${ansi.clearLine}`);
134
+ if (index < count - 1) output.write("\x1b[1B");
135
+ }
136
+ output.write(`\x1b[${count - 1}A\r`);
137
+ }
138
+
60
139
  export function authProviderKeyUrl(provider = "") {
61
140
  const normalized = normalizeAuthProvider(provider, "");
62
141
  const match = [...MAIN_AUTH_PROVIDERS, AUXILIARY_AUTH_PROVIDER].find((item) => item.id === normalized);
@@ -83,7 +162,7 @@ class MutedWritable extends Writable {
83
162
  }
84
163
  }
85
164
 
86
- export async function promptSecret(promptText, { allowEscape = true } = {}) {
165
+ export async function promptSecret(promptText, { allowEscape = true, box = null } = {}) {
87
166
  if (!input.isTTY || !output.isTTY) return { value: "", skipped: true };
88
167
 
89
168
  if (typeof input.setRawMode === "function") {
@@ -91,21 +170,38 @@ export async function promptSecret(promptText, { allowEscape = true } = {}) {
91
170
  emitKeypressEvents(input);
92
171
  const wasRaw = Boolean(input.isRaw);
93
172
  let value = "";
173
+ let selectedExisting = Boolean(box?.currentPreview);
174
+ let renderedLines = 0;
175
+
176
+ const render = () => {
177
+ if (!box) return;
178
+ clearRenderedLines(renderedLines);
179
+ const text = renderSecretBox({
180
+ ...box,
181
+ value,
182
+ selected: selectedExisting,
183
+ });
184
+ renderedLines = text.split("\n").length;
185
+ output.write(`${ansi.cursorHide}${text}`);
186
+ };
94
187
 
95
188
  const cleanup = () => {
96
189
  input.off("keypress", handler);
97
190
  if (typeof input.setRawMode === "function") input.setRawMode(wasRaw);
98
191
  input.pause();
192
+ output.write(ansi.cursorShow);
99
193
  };
100
194
 
101
195
  const finish = (result) => {
196
+ if (box) clearRenderedLines(renderedLines);
102
197
  cleanup();
103
- output.write("\n");
198
+ if (!box) output.write("\n");
104
199
  resolve(result);
105
200
  };
106
201
 
107
202
  const handler = (str = "", key = {}) => {
108
203
  if (key.ctrl && key.name === "c") {
204
+ if (box) clearRenderedLines(renderedLines);
109
205
  cleanup();
110
206
  reject(Object.assign(new Error("Interrupted by ctrl-c."), { name: "AbortError", code: "ABORT_ERR" }));
111
207
  return;
@@ -119,14 +215,34 @@ export async function promptSecret(promptText, { allowEscape = true } = {}) {
119
215
  return;
120
216
  }
121
217
  if (key.name === "backspace" || key.name === "delete") {
122
- value = value.slice(0, -1);
218
+ if (selectedExisting) {
219
+ selectedExisting = false;
220
+ value = "";
221
+ } else {
222
+ value = value.slice(0, -1);
223
+ }
224
+ render();
225
+ return;
226
+ }
227
+ if (key.ctrl && key.name === "u") {
228
+ selectedExisting = false;
229
+ value = "";
230
+ render();
123
231
  return;
124
232
  }
125
233
  if (key.ctrl || key.meta || key.sequence?.startsWith("\x1b")) return;
126
- if (str) value += str.replace(/\r|\n/g, "");
234
+ if (str) {
235
+ if (selectedExisting) {
236
+ selectedExisting = false;
237
+ value = "";
238
+ }
239
+ value += str.replace(/\r|\n/g, "");
240
+ render();
241
+ }
127
242
  };
128
243
 
129
- output.write(promptText);
244
+ if (box) render();
245
+ else output.write(promptText);
130
246
  input.resume();
131
247
  input.setRawMode(true);
132
248
  input.on("keypress", handler);
@@ -313,10 +429,23 @@ export async function runAuthWizard(projectRoot = process.cwd(), options = {}) {
313
429
  const skipped = [];
314
430
 
315
431
  if (mainProvider) {
316
- const current = status[mainProvider] ? "currently available; paste a new key to replace, or Esc to keep existing" : "missing";
432
+ const preview = providerKeyPreview(projectRoot, mainProvider);
433
+ const current = preview.available ? `available: ${preview.preview}` : "missing";
317
434
  const keyHelp = authProviderKeyHelp(mainProvider);
318
- const prompt = `${keyHelp ? `${keyHelp}\n` : ""}${providerLabel(mainProvider)} main API key (${current}) [hidden]: `;
319
- const secret = await promptSecret(prompt, { allowEscape: true });
435
+ const title = `${providerLabel(mainProvider)} main API key`;
436
+ const prompt = `${keyHelp ? `${keyHelp}\n` : ""}${title} (${current}) [hidden]: `;
437
+ const secret = await promptSecret(prompt, {
438
+ allowEscape: true,
439
+ box: {
440
+ title,
441
+ helpText: keyHelp || "Paste the provider API key.",
442
+ statusText: preview.available ? `Existing ${preview.keyName}: ${preview.preview}` : `Missing ${preview.keyName || "provider key"}`,
443
+ currentPreview: preview.preview,
444
+ actionText: preview.available
445
+ ? "Existing key is selected. Type to replace, Enter/Esc to keep existing."
446
+ : "Paste key and Enter to save, or Esc/Enter to skip.",
447
+ },
448
+ });
320
449
  if (secret.value) {
321
450
  const result = await setProviderKey(projectRoot, mainProvider, secret.value);
322
451
  saved.push(result);
@@ -328,10 +457,18 @@ export async function runAuthWizard(projectRoot = process.cwd(), options = {}) {
328
457
  }
329
458
 
330
459
  if (options.includeAuxiliary !== false && directProvider !== "grsai") {
331
- const auxStatus = providerKeyStatus(projectRoot);
332
- const current = auxStatus.grsai ? "currently available; paste a new key to replace, or Esc to skip" : "optional; paste key or Esc to skip";
333
- const secret = await promptSecret(`${AUXILIARY_AUTH_PROVIDER.label} auxiliary image key (${current}) [hidden]: `, {
460
+ const preview = providerKeyPreview(projectRoot, "grsai");
461
+ const current = preview.available ? `available: ${preview.preview}` : "optional";
462
+ const title = `${AUXILIARY_AUTH_PROVIDER.label} auxiliary image key`;
463
+ const secret = await promptSecret(`${title} (${current}) [hidden]: `, {
334
464
  allowEscape: true,
465
+ box: {
466
+ title,
467
+ helpText: "Optional image generation key for GRS AI / Nano Banana.",
468
+ statusText: preview.available ? `Existing ${preview.keyName}: ${preview.preview}` : "Optional; no auxiliary key saved.",
469
+ currentPreview: preview.preview,
470
+ actionText: preview.available ? "Existing key is selected. Type to replace, Enter/Esc to keep existing." : "Paste key and Enter to save, or Esc/Enter to skip.",
471
+ },
335
472
  });
336
473
  if (secret.value) {
337
474
  const result = await setProviderKey(projectRoot, "grsai", secret.value);
@@ -340,8 +477,17 @@ export async function runAuthWizard(projectRoot = process.cwd(), options = {}) {
340
477
  skipped.push({ provider: "grsai", reason: "skipped" });
341
478
  }
342
479
  } else if (directProvider === "grsai") {
343
- const secret = await promptSecret(`${AUXILIARY_AUTH_PROVIDER.label} auxiliary image key [hidden]: `, {
480
+ const preview = providerKeyPreview(projectRoot, "grsai");
481
+ const title = `${AUXILIARY_AUTH_PROVIDER.label} auxiliary image key`;
482
+ const secret = await promptSecret(`${title} [hidden]: `, {
344
483
  allowEscape: true,
484
+ box: {
485
+ title,
486
+ helpText: "Optional image generation key for GRS AI / Nano Banana.",
487
+ statusText: preview.available ? `Existing ${preview.keyName}: ${preview.preview}` : "Missing auxiliary key.",
488
+ currentPreview: preview.preview,
489
+ actionText: preview.available ? "Existing key is selected. Type to replace, Enter/Esc to keep existing." : "Paste key and Enter to save, or Esc/Enter to skip.",
490
+ },
345
491
  });
346
492
  if (secret.value) saved.push(await setProviderKey(projectRoot, "grsai", secret.value));
347
493
  else skipped.push({ provider: "grsai", reason: "skipped" });
package/src/guardrails.js CHANGED
@@ -38,6 +38,17 @@ function isTransientDockerPreviewCommand(command) {
38
38
  );
39
39
  }
40
40
 
41
+ function isDockerTmuxProcessCommand(command = "") {
42
+ return [
43
+ /^tmux\b/i,
44
+ /^(?:sudo\s+)?apt(?:-get)?\s+install\b.*\btmux\b/i,
45
+ /^(?:sudo\s+)?(?:dnf|yum)\s+install\b.*\btmux\b/i,
46
+ /^apk\s+add\b.*\btmux\b/i,
47
+ /^brew\s+install\b.*\btmux\b/i,
48
+ /^(curl|wget)\b.*\btmux\b/i,
49
+ ].some((pattern) => pattern.test(command));
50
+ }
51
+
41
52
  function normalizeDomain(hostname) {
42
53
  return hostname.replace(/^www\./, "").toLowerCase();
43
54
  }
@@ -126,6 +137,14 @@ export function checkToolUse({ toolName, args, snapshot, config }) {
126
137
 
127
138
  if (toolName === "run_command") {
128
139
  const command = String(args.command || "").trim();
140
+ if (config.useDockerSandbox && isDockerTmuxProcessCommand(command)) {
141
+ return {
142
+ allowed: false,
143
+ reason:
144
+ "Docker run_command containers are short-lived, so tmux started there cannot persist. Use host tmux tools: tmux_start_session, tmux_capture_pane, tmux_send_keys, or tmux_list_sessions.",
145
+ category: "tmux",
146
+ };
147
+ }
129
148
  if (config.useDockerSandbox && isTransientDockerPreviewCommand(command)) {
130
149
  return {
131
150
  allowed: false,
@@ -227,7 +227,7 @@ export async function createPlan(client, config, state) {
227
227
  ? `Shell tool is enabled in ${config.commandCwd}. Host platform: ${platformLabel(platform)}. In Docker, this path is mounted as /workspace with persistent /aginti-env and /aginti-cache mounts. Use relative paths or /workspace paths, not absolute host temp paths. Sandbox mode: ${config.sandboxMode}. Package install policy: ${config.packageInstallPolicy}. For npm/pip/conda/venv setup, explain the need and wait for approval unless policy is allow. On native Windows host mode, prefer PowerShell/cmd-compatible commands or WSL/Docker for bash-like toolchains.`
228
228
  : "",
229
229
  config.allowShellTool
230
- ? "Host tmux tools are enabled for long-running sessions. Plan to use tmux_start_session for durable jobs, tmux_capture_pane to monitor, tmux_send_keys to interact after capture, and tmux_list_sessions to discover existing sessions."
230
+ ? "Host tmux tools are enabled for long-running sessions. Plan to use tmux_start_session for durable jobs, tmux_capture_pane to monitor, tmux_send_keys to interact after capture, and tmux_list_sessions to discover existing sessions. Do not install or run tmux inside Docker run_command containers; those containers are short-lived and cannot preserve tmux servers."
231
231
  : "",
232
232
  config.allowFileTools
233
233
  ? `Workspace file tools are enabled in ${config.commandCwd}: inspect_project, list_files, read_file, search_files, write_file, apply_patch, open_workspace_file, preview_workspace. For large or unfamiliar repos, plan to call inspect_project first, then search/read AGINTI.md/AGENTS.md/README/manifests and exact files. apply_patch supports exact single-file replacements and Codex-style/unified multi-file patches; prefer it for edits after reading relevant context. Keep all paths workspace-relative, for example plot_fx.svg or docs/report.tex, and avoid secrets. For generated local HTML/SVG/PDF/static sites, plan to use open_workspace_file or preview_workspace rather than starting a localhost server inside Docker.`
@@ -468,7 +468,7 @@ export async function requestNextStep(client, config, messages) {
468
468
  function: {
469
469
  name: "tmux_list_sessions",
470
470
  description:
471
- "List host tmux sessions and panes. Use this to discover long-running terminals, agent sessions, dev servers, or jobs before interacting with them. tmux tools run host-side even when command execution is Docker-sandboxed.",
471
+ "List durable host tmux sessions and panes. Use this to discover long-running terminals, agent sessions, dev servers, or jobs before interacting with them. tmux tools run host-side even when command execution is Docker-sandboxed; do not use run_command to start tmux inside an ephemeral Docker container.",
472
472
  parameters: {
473
473
  type: "object",
474
474
  properties: {
@@ -483,7 +483,7 @@ export async function requestNextStep(client, config, messages) {
483
483
  function: {
484
484
  name: "tmux_capture_pane",
485
485
  description:
486
- "Capture recent text from a host tmux pane by target such as session:0.0. Use this to monitor progress or inspect a long-running job without interrupting it.",
486
+ "Capture recent text from a durable host tmux pane by target such as session:0.0. Use this to monitor progress or inspect a long-running job without interrupting it.",
487
487
  parameters: {
488
488
  type: "object",
489
489
  properties: {
@@ -500,7 +500,7 @@ export async function requestNextStep(client, config, messages) {
500
500
  function: {
501
501
  name: "tmux_send_keys",
502
502
  description:
503
- "Send literal text and/or safe control keys to a host tmux pane. Use for interacting with known shells or agent sessions after capturing context. Do not send secrets, passwords, sudo passwords, or destructive commands.",
503
+ "Send literal text and/or safe control keys to a durable host tmux pane. Use for interacting with known shells or agent sessions after capturing context. Do not send secrets, passwords, sudo passwords, or destructive commands.",
504
504
  parameters: {
505
505
  type: "object",
506
506
  properties: {
@@ -526,7 +526,7 @@ export async function requestNextStep(client, config, messages) {
526
526
  function: {
527
527
  name: "tmux_start_session",
528
528
  description:
529
- "Start a detached host tmux session rooted inside the workspace, optionally with a startup command. Use for long-running local jobs that should be monitored with tmux_capture_pane instead of blocking the agent.",
529
+ "Start a detached durable host tmux session rooted inside the workspace, optionally with a startup command. Use for long-running local jobs that should be monitored with tmux_capture_pane instead of blocking the agent. This is the correct tmux path in Docker mode because run_command containers are ephemeral.",
530
530
  parameters: {
531
531
  type: "object",
532
532
  properties: {
package/src/project.js CHANGED
@@ -16,10 +16,18 @@ const LOCAL_ENV_KEYS = new Set([
16
16
  "DEEPSEEK_FAST_MODEL",
17
17
  "DEEPSEEK_PRO_MODEL",
18
18
  "OPENAI_DEFAULT_MODEL",
19
+ "QWEN_API_KEY",
19
20
  "GRSAI",
20
21
  "GRSAI_API_KEY",
21
22
  ]);
22
23
 
24
+ const PROVIDER_KEY_CANDIDATES = {
25
+ openai: ["OPENAI_API_KEY", "LLM_API_KEY"],
26
+ deepseek: ["DEEPSEEK_API_KEY", "LLM_API_KEY"],
27
+ qwen: ["QWEN_API_KEY"],
28
+ grsai: ["GRSAI", "GRSAI_API_KEY"],
29
+ };
30
+
23
31
  export function resolveProjectRoot(input = process.cwd()) {
24
32
  return path.resolve(input || process.cwd());
25
33
  }
@@ -267,6 +275,45 @@ export function providerKeyStatus(projectRoot = process.cwd()) {
267
275
  };
268
276
  }
269
277
 
278
+ export function maskProviderKey(value = "") {
279
+ const text = String(value || "").trim();
280
+ if (!text) return "";
281
+ if (text.length <= 8) return `${text.slice(0, 1)}…${text.slice(-1)} (${text.length} chars)`;
282
+ return `${text.slice(0, 4)}…${text.slice(-4)} (${text.length} chars)`;
283
+ }
284
+
285
+ export function providerKeyPreview(projectRoot = process.cwd(), provider = "") {
286
+ loadProjectEnv(projectRoot);
287
+ const normalized = String(provider || "").trim().toLowerCase();
288
+ const aliases = {
289
+ auxiliary: "grsai",
290
+ auxilliary: "grsai",
291
+ image: "grsai",
292
+ imagegen: "grsai",
293
+ };
294
+ const canonical = aliases[normalized] || normalized;
295
+ const keys = PROVIDER_KEY_CANDIDATES[canonical] || [];
296
+ for (const keyName of keys) {
297
+ const value = process.env[keyName];
298
+ if (value) {
299
+ return {
300
+ available: true,
301
+ provider: canonical,
302
+ keyName,
303
+ preview: maskProviderKey(value),
304
+ length: String(value).trim().length,
305
+ };
306
+ }
307
+ }
308
+ return {
309
+ available: false,
310
+ provider: canonical,
311
+ keyName: keys[0] || "",
312
+ preview: "",
313
+ length: 0,
314
+ };
315
+ }
316
+
270
317
  export async function setProviderKey(projectRoot, provider, value) {
271
318
  const normalizedProvider = String(provider || "").toLowerCase();
272
319
  const aliases = {