@sema-agent/core
Version:
Stateless, task-oriented AI agent core
123 lines (122 loc) • 8.13 kB
TypeScript
/**
* design/138 S2-C — the hands band's write-time CONTENT hook (single source; `src/tools/fs` re-exports
* these so its import surface is unchanged — moved here by design/141 件2 because `RunnerDeps.hands`
* carries the deployment-composable half of the hook). Called before EVERY `env.writeFile` in
* Write/Edit/NotebookEdit with the resolved containment key and the exact final text; `{ ok:false }`
* fails the tool with a structured error and NOTHING is written. A THROWING hook fails CLOSED.
*/
export interface BeforeWriteRequest {
tool: "Write" | "Edit" | "NotebookEdit";
/** The model-supplied path argument (for the error-message coordinate the model knows). */
path: string;
/** The resolved canonical containment key — what the hook should judge. */
key: string;
/** The FINAL full text about to be written (Edit/NotebookEdit: after application). */
content: string;
}
export type BeforeWriteResult = {
ok: true;
} | {
ok: false;
code: string;
reason: string;
};
export type BeforeWriteHook = (req: BeforeWriteRequest) => BeforeWriteResult | undefined | Promise<BeforeWriteResult | undefined>;
/**
* design/381 — the hands band's FIRST-TOUCH HISTORY hook, called before EVERY file mutation in
* Write/Edit/NotebookEdit (after the `beforeWrite` content gate passed, immediately before the
* final env write) with the resolved canonical containment key. The Runner wires it to
* `FileHistoryStore.trackEdit` (backup-before-first-edit, the CC FileHistory trigger-point set
* minus the bash simulation arm sema does not have — DV-7); later calls for an already-tracked
* path are cheap store no-ops. `{ok:false}` carries the REFUSAL TEXT the tool must answer instead
* of writing (DV-14 default: no durable first-touch state ⇒ the edit is refused, typed and loud —
* any proceed-unprotected policy is resolved INSIDE the hook by the Runner, never by the band).
* A THROWING hook fails CLOSED (same posture as the write gate). Absent hook ⇒ byte-identical
* behavior (no history store wired).
*/
export interface TrackEditRequest {
tool: "Write" | "Edit" | "NotebookEdit";
/** The model-supplied path argument (for the error-message coordinate the model knows). */
path: string;
/** The resolved canonical containment key — the identity the history record is keyed on. */
key: string;
/** The tool call's abort signal, threaded into the store's env read. */
signal?: AbortSignal;
}
export type TrackEditResult = {
ok: true;
/**
* The compensating handle for the ordering the topology cannot avoid: the pre-image
* capture runs BEFORE the write, so a write that then FAILS (lost create race, ENOSPC,
* EACCES, read-only mount) would leave a first-touch record for an edit that never happened —
* and an `existed-not` record makes an untouched path a rewind DELETE target, removing bytes
* whoever writes them next (the user, a peer process) owns. Present ONLY when THIS call minted
* the record (an already-tracked path belongs to the edit that first touched it and must never
* be discarded); the band calls it on the write-failure arm of every mutation lane. `proof`
* says which retraction rule applies: `"proven"` = the write's own error code guarantees
* nothing was written, `"verify"` = ambiguous, so the history seat must re-read the path and
* retract only if it still matches what the first touch recorded. It never throws and never
* blocks the failure it accompanies — the original write error is the answer.
*/
annul?: (proof: "proven" | "verify") => Promise<void>;
} | {
ok: false;
refusal: string;
};
export type TrackFileEditHook = (req: TrackEditRequest) => Promise<TrackEditResult>;
/**
* The hands band's MUTATION-EDITED observation seat — the counterpart of {@link TrackEditRequest},
* fired at the other end of the same lane. Called once per Write/Edit/NotebookEdit call whose FINAL
* env write did not PROVABLY write nothing, at the single point every mutation lane funnels through,
* under the same three-valued law as the first-touch record's own retraction:
* - a call the argument checks, the read-before-edit gate, the write gate or the first-touch
* history seat refused never fires it (nothing was written);
* - a call whose write FAILED with a code whose contract says nothing was written never fires it;
* - a call whose write failed AMBIGUOUSLY (a non-atomic env that truncated and then errored) or
* THREW fires it — exactly the arm where the retraction KEEPS the first-touch record because the
* file may really have changed. The two seats agree on purpose: a file whose baseline was kept
* for a possible modification must also be listed as possibly modified;
* - a `Bash` command that changed a file never fires it: bash does not run through this band's
* write lanes at all, which is the whole reason this seat is not a tool-name table.
* PURE OBSERVATION: it returns nothing and it cannot refuse. A FAULT in it is contained in both
* shapes an observer can fail in — a synchronous throw, and (the return type is `void`, but an
* `async` function still type-checks there) a rejected promise, which is sunk rather than left
* unhandled. Neither is awaited: an observer must never turn a landed write into a failed tool
* answer, nor delay one.
*/
export interface FileEditedNotice {
tool: "Write" | "Edit" | "NotebookEdit";
/** The model-supplied path argument — the SAME coordinate the delegated-child projection's
* `SubagentEditedFile.path` carries, so the two seats can be read side by side. */
path: string;
/** The resolved canonical containment key the bytes actually landed on. */
key: string;
}
export type FileEditedHook = (notice: FileEditedNotice) => void;
/**
* design/141 件2 — the SAFE deployment-configurable subset of the hands toolkit ({@link RunnerDeps.hands}).
* Only fields whose injection is purely additive for a deployment are here; Runner-internal orchestration
* state (taskRegistry/detachHub/execClamp/cwdRef/mount coordination) is deliberately NOT configurable.
*/
export interface HandsBandOptions {
/** Override the `bash_readonly` command allowlist (read-only band). Default: the built-in read-only set. */
bashReadonlyAllow?: readonly string[];
/** Co-Authored-By trailer for the bash git protocol. Default NONE ([c209] BREAKING: attribution is a
* deployment identity asset — the branded scenario sets `"Name <email>"`); `false` ≡ unset. */
commitCoAuthor?: string | false;
/** Read-tool image downsampler: inject your own, or `false` to force-disable (deterministic no-sharp).
* Omitted ⇒ auto-detect sharp. Same contract as the MCP-side {@link import("./mcp.js").ImageDownsampler}. */
readImageDownsampler?: ((input: Buffer, mimeType: string) => Promise<import("./mcp.js").DownsampledImage | undefined>) | false;
/** Deployment write gate — COMPOSED with (never replacing) the engine's MemoryEngine write-scan gate:
* this hook judges first (a rejection short-circuits), the engine gate then still runs in full. */
beforeWrite?: BeforeWriteHook;
/** RB-198 F1 (CC 220 `Zry`/`WZi.#m` parity): on a foreground Bash command's OWN timeout, an eligible
* command (see `canAutoBackground` — no `git` anywhere in the command, not a `sleep`-first-word wait) is
* adopted as a background task instead of being killed — CC's own posture ("received no response for
* Nms" would otherwise force a wasteful blind re-run of a non-idempotent long command). Default `false`
* (byte-compat): this is a deployment-level opt-in, deliberately NOT inferred from background-task
* plumbing (`detachHub`/`taskRegistry`) merely being present, since the Runner wires those unconditionally
* for every task — tying eligibility to their mere presence would silently flip the timeout outcome for
* every existing caller with no explicit signal at all. */
autoBackgroundOnTimeout?: boolean;
}