Compare commits

...

20 Commits

Author SHA1 Message Date
Vincent Koc
74e0729631
Skills/nano-banana-pro: support hosted input images (#37247)
* skills(nano-banana-pro): support remote edit image URLs

* test(nano-banana-pro): cover remote input image validation

* docs(nano-banana-pro): document remote input images

* docs(changelog): note nano-banana remote image inputs

* chore(nano-banana-pro): normalize script imports

* test(nano-banana-pro): normalize test imports

* ci: use published bun release tag

* ci: skip prod audit on PRs without dependency changes

* test(nano-banana-pro): remove pillow dependency from skill tests

* docs(changelog): credit nano-banana input image follow-up
2026-03-06 01:02:23 -05:00
Vincent Koc
7187bfd84b test(process/supervisor): assert pipe-closed stdin state 2026-03-05 07:56:48 -05:00
Vincent Koc
447b2eaf2c test(process): cover ended stdin hint behavior 2026-03-05 07:56:48 -05:00
Vincent Koc
1d15dfb03d process/supervisor: track pty stdin destroyed state 2026-03-05 07:56:48 -05:00
Vincent Koc
d7a0862382 process/supervisor: track child stdin destroyed state 2026-03-05 07:56:48 -05:00
Vincent Koc
73b3f655ba types/process: expose writable stdin state flags 2026-03-05 07:56:48 -05:00
Vincent Koc
c3ffaaa764 tools/process: refine attach hint and stdin writability checks 2026-03-05 07:56:47 -05:00
Vincent Koc
2c0a34ffb7
Merge branch 'main' into feat/19809-slack-typing-reaction 2026-03-04 06:16:12 -08:00
Vincent Koc
f85a1c4820 docs(changelog): note interactive process recovery 2026-03-04 08:52:32 -05:00
Vincent Koc
628e17d0ea docs(process): add attach and input-wait notes 2026-03-04 08:52:27 -05:00
Vincent Koc
4dc5b0e44f test(process): cover attach and input-wait hints 2026-03-04 08:52:19 -05:00
Vincent Koc
f44639375f tools/process: add attach action and input-wait metadata 2026-03-04 08:52:05 -05:00
Vincent Koc
4a80fb4751 docs(changelog): note slack system event routing fix 2026-03-04 01:12:28 -05:00
Vincent Koc
6181541626 test(slack): assert reaction session routing carries sender 2026-03-04 01:12:27 -05:00
Vincent Koc
999b0c6cf2 test(slack): update interaction session key assertions 2026-03-04 01:12:27 -05:00
Vincent Koc
b41aafb9c0 test(slack): cover binding-aware system event routing 2026-03-04 01:12:27 -05:00
Vincent Koc
2dd6794d0e fix(slack): include modal submitter in session routing 2026-03-04 01:12:27 -05:00
Vincent Koc
1b5e1f558f fix(slack): include sender context for interaction session routing 2026-03-04 01:12:27 -05:00
Vincent Koc
c8e91fdf88 fix(slack): pass sender to system event session resolver 2026-03-04 01:12:27 -05:00
Vincent Koc
6af782d2d9 fix(slack): route system events via binding-aware session keys 2026-03-04 01:12:27 -05:00
14 changed files with 717 additions and 32 deletions

View File

@ -61,7 +61,7 @@ runs:
if: inputs.install-bun == 'true'
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.9+cf6cdbbba"
bun-version: "1.3.9"
- name: Runtime versions
shell: bash

View File

@ -327,7 +327,26 @@ jobs:
pre-commit run zizmor --files "${workflow_files[@]}"
- name: Audit production dependencies
run: pre-commit run --all-files pnpm-audit-prod
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
pre-commit run --all-files pnpm-audit-prod
exit 0
fi
if [ "${{ github.event_name }}" != "pull_request" ]; then
pre-commit run --all-files pnpm-audit-prod
exit 0
fi
BASE="${{ github.event.pull_request.base.sha }}"
if ! git diff --name-only "$BASE" HEAD | grep -Eq '(^|/)package\.json$|^pnpm-lock\.yaml$|^pnpm-workspace\.yaml$'; then
echo "No dependency manifest changes detected; skipping pnpm audit on this PR."
exit 0
fi
pre-commit run --all-files pnpm-audit-prod
checks-windows:
needs: [docs-scope, changed-scope]

View File

@ -13,6 +13,8 @@ Docs: https://docs.openclaw.ai
- Agents/tool-result truncation: preserve important tail diagnostics by using head+tail truncation for oversized tool results while keeping configurable truncation options. (#20076) thanks @jlwestsr.
- Telegram/topic agent routing: support per-topic `agentId` overrides in forum groups and DM topics so topics can route to dedicated agents with isolated sessions. (#33647; based on #31513) Thanks @kesor and @Sid-Qin.
- Slack/DM typing feedback: add `channels.slack.typingReaction` so Socket Mode DMs can show reaction-based processing status even when Slack native assistant typing is unavailable. (#19816) Thanks @dalefrieswthat.
- Exec/process interactive recovery: add `process attach` plus input-wait metadata/hints (`waitingForInput`, `idleMs`, `stdinWritable`) so long-running interactive sessions can be observed and resumed without losing context. Fixes #33957. Thanks @westoque.
- Skills/nano-banana-pro: accept public `http(s)` input images for edit/composition while keeping local path support, and return explicit errors for redirects, `file://`, and private-network URLs. Fixes #33960. Thanks @westoque and @vincentkoc.
### Fixes

View File

@ -54,6 +54,7 @@ Config (preferred):
Actions:
- `list`: running + finished sessions
- `attach`: read current session output with interactive recovery hints
- `poll`: drain new output for a session (also reports exit status)
- `log`: read the aggregated output (supports `offset` + `limit`)
- `write`: send stdin (`data`, optional `eof`)
@ -71,6 +72,9 @@ Notes:
- `process log` uses line-based `offset`/`limit`.
- When both `offset` and `limit` are omitted, it returns the last 200 lines and includes a paging hint.
- When `offset` is provided and `limit` is omitted, it returns from `offset` to the end (not capped to 200).
- Running session details now include `stdinWritable`, `lastOutputAt`, `idleMs`, and `waitingForInput`.
- `process list` appends `[input-wait]` when a session has been idle long enough and stdin is writable.
- `process poll`/`attach` include an interactive recovery hint when `waitingForInput=true`.
## Examples

View File

@ -39,6 +39,12 @@ Edit (single image)
uv run {baseDir}/scripts/generate_image.py --prompt "edit instructions" --filename "output.png" -i "/path/in.png" --resolution 2K
```
Edit from a hosted image URL
```bash
uv run {baseDir}/scripts/generate_image.py --prompt "turn this into a watercolor poster" --filename "output.png" -i "https://images.example.com/source.png" --resolution 2K
```
Multi-image composition (up to 14 images)
```bash
@ -53,6 +59,9 @@ API key
Notes
- Resolutions: `1K` (default), `2K`, `4K`.
- Input images can be local paths or public `http(s)` URLs.
- `file://` URLs are rejected; use a normal local path instead.
- Remote input URLs reject redirects plus private/loopback/special-use hosts for safety.
- Use timestamps in filenames: `yyyy-mm-dd-hh-mm-ss-name.png`.
- The script prints a `MEDIA:` line for OpenClaw to auto-attach on supported chat providers.
- Do not read the image back; report the saved path only.

View File

@ -17,9 +17,22 @@ Multi-image editing (up to 14 images):
"""
import argparse
import ipaddress
import os
import re
import socket
import sys
from io import BytesIO
from pathlib import Path
from urllib import error, parse, request
MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024
REMOTE_IMAGE_TIMEOUT_SEC = 20
class NoRedirectHandler(request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def get_api_key(provided_key: str | None) -> str | None:
@ -29,6 +42,127 @@ def get_api_key(provided_key: str | None) -> str | None:
return os.environ.get("GEMINI_API_KEY")
def is_remote_image_url(image_source: str) -> bool:
parsed = parse.urlparse(image_source)
return parsed.scheme.lower() in {"http", "https"}
def _looks_like_windows_drive_path(image_source: str) -> bool:
return bool(re.match(r"^[a-zA-Z]:[\\/]", image_source))
def _is_blocked_remote_ip(address: str) -> bool:
ip = ipaddress.ip_address(address)
return (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_multicast
or ip.is_reserved
or ip.is_unspecified
)
def validate_remote_image_url(image_url: str) -> parse.ParseResult:
parsed = parse.urlparse(image_url)
scheme = parsed.scheme.lower()
if scheme not in {"http", "https"}:
if scheme == "file":
raise ValueError(
f"Unsupported input image URL '{image_url}'. "
"Use a local path instead of file:// URLs."
)
raise ValueError(
f"Unsupported input image URL '{image_url}'. Only public http(s) URLs are supported."
)
if not parsed.hostname:
raise ValueError(f"Invalid input image URL '{image_url}': hostname is required.")
if parsed.username or parsed.password:
raise ValueError(
f"Unsupported input image URL '{image_url}': embedded credentials are not allowed."
)
try:
resolved = socket.getaddrinfo(
parsed.hostname,
parsed.port or (443 if scheme == "https" else 80),
type=socket.SOCK_STREAM,
)
except socket.gaierror as exc:
raise ValueError(f"Could not resolve input image URL '{image_url}': {exc}.") from exc
blocked = sorted(
{
entry[4][0]
for entry in resolved
if entry[4] and entry[4][0] and _is_blocked_remote_ip(entry[4][0])
}
)
if blocked:
raise ValueError(
f"Unsafe input image URL '{image_url}': private, loopback, or "
f"special-use hosts are not allowed ({', '.join(blocked)})."
)
return parsed
def load_input_image(image_source: str, pil_image_module):
if is_remote_image_url(image_source):
validate_remote_image_url(image_source)
opener = request.build_opener(NoRedirectHandler())
req = request.Request(
image_source,
headers={"User-Agent": "OpenClaw nano-banana-pro/1.0"},
)
try:
with opener.open(req, timeout=REMOTE_IMAGE_TIMEOUT_SEC) as response:
redirected_to = response.geturl()
if redirected_to != image_source:
raise ValueError(
"Redirected input image URLs are not supported for safety. "
f"Re-run with the final asset URL: {redirected_to}"
)
image_bytes = response.read(MAX_REMOTE_IMAGE_BYTES + 1)
except error.HTTPError as exc:
if 300 <= exc.code < 400:
location = exc.headers.get("Location")
detail = f" Redirect target: {location}" if location else ""
raise ValueError(
f"Redirected input image URLs are not supported for safety.{detail}"
) from exc
raise ValueError(
f"Error downloading input image '{image_source}': HTTP {exc.code}."
) from exc
except error.URLError as exc:
raise ValueError(
f"Error downloading input image '{image_source}': {exc.reason}."
) from exc
if len(image_bytes) > MAX_REMOTE_IMAGE_BYTES:
raise ValueError(
f"Input image URL '{image_source}' exceeded the "
f"{MAX_REMOTE_IMAGE_BYTES // (1024 * 1024)} MB download limit."
)
with pil_image_module.open(BytesIO(image_bytes)) as img:
return img.copy()
parsed = parse.urlparse(image_source)
if parsed.scheme and not _looks_like_windows_drive_path(image_source):
if parsed.scheme.lower() == "file":
raise ValueError(
f"Unsupported input image URL '{image_source}'. "
"Use a local path instead of file:// URLs."
)
raise ValueError(
f"Unsupported input image source '{image_source}'. "
"Use a local path or a public http(s) URL."
)
local_path = Path(image_source).expanduser()
with pil_image_module.open(local_path) as img:
return img.copy()
def main():
parser = argparse.ArgumentParser(
description="Generate images using Nano Banana Pro (Gemini 3 Pro Image)"
@ -48,7 +182,10 @@ def main():
action="append",
dest="input_images",
metavar="IMAGE",
help="Input image path(s) for editing/composition. Can be specified multiple times (up to 14 images)."
help=(
"Input image path(s) for editing/composition. "
"Can be specified multiple times (up to 14 images)."
),
)
parser.add_argument(
"--resolution", "-r",
@ -89,15 +226,17 @@ def main():
output_resolution = args.resolution
if args.input_images:
if len(args.input_images) > 14:
print(f"Error: Too many input images ({len(args.input_images)}). Maximum is 14.", file=sys.stderr)
print(
f"Error: Too many input images ({len(args.input_images)}). Maximum is 14.",
file=sys.stderr,
)
sys.exit(1)
max_input_dim = 0
for img_path in args.input_images:
try:
with PILImage.open(img_path) as img:
copied = img.copy()
width, height = copied.size
copied = load_input_image(img_path, PILImage)
width, height = copied.size
input_images.append(copied)
print(f"Loaded input image: {img_path}")
@ -115,13 +254,19 @@ def main():
output_resolution = "2K"
else:
output_resolution = "1K"
print(f"Auto-detected resolution: {output_resolution} (from max input dimension {max_input_dim})")
print(
f"Auto-detected resolution: {output_resolution} "
f"(from max input dimension {max_input_dim})"
)
# Build contents (images first if editing, prompt only if generating)
if input_images:
contents = [*input_images, args.prompt]
img_count = len(input_images)
print(f"Processing {img_count} image{'s' if img_count > 1 else ''} with resolution {output_resolution}...")
print(
f"Processing {img_count} image{'s' if img_count > 1 else ''} "
f"with resolution {output_resolution}..."
)
else:
contents = args.prompt
print(f"Generating image with resolution {output_resolution}...")

View File

@ -0,0 +1,108 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import generate_image
class FakeResponse:
def __init__(self, payload: bytes, url: str):
self._payload = payload
self._url = url
def geturl(self):
return self._url
def read(self, _limit: int):
return self._payload
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
class FakeImage:
def __init__(self, size):
self.size = size
def copy(self):
return FakeImage(self.size)
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
class FakePILImageModule:
def __init__(self, sizes_by_source):
self._sizes_by_source = sizes_by_source
def open(self, source):
if isinstance(source, (str, Path)):
key = source
else:
key = type(source).__name__
size = self._sizes_by_source[key]
return FakeImage(size)
class LoadInputImageTests(unittest.TestCase):
def test_load_input_image_accepts_local_path(self):
with tempfile.TemporaryDirectory() as tmpdir:
image_path = Path(tmpdir) / "input.png"
image_path.write_bytes(b"not-a-real-image")
fake_pil = FakePILImageModule({image_path: (16, 12)})
loaded = generate_image.load_input_image(str(image_path), fake_pil)
self.assertEqual(loaded.size, (16, 12))
def test_load_input_image_accepts_public_https_url(self):
fake_opener = type(
"FakeOpener",
(),
{
"open": lambda self, req, timeout=0: FakeResponse(
b"fake-image-bytes",
req.full_url,
)
},
)()
fake_pil = FakePILImageModule({"BytesIO": (20, 10)})
with patch.object(
generate_image.socket,
"getaddrinfo",
return_value=[(None, None, None, None, ("93.184.216.34", 443))],
), patch.object(generate_image.request, "build_opener", return_value=fake_opener):
loaded = generate_image.load_input_image("https://example.com/input.png", fake_pil)
self.assertEqual(loaded.size, (20, 10))
def test_load_input_image_rejects_private_network_url(self):
with patch.object(
generate_image.socket,
"getaddrinfo",
return_value=[(None, None, None, None, ("127.0.0.1", 443))],
):
with self.assertRaisesRegex(ValueError, "private, loopback, or special-use hosts"):
generate_image.load_input_image(
"https://localhost/input.png",
FakePILImageModule({}),
)
def test_load_input_image_rejects_file_url(self):
with self.assertRaisesRegex(ValueError, "Use a local path instead of file:// URLs"):
generate_image.load_input_image(
"file:///tmp/input.png",
FakePILImageModule({}),
)
if __name__ == "__main__":
unittest.main()

View File

@ -23,6 +23,9 @@ export type SessionStdin = {
// When backed by a real Node stream (child.stdin), this exists; for PTY wrappers it may not.
destroy?: () => void;
destroyed?: boolean;
writable?: boolean;
writableEnded?: boolean;
writableFinished?: boolean;
};
export interface ProcessSession {

View File

@ -512,7 +512,7 @@ export function createExecTool(
type: "text",
text: `${getWarningText()}Command still running (session ${run.session.id}, pid ${
run.session.pid ?? "n/a"
}). Use process (list/poll/log/write/kill/clear/remove) for follow-up.`,
}). Use process (attach/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up.`,
},
],
details: {

View File

@ -0,0 +1,183 @@
import { afterEach, expect, test, vi } from "vitest";
import { addSession, appendOutput, resetProcessRegistryForTests } from "./bash-process-registry.js";
import { createProcessSessionFixture } from "./bash-process-registry.test-helpers.js";
import { createProcessTool } from "./bash-tools.process.js";
afterEach(() => {
resetProcessRegistryForTests();
});
function readText(result: Awaited<ReturnType<ReturnType<typeof createProcessTool>["execute"]>>) {
return result.content.find((part) => part.type === "text")?.text ?? "";
}
test("process attach surfaces input-wait hints for idle interactive sessions", async () => {
vi.useFakeTimers();
try {
const now = new Date("2026-01-01T00:00:00.000Z").getTime();
vi.setSystemTime(now);
const processTool = createProcessTool({ inputWaitIdleMs: 2_000 });
const session = createProcessSessionFixture({
id: "sess-attach",
command: "expo login",
backgrounded: true,
startedAt: now - 10_000,
});
session.stdin = { write: () => {}, end: () => {}, destroyed: false };
addSession(session);
appendOutput(session, "stdout", "Enter 2FA code:\n");
const result = await processTool.execute("toolcall", {
action: "attach",
sessionId: session.id,
});
const details = result.details as {
status?: string;
waitingForInput?: boolean;
stdinWritable?: boolean;
idleMs?: number;
};
const text = readText(result);
expect(details.status).toBe("running");
expect(details.waitingForInput).toBe(true);
expect(details.stdinWritable).toBe(true);
expect(details.idleMs).toBeGreaterThanOrEqual(2_000);
expect(text).toContain("Enter 2FA code:");
expect(text).toContain("may be waiting for input");
expect(text).not.toContain("Use process attach");
expect(text).toContain("process write");
} finally {
vi.useRealTimers();
}
});
test("process poll returns waiting-for-input metadata when no new output arrives", async () => {
vi.useFakeTimers();
try {
const now = new Date("2026-01-01T00:00:00.000Z").getTime();
vi.setSystemTime(now);
const processTool = createProcessTool({ inputWaitIdleMs: 2_000 });
const session = createProcessSessionFixture({
id: "sess-poll-wait",
command: "expo login",
backgrounded: true,
startedAt: now - 10_000,
});
session.stdin = { write: () => {}, end: () => {}, destroyed: false };
addSession(session);
const poll = await processTool.execute("toolcall", {
action: "poll",
sessionId: session.id,
});
const details = poll.details as {
status?: string;
waitingForInput?: boolean;
stdinWritable?: boolean;
idleMs?: number;
};
const text = readText(poll);
expect(details.status).toBe("running");
expect(details.waitingForInput).toBe(true);
expect(details.stdinWritable).toBe(true);
expect(details.idleMs).toBeGreaterThanOrEqual(2_000);
expect(text).toContain("(no new output)");
expect(text).toContain("may be waiting for input");
} finally {
vi.useRealTimers();
}
});
test("process list marks idle interactive sessions as input-wait", async () => {
vi.useFakeTimers();
try {
const now = new Date("2026-01-01T00:00:00.000Z").getTime();
vi.setSystemTime(now);
const processTool = createProcessTool({ inputWaitIdleMs: 2_000 });
const session = createProcessSessionFixture({
id: "sess-list-wait",
command: "expo login",
backgrounded: true,
startedAt: now - 10_000,
});
session.stdin = { write: () => {}, end: () => {}, destroyed: false };
addSession(session);
const list = await processTool.execute("toolcall", { action: "list" });
const details = list.details as {
sessions?: Array<{
sessionId: string;
waitingForInput?: boolean;
stdinWritable?: boolean;
}>;
};
const text = readText(list);
const listed = details.sessions?.find((item) => item.sessionId === session.id);
expect(listed?.waitingForInput).toBe(true);
expect(listed?.stdinWritable).toBe(true);
expect(text).toContain("[input-wait]");
} finally {
vi.useRealTimers();
}
});
test("ended stdin is reported as non-writable and avoids input-wait hints", async () => {
vi.useFakeTimers();
try {
const now = new Date("2026-01-01T00:00:00.000Z").getTime();
vi.setSystemTime(now);
const processTool = createProcessTool({ inputWaitIdleMs: 2_000 });
const session = createProcessSessionFixture({
id: "sess-ended-stdin",
command: "sleep 60",
backgrounded: true,
startedAt: now - 10_000,
});
session.stdin = {
write: () => {},
end: () => {},
destroyed: false,
writableEnded: true,
};
addSession(session);
const attach = await processTool.execute("toolcall", {
action: "attach",
sessionId: session.id,
});
const attachDetails = attach.details as {
waitingForInput?: boolean;
stdinWritable?: boolean;
};
const attachText = readText(attach);
expect(attachDetails.waitingForInput).toBe(false);
expect(attachDetails.stdinWritable).toBe(false);
expect(attachText).not.toContain("may be waiting for input");
expect(attachText).not.toContain("process write");
const poll = await processTool.execute("toolcall", {
action: "poll",
sessionId: session.id,
});
const pollDetails = poll.details as {
waitingForInput?: boolean;
stdinWritable?: boolean;
status?: string;
};
const pollText = readText(poll);
expect(pollDetails.status).toBe("running");
expect(pollDetails.waitingForInput).toBe(false);
expect(pollDetails.stdinWritable).toBe(false);
expect(pollText).toContain("Process still running.");
expect(pollText).not.toContain("may be waiting for input");
} finally {
vi.useRealTimers();
}
});

View File

@ -15,19 +15,30 @@ import {
markExited,
setJobTtlMs,
} from "./bash-process-registry.js";
import { deriveSessionName, pad, sliceLogLines, truncateMiddle } from "./bash-tools.shared.js";
import {
clampWithDefault,
deriveSessionName,
pad,
readEnvInt,
sliceLogLines,
truncateMiddle,
} from "./bash-tools.shared.js";
import { recordCommandPoll, resetCommandPollCount } from "./command-poll-backoff.js";
import { encodeKeySequence, encodePaste } from "./pty-keys.js";
export type ProcessToolDefaults = {
cleanupMs?: number;
scopeKey?: string;
inputWaitIdleMs?: number;
};
type WritableStdin = {
write: (data: string, cb?: (err?: Error | null) => void) => void;
end: () => void;
destroyed?: boolean;
writable?: boolean;
writableEnded?: boolean;
writableFinished?: boolean;
};
const DEFAULT_LOG_TAIL_LINES = 200;
@ -50,7 +61,10 @@ function defaultTailNote(totalLines: number, usingDefaultTail: boolean) {
}
const processSchema = Type.Object({
action: Type.String({ description: "Process action" }),
action: Type.String({
description:
"Process action (list|attach|poll|log|write|send-keys|submit|paste|kill|clear|remove)",
}),
sessionId: Type.Optional(Type.String({ description: "Session id for actions other than list" })),
data: Type.Optional(Type.String({ description: "Data to write for write" })),
keys: Type.Optional(
@ -72,6 +86,9 @@ const processSchema = Type.Object({
});
const MAX_POLL_WAIT_MS = 120_000;
const DEFAULT_INPUT_WAIT_IDLE_MS = 15_000;
const MIN_INPUT_WAIT_IDLE_MS = 1_000;
const MAX_INPUT_WAIT_IDLE_MS = 10 * 60 * 1000;
function resolvePollWaitMs(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
@ -124,9 +141,58 @@ export function createProcessTool(
setJobTtlMs(defaults.cleanupMs);
}
const scopeKey = defaults?.scopeKey;
const inputWaitIdleMs = clampWithDefault(
defaults?.inputWaitIdleMs ?? readEnvInt("OPENCLAW_PROCESS_INPUT_WAIT_IDLE_MS"),
DEFAULT_INPUT_WAIT_IDLE_MS,
MIN_INPUT_WAIT_IDLE_MS,
MAX_INPUT_WAIT_IDLE_MS,
);
const supervisor = getProcessSupervisor();
const isInScope = (session?: { scopeKey?: string } | null) =>
!scopeKey || session?.scopeKey === scopeKey;
const resolveStdinWritable = (session: ProcessSession) => {
const stdin = session.stdin ?? session.child?.stdin;
if (!stdin || stdin.destroyed) {
return false;
}
if ("writable" in stdin && stdin.writable === false) {
return false;
}
if ("writableEnded" in stdin && stdin.writableEnded === true) {
return false;
}
if ("writableFinished" in stdin && stdin.writableFinished === true) {
return false;
}
return true;
};
const describeRunningSession = (session: ProcessSession) => {
const record = supervisor.getRecord(session.id);
const lastOutputAt = record?.lastOutputAtMs ?? session.startedAt;
const idleMs = Math.max(0, Date.now() - lastOutputAt);
const stdinWritable = resolveStdinWritable(session);
const waitingForInput = stdinWritable && idleMs >= inputWaitIdleMs;
return {
stdinWritable,
waitingForInput,
lastOutputAt,
idleMs,
};
};
const buildInputWaitHint = (
hints: { waitingForInput: boolean; idleMs: number },
opts?: { attachContext?: boolean },
) => {
if (!hints.waitingForInput) {
return "";
}
const idleLabel = formatDurationCompact(hints.idleMs) ?? `${Math.round(hints.idleMs / 1000)}s`;
const summary = `\n\nNo new output for ${idleLabel}; this session may be waiting for input.`;
if (opts?.attachContext) {
return summary;
}
return `${summary} Use process attach, then process write/send-keys/submit to continue.`;
};
const cancelManagedSession = (sessionId: string) => {
const record = supervisor.getRecord(sessionId);
@ -150,12 +216,13 @@ export function createProcessTool(
name: "process",
label: "process",
description:
"Manage running exec sessions: list, poll, log, write, send-keys, submit, paste, kill.",
"Manage running exec sessions: list, attach, poll, log, write, send-keys, submit, paste, kill.",
parameters: processSchema,
execute: async (_toolCallId, args, _signal, _onUpdate): Promise<AgentToolResult<unknown>> => {
const params = args as {
action:
| "list"
| "attach"
| "poll"
| "log"
| "write"
@ -181,18 +248,25 @@ export function createProcessTool(
if (params.action === "list") {
const running = listRunningSessions()
.filter((s) => isInScope(s))
.map((s) => ({
sessionId: s.id,
status: "running",
pid: s.pid ?? undefined,
startedAt: s.startedAt,
runtimeMs: Date.now() - s.startedAt,
cwd: s.cwd,
command: s.command,
name: deriveSessionName(s.command),
tail: s.tail,
truncated: s.truncated,
}));
.map((s) => {
const runtime = describeRunningSession(s);
return {
sessionId: s.id,
status: "running",
pid: s.pid ?? undefined,
startedAt: s.startedAt,
runtimeMs: Date.now() - s.startedAt,
cwd: s.cwd,
command: s.command,
name: deriveSessionName(s.command),
tail: s.tail,
truncated: s.truncated,
stdinWritable: runtime.stdinWritable,
waitingForInput: runtime.waitingForInput,
lastOutputAt: runtime.lastOutputAt,
idleMs: runtime.idleMs,
};
});
const finished = listFinishedSessions()
.filter((s) => isInScope(s))
.map((s) => ({
@ -213,7 +287,11 @@ export function createProcessTool(
.toSorted((a, b) => b.startedAt - a.startedAt)
.map((s) => {
const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120);
return `${s.sessionId} ${pad(s.status, 9)} ${formatDurationCompact(s.runtimeMs) ?? "n/a"} :: ${label}`;
const inputWaitTag =
s.status === "running" && "waitingForInput" in s && s.waitingForInput
? " [input-wait]"
: "";
return `${s.sessionId} ${pad(s.status, 9)} ${formatDurationCompact(s.runtimeMs) ?? "n/a"} :: ${label}${inputWaitTag}`;
});
return {
content: [
@ -256,13 +334,13 @@ export function createProcessTool(
result: failedResult(`Session ${params.sessionId} is not backgrounded.`),
};
}
const stdin = scopedSession.stdin ?? scopedSession.child?.stdin;
if (!stdin || stdin.destroyed) {
if (!resolveStdinWritable(scopedSession)) {
return {
ok: false as const,
result: failedResult(`Session ${params.sessionId} stdin is not writable.`),
};
}
const stdin = scopedSession.stdin ?? scopedSession.child?.stdin;
return { ok: true as const, session: scopedSession, stdin: stdin as WritableStdin };
};
@ -291,6 +369,81 @@ export function createProcessTool(
});
switch (params.action) {
case "attach": {
if (scopedSession) {
if (!scopedSession.backgrounded) {
return failText(`Session ${params.sessionId} is not backgrounded.`);
}
const runtime = describeRunningSession(scopedSession);
const window = resolveLogSliceWindow(params.offset, params.limit);
const { slice, totalLines, totalChars } = sliceLogLines(
scopedSession.aggregated,
window.effectiveOffset,
window.effectiveLimit,
);
const logDefaultTailNote = defaultTailNote(totalLines, window.usingDefaultTail);
const waitingHint = buildInputWaitHint(runtime, { attachContext: true });
const controlHint = runtime.stdinWritable
? "\n\nUse process write, send-keys, or submit to provide input."
: "";
return {
content: [
{
type: "text",
text:
(slice || "(no output yet)") + logDefaultTailNote + waitingHint + controlHint,
},
],
details: {
status: "running",
sessionId: params.sessionId,
total: totalLines,
totalLines,
totalChars,
truncated: scopedSession.truncated,
name: deriveSessionName(scopedSession.command),
stdinWritable: runtime.stdinWritable,
waitingForInput: runtime.waitingForInput,
idleMs: runtime.idleMs,
lastOutputAt: runtime.lastOutputAt,
},
};
}
if (scopedFinished) {
const window = resolveLogSliceWindow(params.offset, params.limit);
const { slice, totalLines, totalChars } = sliceLogLines(
scopedFinished.aggregated,
window.effectiveOffset,
window.effectiveLimit,
);
const status = scopedFinished.status === "completed" ? "completed" : "failed";
const logDefaultTailNote = defaultTailNote(totalLines, window.usingDefaultTail);
return {
content: [
{
type: "text",
text:
(slice || "(no output recorded)") +
logDefaultTailNote +
"\n\nSession already exited.",
},
],
details: {
status,
sessionId: params.sessionId,
total: totalLines,
totalLines,
totalChars,
truncated: scopedFinished.truncated,
exitCode: scopedFinished.exitCode ?? undefined,
exitSignal: scopedFinished.exitSignal ?? undefined,
name: deriveSessionName(scopedFinished.command),
},
};
}
return failText(`No session found for ${params.sessionId}`);
}
case "poll": {
if (!scopedSession) {
if (scopedFinished) {
@ -353,6 +506,7 @@ export function createProcessTool(
? "completed"
: "failed"
: "running";
const runtime = describeRunningSession(scopedSession);
const output = [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join("\n").trim();
const hasNewOutput = output.length > 0;
const retryInMs = exited
@ -371,7 +525,7 @@ export function createProcessTool(
? `\n\nProcess exited with ${
exitSignal ? `signal ${exitSignal}` : `code ${exitCode}`
}.`
: "\n\nProcess still running."),
: buildInputWaitHint(runtime) || "\n\nProcess still running."),
},
],
details: {
@ -380,6 +534,10 @@ export function createProcessTool(
exitCode: exited ? exitCode : undefined,
aggregated: scopedSession.aggregated,
name: deriveSessionName(scopedSession.command),
stdinWritable: runtime.stdinWritable,
waitingForInput: runtime.waitingForInput,
idleMs: runtime.idleMs,
lastOutputAt: runtime.lastOutputAt,
...(typeof retryInMs === "number" ? { retryInMs } : {}),
},
};
@ -405,6 +563,7 @@ export function createProcessTool(
window.effectiveLimit,
);
const logDefaultTailNote = defaultTailNote(totalLines, window.usingDefaultTail);
const runtime = describeRunningSession(scopedSession);
return {
content: [{ type: "text", text: (slice || "(no output yet)") + logDefaultTailNote }],
details: {
@ -415,6 +574,10 @@ export function createProcessTool(
totalChars,
truncated: scopedSession.truncated,
name: deriveSessionName(scopedSession.command),
stdinWritable: runtime.stdinWritable,
waitingForInput: runtime.waitingForInput,
idleMs: runtime.idleMs,
lastOutputAt: runtime.lastOutputAt,
},
};
}

View File

@ -34,6 +34,7 @@ async function createAdapterHarness(params?: {
pid?: number;
argv?: string[];
env?: NodeJS.ProcessEnv;
stdinMode?: "inherit" | "pipe-open" | "pipe-closed";
}) {
const { child, killMock } = createStubChild(params?.pid);
spawnWithFallbackMock.mockResolvedValue({
@ -43,7 +44,7 @@ async function createAdapterHarness(params?: {
const adapter = await createChildAdapter({
argv: params?.argv ?? ["node", "-e", "setTimeout(() => {}, 1000)"],
env: params?.env,
stdinMode: "pipe-open",
stdinMode: params?.stdinMode ?? "pipe-open",
});
return { adapter, killMock };
}
@ -114,4 +115,20 @@ describe("createChildAdapter", () => {
};
expect(spawnArgs.options?.env).toEqual({ FOO: "bar", COUNT: "12" });
});
it("marks stdin as destroyed when launched with pipe-closed", async () => {
const { adapter } = await createAdapterHarness({
pid: 7777,
argv: ["node", "-e", "setTimeout(() => {}, 1000)"],
stdinMode: "pipe-closed",
});
expect(adapter.stdin?.destroyed).toBe(true);
await new Promise<void>((resolve) => {
adapter.stdin?.write("input", (err) => {
expect(err).toBeTruthy();
resolve();
});
});
});
});

View File

@ -68,19 +68,31 @@ export async function createChildAdapter(params: {
});
const child = spawned.child as ChildProcessWithoutNullStreams;
let stdinDestroyed = params.input !== undefined || stdinMode === "pipe-closed";
if (child.stdin) {
if (params.input !== undefined) {
child.stdin.write(params.input);
child.stdin.end();
stdinDestroyed = true;
} else if (stdinMode === "pipe-closed") {
child.stdin.end();
stdinDestroyed = true;
}
child.stdin.on("close", () => {
stdinDestroyed = true;
});
}
const stdin: ManagedRunStdin | undefined = child.stdin
? {
destroyed: false,
get destroyed() {
return stdinDestroyed;
},
write: (data: string, cb?: (err?: Error | null) => void) => {
if (stdinDestroyed) {
cb?.(new Error("stdin is not writable"));
return;
}
try {
child.stdin.write(data, cb);
} catch (err) {
@ -88,14 +100,22 @@ export async function createChildAdapter(params: {
}
},
end: () => {
if (stdinDestroyed) {
return;
}
try {
stdinDestroyed = true;
child.stdin.end();
} catch {
// ignore close errors
}
},
destroy: () => {
if (stdinDestroyed) {
return;
}
try {
stdinDestroyed = true;
child.stdin.destroy();
} catch {
// ignore destroy errors

View File

@ -65,6 +65,7 @@ export async function createPtyAdapter(params: {
let waitPromise: Promise<{ code: number | null; signal: NodeJS.Signals | number | null }> | null =
null;
let forceKillWaitFallbackTimer: NodeJS.Timeout | null = null;
let stdinDestroyed = false;
const clearForceKillWaitFallback = () => {
if (!forceKillWaitFallbackTimer) {
@ -104,8 +105,14 @@ export async function createPtyAdapter(params: {
}) ?? null;
const stdin: ManagedRunStdin = {
destroyed: false,
get destroyed() {
return stdinDestroyed;
},
write: (data, cb) => {
if (stdinDestroyed) {
cb?.(new Error("stdin is not writable"));
return;
}
try {
pty.write(data);
cb?.(null);
@ -114,7 +121,11 @@ export async function createPtyAdapter(params: {
}
},
end: () => {
if (stdinDestroyed) {
return;
}
try {
stdinDestroyed = true;
const eof = process.platform === "win32" ? "\x1a" : "\x04";
pty.write(eof);
} catch {
@ -183,6 +194,7 @@ export async function createPtyAdapter(params: {
// ignore disposal errors
}
clearForceKillWaitFallback();
stdinDestroyed = true;
dataListener = null;
exitListener = null;
settleWait({ code: null, signal: null });