diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index aad20b164..aac7867fd 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
+### Added
+
+- Support for writing to archive entries in `.tar`, `.tar.gz`, `.tgz`, and `.zip` files using `archive.ext:path/inside/archive` syntax
+- Ability to create new archives when writing to archive subpaths that don't yet exist
## [13.17.3] - 2026-04-01
diff --git a/packages/coding-agent/src/prompts/tools/write.md b/packages/coding-agent/src/prompts/tools/write.md
index b9afcb598..d592e5f2d 100644
--- a/packages/coding-agent/src/prompts/tools/write.md
+++ b/packages/coding-agent/src/prompts/tools/write.md
@@ -3,6 +3,7 @@ Creates or overwrites file at specified path.
- Creating new files explicitly required by task
- Replacing entire file contents when editing would be more complex
+- Supports `.tar`, `.tar.gz`, `.tgz`, and `.zip` archive entries via `archive.ext:path/inside/archive`
diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts
index 223119bd7..84368b115 100644
--- a/packages/coding-agent/src/tools/write.ts
+++ b/packages/coding-agent/src/tools/write.ts
@@ -1,4 +1,5 @@
import * as fs from "node:fs/promises";
+import * as path from "node:path";
import type {
AgentTool,
AgentToolContext,
@@ -8,7 +9,7 @@ import type {
} from "@oh-my-pi/pi-agent-core";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
-import { untilAborted } from "@oh-my-pi/pi-utils";
+import { isEnoent, untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import { renderPromptTemplate } from "../config/prompt-templates";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -19,6 +20,7 @@ import writeDescription from "../prompts/tools/write.md" with { type: "text" };
import type { ToolSession } from "../sdk";
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
import { resolveFileDisplayMode } from "../utils/file-display-mode";
+import { parseArchivePathCandidates } from "./archive-reader";
import { checkAutoGeneratedFile } from "./auto-generated-guard";
import { invalidateFsScanAfterWrite } from "./fs-cache-invalidation";
import { type OutputMeta, outputMeta } from "./output-meta";
@@ -32,6 +34,7 @@ import {
replaceTabs,
shortenPath,
} from "./render-utils";
+import { ToolError } from "./tool-errors";
const writeSchema = Type.Object({
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
@@ -84,6 +87,44 @@ function stripWriteContent(session: ToolSession, content: string): { text: strin
type WriteParams = WriteToolInput;
+interface ResolvedArchiveWritePath {
+ absolutePath: string;
+ archivePath: string;
+ archiveSubPath: string;
+ exists: boolean;
+}
+
+function isArchivePathNotFound(error: unknown): boolean {
+ if (isEnoent(error)) return true;
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOTDIR";
+}
+
+function normalizeArchiveWriteSubPath(rawPath: string): string {
+ const normalized = rawPath.replace(/\\/g, "/");
+ if (normalized.length === 0) {
+ throw new ToolError("Archive write path must target a file inside the archive");
+ }
+ if (normalized.endsWith("/")) {
+ throw new ToolError("Archive write path must target a file, not a directory");
+ }
+
+ const parts = normalized.split("/");
+ const normalizedParts: string[] = [];
+ for (const part of parts) {
+ if (!part || part === ".") continue;
+ if (part === "..") {
+ throw new ToolError("Archive path cannot contain '..'");
+ }
+ normalizedParts.push(part);
+ }
+
+ if (normalizedParts.length === 0) {
+ throw new ToolError("Archive write path must target a file inside the archive");
+ }
+
+ return normalizedParts.join("/");
+}
+
/**
* Write tool implementation.
*
@@ -110,6 +151,90 @@ export class WriteTool implements AgentTool {
+ const candidates = parseArchivePathCandidates(writePath).filter(candidate => candidate.archivePath !== writePath);
+ if (candidates.length === 0) {
+ return null;
+ }
+
+ const fallbackCandidate = candidates[candidates.length - 1]!;
+ const fallback: ResolvedArchiveWritePath = {
+ absolutePath: resolvePlanPath(this.session, fallbackCandidate.archivePath),
+ archivePath: fallbackCandidate.archivePath,
+ archiveSubPath: normalizeArchiveWriteSubPath(fallbackCandidate.subPath),
+ exists: false,
+ };
+
+ for (const candidate of candidates) {
+ const absolutePath = resolvePlanPath(this.session, candidate.archivePath);
+ try {
+ const stat = await Bun.file(absolutePath).stat();
+ if (stat.isDirectory()) {
+ continue;
+ }
+
+ return {
+ absolutePath,
+ archivePath: candidate.archivePath,
+ archiveSubPath: normalizeArchiveWriteSubPath(candidate.subPath),
+ exists: true,
+ };
+ } catch (error) {
+ if (!isArchivePathNotFound(error)) {
+ throw error;
+ }
+ }
+ }
+
+ return fallback;
+ }
+
+ async #writeArchiveEntry(
+ displayPath: string,
+ content: string,
+ resolvedArchivePath: ResolvedArchiveWritePath,
+ ): Promise> {
+ const archiveEntries: Record = {};
+ if (resolvedArchivePath.exists) {
+ let archive: Bun.Archive;
+ try {
+ archive = new Bun.Archive(await Bun.file(resolvedArchivePath.absolutePath).bytes());
+ } catch (error) {
+ throw new ToolError(error instanceof Error ? error.message : String(error));
+ }
+
+ let files: Map;
+ try {
+ files = await archive.files();
+ } catch (error) {
+ throw new ToolError(error instanceof Error ? error.message : String(error));
+ }
+
+ for (const [entryPath, file] of files) {
+ archiveEntries[entryPath.replace(/\\/g, "/")] = file;
+ }
+ }
+
+ archiveEntries[resolvedArchivePath.archiveSubPath] = content;
+
+ const parentDir = path.dirname(resolvedArchivePath.absolutePath);
+ if (parentDir && parentDir !== ".") {
+ await fs.mkdir(parentDir, { recursive: true });
+ }
+
+ try {
+ await Bun.Archive.write(resolvedArchivePath.absolutePath, archiveEntries);
+ } catch (error) {
+ throw new ToolError(error instanceof Error ? error.message : String(error));
+ }
+
+ invalidateFsScanAfterWrite(resolvedArchivePath.absolutePath);
+ return {
+ content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${displayPath}` }],
+ details: {},
+ };
+ }
+
async execute(
_toolCallId: string,
{ path, content }: WriteParams,
@@ -118,6 +243,27 @@ export class WriteTool implements AgentTool> {
return untilAborted(signal, async () => {
+ // Strip hashline display prefixes (LINE#ID:) if the model copied them from read output
+ const { text: cleanContent, stripped } = stripWriteContent(this.session, content);
+ const resolvedArchivePath = await this.#resolveArchiveWritePath(path);
+ if (resolvedArchivePath) {
+ enforcePlanModeWrite(this.session, resolvedArchivePath.archivePath, {
+ op: resolvedArchivePath.exists ? "update" : "create",
+ });
+
+ const archiveResult = await this.#writeArchiveEntry(path, cleanContent, resolvedArchivePath);
+ if (stripped) {
+ const firstText = archiveResult.content.find(
+ (block): block is { type: "text"; text: string } =>
+ block.type === "text" && typeof block.text === "string",
+ );
+ if (firstText) {
+ firstText.text += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
+ }
+ }
+ return archiveResult;
+ }
+
enforcePlanModeWrite(this.session, path, { op: "create" });
const absolutePath = resolvePlanPath(this.session, path);
const batchRequest = getLspBatchRequest(context?.toolCall);
@@ -127,9 +273,6 @@ export class WriteTool implements AgentTool {
expect(fs.existsSync(expectedPath)).toBe(true);
expect(fs.readFileSync(expectedPath, "utf-8")).toBe(content);
});
+
+ it("should write to an existing archive entry", async () => {
+ const archivePath = path.join(testDir, "write-existing.zip");
+ fs.writeFileSync(
+ archivePath,
+ createZipArchive([
+ { path: "pkg/README.md", content: "# Original\n" },
+ { path: "pkg/src/index.ts", content: "export const archiveValue = 1;\n" },
+ ]),
+ );
+
+ const content = "# Updated\nLine 2\n";
+ const result = await writeTool.execute("test-call-archive-write-existing", {
+ path: `${archivePath}:pkg/README.md`,
+ content,
+ });
+
+ expect(getTextOutput(result)).toContain(
+ `Successfully wrote ${content.length} bytes to ${archivePath}:pkg/README.md`,
+ );
+
+ const archive = new Bun.Archive(await Bun.file(archivePath).bytes());
+ const files = await archive.files();
+ expect(await files.get("pkg/README.md")?.text()).toBe(content);
+ expect(await files.get("pkg/src/index.ts")?.text()).toBe("export const archiveValue = 1;\n");
+ });
+
+ it("should create a new archive when writing to an archive subpath", async () => {
+ const archivePath = path.join(testDir, "nested", "created.tar.gz");
+ const content = "created inside archive\n";
+
+ const result = await writeTool.execute("test-call-archive-write-create", {
+ path: `${archivePath}:pkg/new.txt`,
+ content,
+ });
+
+ expect(getTextOutput(result)).toContain(
+ `Successfully wrote ${content.length} bytes to ${archivePath}:pkg/new.txt`,
+ );
+ expect(fs.existsSync(archivePath)).toBe(true);
+
+ const archive = new Bun.Archive(await Bun.file(archivePath).bytes());
+ const files = await archive.files();
+ expect(await files.get("pkg/new.txt")?.text()).toBe(content);
+ });
+
+ it("should treat a plain archive filename as a regular file write", async () => {
+ const archivePath = path.join(testDir, "literal.zip");
+ const content = "plain file contents\n";
+
+ const result = await writeTool.execute("test-call-archive-plain-file", {
+ path: archivePath,
+ content,
+ });
+
+ expect(getTextOutput(result)).toContain(`Successfully wrote ${content.length} bytes to ${archivePath}`);
+ expect(fs.readFileSync(archivePath, "utf-8")).toBe(content);
+ });
});
describe("edit tool", () => {