From Command to Durable File

In OfficeCLI, the difficult part of a repeated document workflow is not sending the next command; it is knowing which version of the document that command changed and who can safely read it afterward. This chapter follows a document through one-shot execution, resident mode, batching, and the CLI's SDK and MCP integrations. The practical judgment is simple but easy to miss: a successful response may describe a changed live model, while a third-party reader needs a flushed disk file.

The resident owns a live document, not a magic file lock

A one-shot command has a straightforward lifecycle. OfficeCLI opens the file, performs an operation, and closes the handler. Repeating that pattern for a long sequence of edits is costly: the process repeatedly parses the package and repeatedly serializes changes that could have been made together. Resident mode changes the unit of work. A resident opens the document once, keeps the format-specific handler and in-memory OOXML model alive, and accepts later commands through a named-pipe connection.

The distinction between a handler and a file is important. The resident is not merely holding a convenient lock around an unchanged file. It owns a live representation of the document. A read such as get, query, or view can observe the current in-memory state. A mutation such as set, add, remove, or move updates that state and marks it dirty. The disk copy may remain unchanged until a save, close, shutdown, or configured idle flush occurs.

The resident begins in a read-oriented mode and promotes itself to editable mode when the first mutation arrives. That choice avoids opening every document as writable when an agent only wants to inspect it. After promotion, the editable state remains active for the session. This is a lifecycle transition with a practical consequence: the first edit may incur the cost of reopening the handler in writable mode, but subsequent edits can work against the same live model rather than paying that transition repeatedly.

Commands are serialized. OfficeCLI uses a command lock so two mutations cannot update the same in-memory document at once. This is not only about avoiding a data race. Consider two clients changing a table and a slide at nearly the same time, or a batch arriving while an agent asks for a screenshot. If both operations could write the package independently, the result would depend on which process happened to serialize last. A single resident gives the document one owner and one ordered mutation path.

Liveness has its own path. The resident uses a separate ping channel for checks such as “is this file already held?”, close coordination, and idle-timeout updates. Keeping that channel responsive while the main command path is busy lets a client distinguish a live but occupied session from a file with no resident. It also supports a useful invariant: when the resident answers as live, it still owns the file; when it has fully shut down, the handler has been disposed and the file lock has been released.

That invariant explains the care around shutdown. A resident should not acknowledge close while its handler is still writing or while an in-flight command can start another operation. The shutdown path first prevents new work, drains the active command, disposes the handler, and only then completes the close response. If a process receives a cooperative termination signal, the same concern applies: exiting before disposal could release the operating-system lock while losing edits that existed only in memory.

The resident's dirty state also explains its flush policies. each makes a mutation response a disk-visible boundary by saving before the command returns. auto waits until the session has gone idle and chooses a debounce interval based on observed save cost. A fixed interval provides a predictable idle delay. off keeps the strongest deferral: only an explicit save, close, or shutdown writes the package. These modes are not four names for the same behavior. They let a caller trade latency and serialization cost against how quickly other programs must see the file.

For example, an agent building a report may issue dozens of small formatting changes. Saving after every one makes each operation pay for the document's serialization cost, even though the agent is still working. A resident with deferred saving can apply the changes in memory and render or query that live state as it goes. But if a Python reader opens the path immediately after one of those mutations, it may read the older package on disk. The correct boundary is explicit: save or close before crossing from OfficeCLI's live session to a non-OfficeCLI reader, renderer, uploader, or delivery step, unless the chosen flush policy already provides that guarantee.

Deferred persistence is therefore a visible state, not an implementation surprise to hide. It permits a fast interactive workflow, but it changes what “current” means. OfficeCLI's own reads are session-aware; an unrelated process usually is not. A reliable pipeline records which side of that boundary each operation belongs to.

The save itself is designed to avoid a particularly bad failure mode. The resident serializes the complete package to a sibling temporary file and then replaces the target, rather than rewriting the only copy in place. If the process dies during serialization, the original or the new complete package remains available instead of a partially written Office file. That is a crash-safety measure, not a promise of power-loss durability or a transaction covering external actions. A save can still fail because the target was moved, permissions changed, or another process interfered.

sequenceDiagram participant A as Agent participant C as Client participant R as Resident participant D as Disk participant X as External reader A->>C: Send edit or batch C->>R: Liveness probe C->>R: Command over main pipe R->>R: Queue and update live model R->>R: Apply atomic or best-effort policy R-->>C: Result for in-memory state C-->>A: Success or warning alt Save, idle flush, or close R->>D: Serialize complete package D-->>R: Disk write completes X->>D: Open document D-->>X: Current package else No flush yet X->>D: Open document D-->>X: Older disk copy possible end

The diagram's two branches are the key operational distinction. The resident can return an accurate result about its live model before the save branch has happened. A caller that needs an external reader to see that result must cause or wait for the flush. That is why a resident session is best understood as a stateful document service with a persistence boundary, not as a faster spelling of a one-shot command.

Batching changes the transaction boundary

Resident mode reduces repeated open and save work, but batching addresses a second problem: a workflow often has a set of related operations that should be interpreted together. OfficeCLI's batch input is a JSON array of command objects. Each item uses the same bare verbs and fields as the ordinary command surface, so a batch is not a separate mini-language. It is a compact way to replay the document operations that a caller could have issued individually.

The implementation opens a non-resident document once, runs the items against the same handler, defers per-item saves, and normally saves once. This matters for a dump replay or a generated report with many changes. Serializing a growing document after every mutation can compound the cost across the sequence; deferring the writes turns the batch into one coordinated package update. Reads inside the batch can still observe earlier changes in the same live model, which makes a batch more than a blind list of independent file edits.

Efficiency alone would not make a batch reliable. A long replay can fail in the middle because one path is missing, one property is unsupported, or one value violates a format rule. Leaving the earlier items on disk creates a document that the caller did not fully request and may be difficult to reproduce. OfficeCLI therefore makes mutating batches atomic by default in the non-resident path. It applies the work to a same-directory temporary copy and promotes that copy over the original only if the batch finishes with an all-green result.

The temporary-copy boundary gives failure a clear meaning. If an item fails, the original remains untouched and the batch reports the per-item errors along with a failed outer verdict. If the final replacement fails, the command reports that failure before claiming success. A process crash while the temporary copy is being saved damages the temporary work, not the document that was supplied as the starting point. The approach costs extra copying and can encounter its own permission or locking failures, but it avoids making the user's original file the workbench for a transaction that has not yet succeeded.

A resident already has a live model, so its atomic path is different. Before an atomic mutating batch begins, it can establish the pre-batch state as a rollback point on disk when necessary. It then applies the items in memory with saves deferred. If any item fails, the resident discards the changed handler and reloads the pre-batch document, restoring the live model to the state represented by that rollback point. The result is still subject to the resident's persistence rules: a successful atomic batch can update the live document while a later explicit save remains the step that makes it visible to unrelated readers.

Atomicity is not always the right policy. Some dump replays contain elements that the current high-level model cannot represent. In that situation, preserving all successful items while reporting skipped or failed ones may be more useful than rejecting the entire artifact. --best-effort chooses that behavior. It is a deliberate acceptance of partial progress, not a weaker name for atomic mode. --stop-on-error controls whether replay stops at the first failure; combined with best-effort, it keeps earlier successful work, while in the default atomic path a failed verdict still means that the whole attempted batch is rolled back rather than partially promoted.

The outer result and the per-item results serve different purposes. Each item can retain its own success, error, warning, and code, which lets a caller locate the rejected operation. The batch itself is a judgment about the whole set: if any step fails, the outer success is false. That prevents a script or agent from treating a mixed result as a clean delivery merely because some operations succeeded. An applied-with-caveats warning is also distinguished from a failed mutation, because reissuing a command that already landed could duplicate an add or move.

The same replay logic is shared across the terminal batch command, the resident server, and in-process execution. That matters because a system with separate loops for each transport tends to acquire subtly different stop, error, and envelope behavior. Here the command vocabulary stays stable while the surrounding lifetime differs. A non-resident caller owns the open/save cycle. A resident caller shares an already-open model. An in-process host can use the same batch-shaped items without inventing another result format.

The Python and Node SDKs sit on the resident side of this boundary. They are thin clients rather than parallel object models. send forwards one batch-shaped command item; batch forwards multiple items; create and open establish a session handle. This design means a new document operation does not require a matching SDK method before it can be reached. It also means the caller must understand the underlying command vocabulary and the resident's ownership rules.

The SDK lifecycle distinguishes an owner from a borrower. An owner closes the resident when the editing scope ends, which flushes the document. A borrower can use an existing resident without taking responsibility for stopping it. That distinction is useful when several tools share a document, but it creates a coordination obligation: the borrower must not assume that its return from send means the owner has finished the session or that the disk copy is ready for upload.

Retries are deliberately narrow. A failed connection can be retried because no command reached the resident. Once the pipe connection succeeds and the command is written, a missing or broken response is not automatically resent. The operation may already have been applied, and resending an add, remove, move, swap, or batch could apply it twice. OfficeCLI chooses a visible “not delivered” or busy outcome over a silent duplicate mutation. This is a correctness choice even when it asks the caller to decide whether and how to retry.

MCP uses the same principle at a different boundary. The built-in MCP server speaks JSON-RPC over standard input and output but exposes one OfficeCLI tool whose argument is a command string or a pre-split argument array. It runs the command through the same CLI grammar rather than rebuilding every option in a separate MCP schema. Help, validation, business logic, envelopes, and errors therefore retain one meaning across a terminal and an agent integration. The tokenizer does not invoke a shell, so a command is passed to the in-process parser instead of becoming an arbitrary shell script.

A standalone MCP process defaults not to spawn a new resident, so a short-lived integration does not leave a background document session with deferred writes that its caller did not expect. That opt-out does not bypass a resident that someone else already owns. If one exists, the MCP request routes to it instead of competing for the file. If none exists, the command can open, mutate, and save directly. The result is a transport choice around the same state model, not a second ownership model.

The operational rule follows from all of these transitions. Before a third-party program opens a file, the caller must know whether the document is in a resident session, whether the live model is dirty, and whether the intended policy has flushed it. save keeps the resident available; close flushes and releases it. If a pipeline skips that boundary, it may upload a stale file while every OfficeCLI query made during the session reported the newer live state. The command protocol makes editing efficient; the caller still has to decide when the artifact leaves the session and becomes a deliverable.

References