@chrok/dsh-braid 0.0.0-stage → 0.3.2

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/dist/schema.js ADDED
@@ -0,0 +1,93 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ const StringEnum = (values, options = {}) => Type.Union(values.map(value => Type.Literal(value)), options);
3
+ const text = (description) => Type.String({ minLength: 1, pattern: "\\S", ...(description ? { description } : {}) });
4
+ const prompt = () => Type.Union([
5
+ text("Instructions for this node; it does not receive the parent conversation."),
6
+ Type.Object({
7
+ template: text("Exact key in promptTemplates."),
8
+ variables: Type.Record(Type.String(), Type.String(), {
9
+ description: "Exactly the template's placeholder names with string values; use {} if there are no placeholders. No missing or extra keys.",
10
+ }),
11
+ }, { additionalProperties: false }),
12
+ ], { description: "Required for execute/decision; optional for merge/integrate. A non-blank instruction string or a reference to a declared prompt template." });
13
+ const timeout = (scope) => Type.Optional(Type.Number({
14
+ exclusiveMinimum: 0,
15
+ maximum: 2_147_483_647,
16
+ description: scope === "graph"
17
+ ? "Total wall-clock timeout in milliseconds, including queueing and pauses. Updates/resume do not reset it; reserve time for final integration. Omit for no time limit."
18
+ : "Timeout in milliseconds per node execution; omit for no time limit.",
19
+ }));
20
+ const toolBudget = (unit) => Type.Optional(Type.Integer({
21
+ minimum: 1,
22
+ maximum: Number.MAX_SAFE_INTEGER,
23
+ description: `Maximum tool ${unit} per node, including decide and rejected requests; omit for no limit`,
24
+ }));
25
+ const commonNodeParameters = {
26
+ id: text("Unique node definition ID; edges refer to this exact ID. This is not an executionId."),
27
+ model: Type.Optional(text("Model override as provider/model-id; omit to use the parent model.")),
28
+ notifyOnCompletion: Type.Optional(Type.Boolean({ description: "Send an execution completion reminder; default false. Does not pause scheduling." })),
29
+ pauseAfter: Type.Optional(Type.Boolean({ description: "Hold this execution's outgoing dependencies until braid_resume or an atomic braid_update with resume. Sends a pause reminder." })),
30
+ requireSuccess: Type.Optional(Type.Boolean({ description: "If true, failure aborts the entire job and cancels running siblings. Default false: unconditional successors can recover." })),
31
+ };
32
+ const workspace = Type.Optional(StringEnum(["read-only", "worktree"], {
33
+ description: "Only for execute/decision; omit for merge/integrate. Defaults to worktree in Git. Both modes use fresh predecessor snapshots; read-only disables writes and shell tools. Outside Git, all workers are read-only.",
34
+ }));
35
+ // Keep all fields visible in one object. Kimi sessions using object-variant
36
+ // unions repeatedly emitted bare merge nodes instead of intended executions.
37
+ // Core validates the conditional requirements before starting or changing a job.
38
+ export const nodeParameters = Type.Object({
39
+ id: commonNodeParameters.id,
40
+ type: StringEnum(["execute", "decision", "merge", "integrate"], {
41
+ description: "execute: analyze/implement; decision: choose a route; merge: combine predecessor checkpoints in an isolated worktree; integrate: apply predecessor changes to the invoking checkout.",
42
+ }),
43
+ prompt: Type.Optional(prompt()),
44
+ choices: Type.Optional(Type.Array(text(), { minItems: 1, uniqueItems: true, description: "Required only for decision; omit for every other type. Distinct routing labels used by decide and outgoing choice edges." })),
45
+ workspace,
46
+ model: commonNodeParameters.model,
47
+ notifyOnCompletion: commonNodeParameters.notifyOnCompletion,
48
+ pauseAfter: commonNodeParameters.pauseAfter,
49
+ requireSuccess: commonNodeParameters.requireSuccess,
50
+ }, { additionalProperties: false, description: "execute/decision REQUIRE prompt; decision also REQUIRES choices. merge/integrate may omit prompt but MUST omit choices and workspace. Omit unused optional fields. All types accept model, notifyOnCompletion, pauseAfter, and requireSuccess." });
51
+ const edgeFields = {
52
+ from: text("Source node ID."),
53
+ to: text("Target node ID."),
54
+ choice: Type.Optional(text("Only for a decision source: one exact declared choice. Omit for an unconditional dependency, including error recovery.")),
55
+ feedback: Type.Optional(text("Loop ID, only on the single back edge from its decision to its entry. Requires choice and a matching loops definition; not a boolean. Cannot be combined with executionId.")),
56
+ };
57
+ const edgeParameters = Type.Object(edgeFields, { additionalProperties: false });
58
+ export const updateEdgeParameters = Type.Object({
59
+ ...edgeFields,
60
+ executionId: Type.Optional(text("Pin a completed historical source execution from braid_status; from must match that execution's node ID. Available only in braid_update, never in the initial graph.")),
61
+ }, { additionalProperties: false });
62
+ export const loopParameters = Type.Object({
63
+ id: text("Unique loop ID referenced by exactly one edge.feedback."),
64
+ entry: text("Node ID where every iteration starts; the feedback edge must target this node."),
65
+ maxIterations: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "Total rounds including the first, not the number of retries. Choosing feedback on the last round fails with LOOP_LIMIT." }),
66
+ }, { additionalProperties: false, description: "A bounded loop with one entry and a decision that selects retry or exit. The body is acyclic after removing the feedback edge; external edges enter only at entry and leave only from that decision. Loops cannot overlap or nest." });
67
+ const templateDescription = "Named prompt strings. Placeholders use {{name}} with names matching [A-Za-z_][A-Za-z0-9_]*. Node variables must match exactly; string values are inserted literally.";
68
+ const templates = Type.Record(Type.String(), text(), { description: templateDescription });
69
+ export const braidParameters = Type.Object({
70
+ goal: text("Shared goal included in every worker's context."),
71
+ nodes: Type.Array(nodeParameters, { minItems: 1 }),
72
+ edges: Type.Array(edgeParameters, { description: "Dependencies between node IDs; use [] for independent roots. Cycles require a declared loop and explicit feedback edge. Historical executionId is not allowed at submission." }),
73
+ promptTemplates: Type.Optional(templates), loops: Type.Optional(Type.Array(loopParameters)),
74
+ options: Type.Optional(Type.Object({
75
+ maxConcurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum simultaneous node executions; default 4." })),
76
+ maxExecutions: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "Total execution limit across all iterations and updates; default 1000." })),
77
+ nodeTimeoutMs: timeout("node"), graphTimeoutMs: timeout("graph"), maxToolRounds: toolBudget("rounds"), maxToolCalls: toolBudget("calls"),
78
+ }, { additionalProperties: false })),
79
+ }, { additionalProperties: false });
80
+ const BRAID_FILESYSTEM_GUIDANCE = "Every execute/decision activation gets a fresh worktree in Git, based on its predecessor execution checkpoint (roots use the initial job snapshot). Set workspace=read-only to disable writes. Multiple independent code snapshots require an explicit merge node. " +
81
+ "merge combines selected predecessor checkpoints into a new isolated worktree; integrate writes selected changes to the invoking checkout while preserving user edits. Workers apply changes with Git/file tools, then call finish_merge using executionId for each source; finish_merge only records dispositions and does not apply changes. There is no automatic final integration. " +
82
+ "Do not set workspace on merge/integrate nodes. Checkpoints remain recoverable after cleanup. Optional failed predecessors pass errors and partial work along unconditional edges; requireSuccess=true makes failure abort the job. " +
83
+ "Nodes have local read/ls/grep/find and Git inspection; writable nodes have write/edit and shell tools for dependencies, builds, and tests, while merge/integrate also have local Git integration tools. Search tools require local rg. Outside Git all filesystem access is read-only; read-only nodes have no shell. Worktrees are not an OS sandbox: prompts constrain shell writes and shared resources. Checkpoints omit ignored new files. Parent extension/MCP tools and recursive Braid calls are not provided. Nodes should verify their changes; the parent reviews results and performs any remaining validation after integration.";
84
+ export const BRAID_USAGE_GUIDANCE = [
85
+ "Braid is a proactive execution primitive, not only a user-requested command.",
86
+ "Selection rule: for a code review, bug investigation, design comparison, test-planning request, or change spanning multiple files, call braid FIRST when two or more concerns can be handled independently. Nodes can analyze the project and implement changes in isolated Git worktrees. Do this without waiting for the user to say Braid; do not read everything in the parent and then decide whether to delegate.",
87
+ BRAID_FILESYSTEM_GUIDANCE,
88
+ "For repeated instructions, define promptTemplates once and use prompt={template: name, variables: {name: value}} on nodes. Values are strings inserted literally into {{name}} placeholders; plain-string prompts remain supported.",
89
+ "When Braid fits, submit a graph: use parallel execute nodes for independent analysis or implementation, execute nodes to synthesize findings, merge nodes to combine code snapshots, and integrate nodes to apply changes to the working branch. The tool returns a jobId immediately. Continue independent work or finish your turn while it runs; do not poll repeatedly. A completion reminder will resume you. Use braid_status with the jobId to retrieve terminal outputs before relying on them.",
90
+ "Set notifyOnCompletion=true on selected nodes to receive intermediate success/failure reminders. Use braid_status({jobId, executionId}) to retrieve the exact execution; nodeId selects the latest instance. Use pauseAfter=true to hold outgoing scheduling. Definitions can always be changed with braid_update using expectedRevision; existing executions retain their captured inputs. Use resume in the same update to apply changes and release held executions atomically, or braid_resume for no graph changes.",
91
+ "Make the delegation choice once per user request. Reminders and definition errors are not new tasks. Check the accepted node types and settings in the tool response. If the same definition problem recurs, stop resubmitting and report the concrete mismatch; do not launch repeated probe/replacement jobs. In braid_update, add new nodes together with their dependencies and loops: a node added without incoming edges can start immediately, even while another execution is paused.",
92
+ "Do not use braid for a simple one-step answer, a trivial direct edit, a single shell command, or when decomposition adds no value. Writable nodes can implement and test their work. The parent reviews results and performs any remaining validation after Braid completes.",
93
+ ].join("\n");
@@ -0,0 +1,2 @@
1
+ /** Own the entire process group through completion, cancellation, and timeout. */
2
+ export declare function runCommand(shell: string, args: string[], cwd: string, signal: AbortSignal, timeout?: number): Promise<string>;
@@ -0,0 +1,90 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { constants } from "node:os";
3
+ import { join } from "node:path";
4
+ /** Own the entire process group through completion, cancellation, and timeout. */
5
+ export async function runCommand(shell, args, cwd, signal, timeout) {
6
+ let output = "";
7
+ const onData = (data) => { output = (output + data.toString()).slice(-50_000); };
8
+ signal?.throwIfAborted();
9
+ if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0 || timeout * 1000 > 2_147_483_647))
10
+ throw new Error("Invalid timeout: expected positive seconds within the timer limit");
11
+ const child = spawn(shell, args, {
12
+ cwd, detached: process.platform !== "win32", windowsHide: true,
13
+ stdio: ["ignore", "pipe", "pipe"],
14
+ });
15
+ const closed = new Promise(resolve => child.once("close", () => resolve()));
16
+ const exited = new Promise((resolve, reject) => {
17
+ child.once("error", reject);
18
+ child.once("exit", resolve);
19
+ });
20
+ child.stdout.on("data", onData);
21
+ child.stderr.on("data", onData);
22
+ let termination;
23
+ const terminate = () => termination ??= (async () => {
24
+ if (!child.pid)
25
+ return;
26
+ if (process.platform === "win32") {
27
+ await new Promise((resolve, reject) => {
28
+ execFile(join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"), ["/F", "/T", "/PID", String(child.pid)], { windowsHide: true }, error => {
29
+ // taskkill reports 128 when the process has already exited.
30
+ if (error && error.code !== 128)
31
+ reject(error);
32
+ else
33
+ resolve();
34
+ });
35
+ });
36
+ }
37
+ else {
38
+ try {
39
+ process.kill(-child.pid, "SIGKILL");
40
+ }
41
+ catch (error) {
42
+ if (error.code !== "ESRCH")
43
+ throw error;
44
+ }
45
+ }
46
+ })();
47
+ // The completion path awaits termination; event handlers must not create
48
+ // unhandled rejections while the shell is still exiting.
49
+ const stop = () => { void terminate().catch(() => { }); };
50
+ let timedOut = false;
51
+ const timer = timeout === undefined ? undefined : setTimeout(() => {
52
+ timedOut = true;
53
+ stop();
54
+ }, timeout * 1000);
55
+ signal?.addEventListener("abort", stop, { once: true });
56
+ if (signal?.aborted)
57
+ stop();
58
+ let exitCode;
59
+ try {
60
+ exitCode = await exited;
61
+ }
62
+ finally {
63
+ clearTimeout(timer);
64
+ signal?.removeEventListener("abort", stop);
65
+ // Stop leftover children even on successful command completion. Commands
66
+ // cannot leave a server/watch process writing during checkpoint/cleanup.
67
+ try {
68
+ await terminate();
69
+ }
70
+ finally {
71
+ // A daemon can leave the process group while holding inherited pipes.
72
+ // Bound pipe draining; this process-group cleanup is not an OS sandbox.
73
+ const drainTimer = setTimeout(() => {
74
+ child.stdout.destroy();
75
+ child.stderr.destroy();
76
+ }, 250);
77
+ try {
78
+ await closed;
79
+ }
80
+ finally {
81
+ clearTimeout(drainTimer);
82
+ }
83
+ }
84
+ }
85
+ if (signal?.aborted)
86
+ throw new Error("aborted");
87
+ if (timedOut)
88
+ throw new Error(`timeout:${timeout}`);
89
+ return JSON.stringify({ output, exitCode: exitCode ?? (child.signalCode ? 128 + (constants.signals[child.signalCode] ?? 0) : 1) });
90
+ }
@@ -0,0 +1,46 @@
1
+ import type { ToolDefinition, ToolRunContext } from "@deepseek-ai/dsh-tools";
2
+ import type { BraidJobs, JobSnapshot } from "./jobs.js";
3
+ export declare function graphReceipt(job: JobSnapshot): {
4
+ nodes: ({
5
+ model?: string;
6
+ type: "execute";
7
+ id: string;
8
+ notifyOnCompletion?: boolean;
9
+ requireSuccess?: boolean;
10
+ pauseAfter?: boolean;
11
+ workspace?: "read-only" | "worktree";
12
+ } | {
13
+ model?: string;
14
+ type: "decision";
15
+ id: string;
16
+ notifyOnCompletion?: boolean;
17
+ requireSuccess?: boolean;
18
+ pauseAfter?: boolean;
19
+ workspace?: "read-only" | "worktree";
20
+ choices: readonly string[];
21
+ } | {
22
+ model?: string;
23
+ type: "merge";
24
+ id: string;
25
+ notifyOnCompletion?: boolean;
26
+ requireSuccess?: boolean;
27
+ pauseAfter?: boolean;
28
+ } | {
29
+ model?: string;
30
+ type: "integrate";
31
+ id: string;
32
+ notifyOnCompletion?: boolean;
33
+ requireSuccess?: boolean;
34
+ pauseAfter?: boolean;
35
+ })[];
36
+ edges: readonly import("@chrok/braid").Edge[];
37
+ loops: readonly import("@chrok/braid").LoopDefinition[];
38
+ };
39
+ /** Keep the canonical value lossless; save large responses before returning a bounded preview. */
40
+ export declare function boundedResult(value: object): Promise<object>;
41
+ export declare function createBraidTools(getJobs: (context: ToolRunContext) => BraidJobs, submit: (args: {
42
+ goal: string;
43
+ nodes: unknown[];
44
+ edges: unknown[];
45
+ options?: object;
46
+ }, context: ToolRunContext) => object): ToolDefinition[];
package/dist/tools.js ADDED
@@ -0,0 +1,86 @@
1
+ import { mkdtemp, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { Type } from "@sinclair/typebox";
5
+ import { Value } from "@sinclair/typebox/value";
6
+ import { braidParameters, nodeParameters, updateEdgeParameters, loopParameters } from "./schema.js";
7
+ const id = Type.String({ minLength: 1, pattern: "\\S" });
8
+ const revision = Type.Integer({ minimum: 0 });
9
+ export function graphReceipt(job) {
10
+ return {
11
+ nodes: job.execution.graph.nodes.map(({ prompt: _prompt, ...node }) => node),
12
+ edges: job.execution.graph.edges, loops: job.execution.graph.loops ?? [],
13
+ };
14
+ }
15
+ /** Keep the canonical value lossless; save large responses before returning a bounded preview. */
16
+ export async function boundedResult(value) {
17
+ const full = JSON.stringify(value, null, 2);
18
+ if (Buffer.byteLength(full) <= 50_000)
19
+ return JSON.parse(full);
20
+ const directory = await mkdtemp(join(tmpdir(), "braid-dsh-result-"));
21
+ const fullOutputPath = join(directory, "result.json");
22
+ await writeFile(fullOutputPath, full, { mode: 0o600 });
23
+ const control = value;
24
+ return JSON.parse(JSON.stringify({ jobId: control.jobId, canonicalJobId: control.canonicalJobId, handle: control.handle, status: control.status,
25
+ execution: control.execution && { status: control.execution.status, revision: control.execution.revision, pausedExecutionIds: control.execution.pausedExecutionIds },
26
+ usage: control.usage, fullOutputPath, truncated: true, preview: full.slice(0, 10_000) }));
27
+ }
28
+ export function createBraidTools(getJobs, submit) {
29
+ function tool(name, description, parameters, execute) {
30
+ // TypeBox's symbol metadata is local validation state, not lossless wire JSON.
31
+ return { name, description, parameters: JSON.parse(JSON.stringify(parameters)),
32
+ output: { schema: { type: "object", additionalProperties: true }, render: (_args, value) => [{ type: "text", text: JSON.stringify(value) }] },
33
+ async execute(args, context) {
34
+ context.signal.throwIfAborted();
35
+ if (!Value.Check(parameters, args))
36
+ throw new Error(`Invalid ${name} arguments: ${[...Value.Errors(parameters, args)].map(error => `${error.path} ${error.message}`).join("; ")}`);
37
+ return boundedResult(await execute(args, context));
38
+ }, };
39
+ }
40
+ return [
41
+ tool("braid", "Start a background Braid graph before nontrivial engineering work with independent concerns. Returns jobId immediately. Supports execute/decision/merge/integrate, promptTemplates, bounded loops, worktree isolation, pauseAfter and notifyOnCompletion. Omit workspace on merge/integrate. No automatic integration. Continue independent work; retrieve results after the completion reminder instead of polling.", braidParameters, submit),
42
+ tool("braid_status", "Retrieve progress and results by exact jobId handle or UUID. Omit all IDs to list this agent's jobs. executionId selects an exact invocation; nodeId selects its latest invocation. Control fields contain the CURRENT revision and paused IDs. Large results provide a fullOutputPath. usage includes completed provider rounds from failed workers and cache tokens; reads do not add charges.", Type.Object({
43
+ jobId: Type.Optional(id), nodeId: Type.Optional(id), executionId: Type.Optional(id),
44
+ }, { additionalProperties: false }), (args, context) => {
45
+ const jobs = getJobs(context);
46
+ if (!args.jobId) {
47
+ if (args.nodeId || args.executionId)
48
+ throw new Error("nodeId/executionId requires jobId");
49
+ return { jobs: jobs.list() };
50
+ }
51
+ const job = jobs.get(args.jobId);
52
+ return args.nodeId || args.executionId ? {
53
+ jobId: job.jobId, handle: job.handle, status: job.status,
54
+ execution: { status: job.execution.status, revision: job.execution.revision, pausedExecutionIds: job.execution.pausedExecutionIds },
55
+ node: jobs.getNode(args.jobId, args.nodeId, args.executionId),
56
+ } : job;
57
+ }),
58
+ tool("braid_cancel", "Cancel a background job. Foreground cancellation does not stop Braid jobs.", Type.Object({ jobId: id }, { additionalProperties: false }), (args, context) => {
59
+ const jobs = getJobs(context), job = jobs.get(args.jobId);
60
+ return { jobId: job.handle, canonicalJobId: job.jobId, cancelled: jobs.cancel(args.jobId) };
61
+ }),
62
+ tool("braid_update", "Atomically edit live definitions and optionally resume paused executions at expectedRevision. Upserts replace complete definitions. Include new nodes and all dependencies/loops in the SAME update: disconnected roots may start immediately. removeEdges matches exact identities, not wildcards. Rejected patches have no effects; retry the complete corrected patch. Finalized jobs cannot be reopened.", Type.Object({
63
+ jobId: id, expectedRevision: revision,
64
+ upsertNodes: Type.Optional(Type.Array(nodeParameters)), removeNodeIds: Type.Optional(Type.Array(id)),
65
+ addEdges: Type.Optional(Type.Array(updateEdgeParameters)), removeEdges: Type.Optional(Type.Array(updateEdgeParameters)),
66
+ promptTemplates: Type.Optional(Type.Record(Type.String(), id)), loops: Type.Optional(Type.Array(loopParameters)),
67
+ resume: Type.Optional(Type.Array(id, { uniqueItems: true })),
68
+ }, { additionalProperties: false }), (args, context) => {
69
+ const { jobId, ...patch } = args;
70
+ let job;
71
+ try {
72
+ job = getJobs(context).update(jobId, patch);
73
+ }
74
+ catch (error) {
75
+ throw new Error(`Braid update rejected; no changes or resumes applied. ${error instanceof Error ? error.message : String(error)}. Retry the complete corrected patch with dependencies, loops, and resume IDs.`);
76
+ }
77
+ return { jobId: job.handle, canonicalJobId: job.jobId, revision: job.execution.revision, pausedExecutionIds: job.execution.pausedExecutionIds, graph: graphReceipt(job) };
78
+ }),
79
+ tool("braid_resume", "Release selected paused execution IDs at expectedRevision. This does not reset budgets or deadlines.", Type.Object({
80
+ jobId: id, expectedRevision: revision, executionIds: Type.Array(id, { minItems: 1, uniqueItems: true }),
81
+ }, { additionalProperties: false }), (args, context) => {
82
+ const job = getJobs(context).resume(args.jobId, args.executionIds, args.expectedRevision);
83
+ return { jobId: job.handle, canonicalJobId: job.jobId, revision: job.execution.revision, pausedExecutionIds: job.execution.pausedExecutionIds };
84
+ }),
85
+ ];
86
+ }
@@ -0,0 +1,9 @@
1
+ import type { ModelRequest, NodeWorkspace } from "@chrok/braid";
2
+ export interface WorkerTool {
3
+ name: string;
4
+ description: string;
5
+ parameters: Record<string, unknown>;
6
+ writes: boolean;
7
+ execute(args: unknown): Promise<string>;
8
+ }
9
+ export declare function createWorkerTools(request: ModelRequest, workspace: NodeWorkspace): Promise<WorkerTool[]>;
@@ -0,0 +1,81 @@
1
+ import { readdir, open } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+ import { Type } from "@sinclair/typebox";
6
+ import { Value } from "@sinclair/typebox/value";
7
+ import { createWriteOperations } from "./write-tools.js";
8
+ import { runCommand } from "./shell-tools.js";
9
+ export async function createWorkerTools(request, workspace) {
10
+ const cwd = workspace.workingDirectory, signal = request.signal;
11
+ const tools = [];
12
+ function add(name, description, parameters, writes, execute) {
13
+ tools.push({ name, description, parameters, writes, async execute(args) {
14
+ signal.throwIfAborted();
15
+ if (!Value.Check(parameters, args))
16
+ throw new Error(`Invalid ${name} arguments: ${[...Value.Errors(parameters, args)].map(error => `${error.path} ${error.message}`).join("; ")}`);
17
+ const result = await execute(args);
18
+ signal.throwIfAborted();
19
+ return result;
20
+ } });
21
+ }
22
+ const path = Type.String({ minLength: 1 });
23
+ add("read", "Read a UTF-8 file, with optional byte offset. Returns at most 50 KB; use the next offset for more.", Type.Object({
24
+ path, offset: Type.Optional(Type.Integer({ minimum: 0 })),
25
+ }, { additionalProperties: false }), false, async (args) => {
26
+ // Do not block opening a FIFO before the regular-file check can reject it.
27
+ const file = await open(resolve(cwd, args.path), constants.O_RDONLY | constants.O_NONBLOCK);
28
+ try {
29
+ if (!(await file.stat()).isFile())
30
+ throw new Error("read requires a regular file");
31
+ const buffer = Buffer.alloc(50_000);
32
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, args.offset ?? 0);
33
+ // Leave incomplete UTF-8 characters for the next window instead of corrupting them.
34
+ const text = new TextDecoder().decode(buffer.subarray(0, bytesRead), { stream: bytesRead === buffer.length });
35
+ const consumed = bytesRead === buffer.length ? Math.min(bytesRead, Buffer.byteLength(text)) : bytesRead;
36
+ return JSON.stringify({ text, nextOffset: (args.offset ?? 0) + consumed, eof: bytesRead < buffer.length });
37
+ }
38
+ finally {
39
+ await file.close();
40
+ }
41
+ });
42
+ add("ls", "List a directory (up to 1000 entries).", Type.Object({ path: Type.Optional(path) }, { additionalProperties: false }), false, async (args) => {
43
+ const entries = await readdir(resolve(cwd, args.path ?? "."), { withFileTypes: true });
44
+ return JSON.stringify({ entries: entries.slice(0, 1000).map(entry => entry.name + (entry.isDirectory() ? "/" : "")), truncated: entries.length > 1000 });
45
+ });
46
+ // Search is exposed only when rg is installed. No automatic downloads.
47
+ try {
48
+ execFileSync("rg", ["--version"], { stdio: "ignore", timeout: 2000 });
49
+ add("grep", "Search file content using ripgrep. Output is bounded to the last 50 KB.", Type.Object({ pattern: Type.String(), path: Type.Optional(path) }, { additionalProperties: false }), false, args => runCommand("rg", ["--no-config", "-n", "--", args.pattern, args.path ?? "."], cwd, signal));
50
+ add("find", "Find file paths by glob using ripgrep; respects ignore rules. Output is bounded to the last 50 KB.", Type.Object({ pattern: path }, { additionalProperties: false }), false, args => runCommand("rg", ["--no-config", "--files", "--glob", args.pattern], cwd, signal));
51
+ }
52
+ catch { /* ls and read remain available without rg. */ }
53
+ const root = workspace.mode === "read-only" ? undefined : workspace.mode === "integrate" ? workspace.sourceRoot : workspace.worktreeRoot;
54
+ if (root) {
55
+ const writes = await createWriteOperations(cwd, root, signal, async () => {
56
+ if (!request.git)
57
+ return [];
58
+ const result = await request.git(["ls-files", "--stage", "-z"]);
59
+ if (result.exitCode !== 0)
60
+ throw new Error("Cannot validate submodule write boundaries");
61
+ return result.stdout.split("\0").filter(entry => entry.startsWith("160000 ")).map(entry => entry.slice(entry.indexOf("\t") + 1));
62
+ });
63
+ add("write", "Write a UTF-8 file inside the assigned workspace; create parent directories as needed.", Type.Object({ path, content: Type.String() }, { additionalProperties: false }), true, async (args) => {
64
+ await writes.write(args.path, args.content);
65
+ return "File written.";
66
+ });
67
+ add("edit", "Replace exactly one occurrence of oldText with newText in a workspace file.", Type.Object({ path, oldText: Type.String({ minLength: 1 }), newText: Type.String() }, { additionalProperties: false }), true, async (args) => {
68
+ const content = await writes.read(args.path);
69
+ const first = content.indexOf(args.oldText);
70
+ if (first < 0 || content.indexOf(args.oldText, first + 1) >= 0)
71
+ throw new Error("oldText must match exactly once");
72
+ await writes.write(args.path, content.slice(0, first) + args.newText + content.slice(first + args.oldText.length));
73
+ return "File edited.";
74
+ });
75
+ const shellParameters = Type.Object({ command: path, timeout: Type.Optional(Type.Number({ exclusiveMinimum: 0, maximum: 2_147_483.647 })) }, { additionalProperties: false });
76
+ add("bash", "Run a foreground shell command in the assigned workspace. timeout is seconds. Output retains the last 50 KB. Host permissions apply; do not daemonize.", shellParameters, true, args => runCommand("bash", ["-c", args.command], cwd, signal, args.timeout));
77
+ if (process.platform === "win32")
78
+ add("powershell", "Run a foreground PowerShell command in the assigned workspace. timeout is seconds.", shellParameters, true, args => runCommand("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", args.command], cwd, signal, args.timeout));
79
+ }
80
+ return tools;
81
+ }
@@ -0,0 +1,5 @@
1
+ /** Enforce the write boundary in filesystem operations, not in model instructions. */
2
+ export declare function createWriteOperations(cwd: string, root: string, signal: AbortSignal, readOnlyPaths?: () => Promise<string[]>): Promise<{
3
+ read: (path: string) => Promise<string>;
4
+ write: (path: string, content: string) => Promise<void>;
5
+ }>;
@@ -0,0 +1,70 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, mkdir, open, readFile, realpath } from "node:fs/promises";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ /** Enforce the write boundary in filesystem operations, not in model instructions. */
5
+ export async function createWriteOperations(cwd, root, signal, readOnlyPaths = async () => []) {
6
+ const boundary = await realpath(root);
7
+ const gitMetadata = await lstat(join(boundary, ".git"));
8
+ const checked = async (path) => {
9
+ signal.throwIfAborted();
10
+ const target = resolve(path);
11
+ // Callers can pass a lexical /tmp path while realpath uses /private/tmp on macOS.
12
+ const outside = (path) => path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path);
13
+ let relativePath = relative(root, target);
14
+ if (outside(relativePath))
15
+ relativePath = relative(boundary, target);
16
+ if (outside(relativePath))
17
+ throw new Error("Writes are allowed only inside this node's worktree");
18
+ if ((await readOnlyPaths()).some(path => relativePath === path || relativePath.startsWith(`${path}${sep}`)))
19
+ throw new Error("Writing inside submodules is not supported");
20
+ const parts = relativePath.split(sep).filter(Boolean);
21
+ if (parts.some(part => part.toLowerCase() === ".git"))
22
+ throw new Error("Writing Git metadata is not allowed");
23
+ let current = boundary;
24
+ // Reject symlinks, including dangling links and linked parent directories.
25
+ for (const part of parts) {
26
+ current = join(current, part);
27
+ try {
28
+ const stat = await lstat(current);
29
+ // Also catch filesystem-specific aliases of .git (case/Unicode/streams).
30
+ if (stat.dev === gitMetadata.dev && stat.ino === gitMetadata.ino)
31
+ throw new Error("Writing Git metadata is not allowed");
32
+ if (stat.isSymbolicLink())
33
+ throw new Error("Writing through symlinks is not allowed");
34
+ if (!stat.isDirectory() && (!stat.isFile() || stat.nlink > 1))
35
+ throw new Error("Writing special files or hard links is not allowed");
36
+ }
37
+ catch (error) {
38
+ if (error.code !== "ENOENT")
39
+ throw error;
40
+ }
41
+ }
42
+ signal.throwIfAborted();
43
+ return current;
44
+ };
45
+ const writeFile = async (path, content) => {
46
+ const target = await checked(path);
47
+ // O_NOFOLLOW prevents following a replaced final symlink. Nodes have no
48
+ // operations that create links or rename directories; workspaces are private.
49
+ const file = await open(target, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW, 0o644);
50
+ try {
51
+ const stat = await file.stat();
52
+ if (!stat.isFile() || stat.nlink > 1)
53
+ throw new Error("Writing special files or hard links is not allowed");
54
+ signal.throwIfAborted();
55
+ await file.truncate(0);
56
+ await file.writeFile(content, "utf8");
57
+ }
58
+ finally {
59
+ await file.close();
60
+ }
61
+ };
62
+ return {
63
+ read: async (path) => readFile(await checked(resolve(cwd, path)), "utf8"),
64
+ write: async (path, content) => {
65
+ const target = await checked(resolve(cwd, path));
66
+ await mkdir(await checked(resolve(target, "..")), { recursive: true });
67
+ await writeFile(target, content);
68
+ },
69
+ };
70
+ }
package/package.json CHANGED
@@ -1,6 +1,119 @@
1
1
  {
2
2
  "name": "@chrok/dsh-braid",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.2",
4
+ "license": "MIT",
5
+ "description": "DeepSeek Harness plugin for Braid: background agent graphs, bounded loops, live updates, and execution progress",
6
+ "type": "module",
7
+ "dependencies": {
8
+ "@chrok/braid": "0.3.2",
9
+ "@sinclair/typebox": "^0.34.41"
10
+ },
11
+ "peerDependencies": {
12
+ "@deepseek-ai/cordis": "~4.0.4",
13
+ "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
14
+ "@deepseek-ai/dsh-commands": "0.2.0-rc.2",
15
+ "@deepseek-ai/dsh-jobs": "0.2.0-rc.2",
16
+ "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
17
+ "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2",
18
+ "@deepseek-ai/dsh-tools": "0.2.0-rc.2",
19
+ "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2",
20
+ "@deepseek-ai/dsh-typert-registry": "0.2.0-rc.2"
21
+ },
22
+ "devDependencies": {
23
+ "@deepseek-ai/cordis": "~4.0.4",
24
+ "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
25
+ "@deepseek-ai/dsh-api-gateway": "0.2.0-rc.2",
26
+ "@deepseek-ai/dsh-client-ui-chat": "0.2.0-rc.2",
27
+ "@deepseek-ai/dsh-client-ui-conversation": "0.2.0-rc.2",
28
+ "@deepseek-ai/dsh-client-ui-renderer": "0.2.0-rc.2",
29
+ "@deepseek-ai/dsh-client-ui-sidebar-right": "0.2.0-rc.2",
30
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.2",
31
+ "@deepseek-ai/dsh-client-ui-tool": "0.2.0-rc.2",
32
+ "@deepseek-ai/dsh-commands": "0.2.0-rc.2",
33
+ "@deepseek-ai/dsh-jobs": "0.2.0-rc.2",
34
+ "@deepseek-ai/dsh-jobs-local": "0.2.0-rc.2",
35
+ "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
36
+ "@deepseek-ai/dsh-session": "0.2.0-rc.2",
37
+ "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2",
38
+ "@deepseek-ai/dsh-tools": "0.2.0-rc.2",
39
+ "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2",
40
+ "@deepseek-ai/dsh-typert-registry": "0.2.0-rc.2",
41
+ "@types/jsdom": "21.1.7",
42
+ "@types/node": "^22.0.0",
43
+ "@types/react": "18.3.28",
44
+ "@types/react-dom": "18.3.7",
45
+ "esbuild": "0.28.2",
46
+ "jsdom": "26.1.0",
47
+ "react": "18.3.1",
48
+ "react-dom": "18.3.1",
49
+ "tsx": "^4.0.0",
50
+ "typescript": "^5.0.0"
51
+ },
52
+ "scripts": {
53
+ "check": "tsc --noEmit && tsc -p tsconfig.client.json --noEmit",
54
+ "test": "node build-client.mjs && tsx --tsconfig tsconfig.json --test test/*.test.ts test/*.test.mjs test/client/*.test.tsx",
55
+ "build": "node ../../scripts/build.mjs dsh",
56
+ "prepack": "npm run build"
57
+ },
58
+ "engines": {
59
+ "node": ">=22.19.0"
60
+ },
61
+ "repository": {
62
+ "type": "git",
63
+ "url": "git+https://github.com/Epsirom/braid.git",
64
+ "directory": "integrations/dsh"
65
+ },
66
+ "homepage": "https://github.com/Epsirom/braid/tree/main/integrations/dsh#readme",
67
+ "bugs": {
68
+ "url": "https://github.com/Epsirom/braid/issues"
69
+ },
70
+ "keywords": [
71
+ "dsh-plugin",
72
+ "llm",
73
+ "ai-agents",
74
+ "agents",
75
+ "agent-orchestration",
76
+ "graph",
77
+ "bounded-loops",
78
+ "git-worktree",
79
+ "typescript"
80
+ ],
81
+ "publishConfig": {
82
+ "access": "public",
83
+ "registry": "https://registry.npmjs.org"
84
+ },
85
+ "files": [
86
+ "dist",
87
+ "README.md",
88
+ "LICENSE",
89
+ "cordis.patch.yml"
90
+ ],
91
+ "exports": {
92
+ ".": {
93
+ "types": "./dist/index.d.ts",
94
+ "default": "./dist/index.js"
95
+ },
96
+ "./package.json": "./package.json",
97
+ "./client": {
98
+ "types": "./dist/client/index.d.ts",
99
+ "default": "./dist/client.js"
100
+ }
101
+ },
102
+ "main": "./dist/index.js",
103
+ "types": "./dist/index.d.ts",
104
+ "dsh": {
105
+ "bundle": {
106
+ "patch": "./cordis.patch.yml"
107
+ },
108
+ "client": {
109
+ "platform": "web",
110
+ "inject": [
111
+ "@deepseek-ai/dsh-api-gateway",
112
+ "@deepseek-ai/dsh-client-ui-renderer",
113
+ "@deepseek-ai/dsh-client-ui-conversation",
114
+ "@deepseek-ai/dsh-client-ui-sidebar-right",
115
+ "@deepseek-ai/dsh-client-ui-tool"
116
+ ]
117
+ }
118
+ }
119
+ }