@lef237/hagumi 0.0.0-stage → 0.1.0

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/src/shell.ts ADDED
@@ -0,0 +1,215 @@
1
+ // Reads a command line as the shell would, to find every program it would run.
2
+ // This is for judging commands against the user's rules; the sandbox is what actually contains them.
3
+ import { basename } from "node:path"
4
+
5
+ type Token = { kind: "word" | "op"; text: string }
6
+
7
+ // The simple commands a line would run, as argument lists, including those nested in substitutions,
8
+ // `bash -c`, `eval`, `find -exec` and wrappers such as `env` or `xargs`. `opaque` is set when the line holds
9
+ // anything whose effect cannot be read from its words: substitutions, loops, writes to files, scripts.
10
+ export type Analysis = { commands: string[][]; opaque: boolean }
11
+
12
+ const operators = ["&&", "||", "|&", ";;", ">>", "&>", "<<<", "<<", ">&", "<&", "|", "&", ";", "(", ")", "<", ">"]
13
+ const separators = new Set(["&&", "||", "|&", ";;", "|", "&", ";", "(", ")"])
14
+ const fileWrites = new Set([">", ">>", "&>"])
15
+ const keywords = new Set(["!", "{", "}", "then", "else", "elif", "fi", "do", "done", "esac", "time"])
16
+ const controls = new Set(["if", "for", "while", "until", "case", "select", "function"])
17
+ const shells = new Set(["bash", "sh", "zsh", "dash", "ksh"])
18
+ // Programs that run another program, with their options that take a value.
19
+ const wrappers: Record<string, string[]> = {
20
+ env: ["-u", "-C", "-S"],
21
+ command: [],
22
+ builtin: [],
23
+ exec: ["-a"],
24
+ nohup: [],
25
+ stdbuf: [],
26
+ nice: ["-n"],
27
+ timeout: ["-s", "-k"],
28
+ sudo: ["-u", "-g", "-C", "-D", "-h", "-p", "-U", "-r", "-t"],
29
+ doas: ["-u", "-C"],
30
+ xargs: ["-I", "-n", "-P", "-L", "-s", "-E", "-d", "-a", "-J", "-R", "-S"],
31
+ }
32
+
33
+ export function analyze(line: string, depth = 0): Analysis {
34
+ if (depth > 3) return { commands: [], opaque: true }
35
+ const { tokens, nested, opaque: unreadable } = scan(line)
36
+ let opaque = unreadable || nested.length > 0
37
+ const commands: string[][] = []
38
+ let words: string[] = []
39
+ const endCommand = () => {
40
+ const found = commandsOf(words, depth)
41
+ commands.push(...found.commands)
42
+ opaque ||= found.opaque
43
+ words = []
44
+ }
45
+
46
+ for (let i = 0; i < tokens.length; i++) {
47
+ const token = tokens[i]!
48
+ if (token.kind === "word") words.push(token.text)
49
+ else if (separators.has(token.text)) endCommand()
50
+ else {
51
+ // A redirection: the next word is where it points, not an argument.
52
+ const target = tokens[i + 1]?.kind === "word" ? tokens[++i]!.text : ""
53
+ if (token.text === "<<") opaque = true
54
+ if (fileWrites.has(token.text) && target !== "/dev/null") opaque = true
55
+ if (token.text === ">&" && !/^\d+$/.test(target)) opaque = true
56
+ }
57
+ }
58
+ endCommand()
59
+ for (const script of nested) commands.push(...analyze(script, depth + 1).commands)
60
+ return { commands, opaque }
61
+ }
62
+
63
+ function commandsOf(words: string[], depth: number): Analysis {
64
+ let opaque = false
65
+ const commands: string[][] = []
66
+ let rest = words
67
+ const skip = (count: number) => {
68
+ rest = rest.slice(count)
69
+ }
70
+
71
+ // Keywords and variable assignments come before the program, not in its place.
72
+ while (rest[0] !== undefined && (keywords.has(rest[0]) || controls.has(rest[0]) || /^\w+=/.test(rest[0]))) {
73
+ if (controls.has(rest[0]) || /^\w+=/.test(rest[0])) opaque = true
74
+ skip(1)
75
+ }
76
+ while (rest[0] !== undefined && wrappers[basename(rest[0])]) {
77
+ const wrapper = basename(rest[0])
78
+ const takesValue = wrappers[wrapper]!
79
+ commands.push(rest)
80
+ if (wrapper === "env") opaque = true
81
+ skip(1)
82
+ while (rest[0] !== undefined && (rest[0].startsWith("-") || /^\w+=/.test(rest[0]))) {
83
+ skip(takesValue.includes(rest[0]) ? 2 : 1)
84
+ }
85
+ if (wrapper === "timeout") skip(1)
86
+ }
87
+ if (rest[0] === undefined) return { commands, opaque }
88
+
89
+ const program = basename(rest[0])
90
+ const argv = [program, ...rest.slice(1)]
91
+ commands.push(rest)
92
+ if (program.startsWith("$") || program === "source" || program === ".") opaque = true
93
+ if (shells.has(program) && argv.length > 1) {
94
+ opaque = true
95
+ const script = argv[argv.indexOf("-c") + 1]
96
+ if (argv.includes("-c") && script) commands.push(...analyze(script, depth + 1).commands)
97
+ }
98
+ if (program === "eval") {
99
+ opaque = true
100
+ commands.push(...analyze(argv.slice(1).join(" "), depth + 1).commands)
101
+ }
102
+ if (program === "find") {
103
+ argv.forEach((word, index) => {
104
+ if (!["-exec", "-execdir", "-ok", "-okdir"].includes(word)) return
105
+ const after = argv.slice(index + 1)
106
+ const end = after.findIndex((part) => part === ";" || part === "+")
107
+ commands.push(...commandsOf(end < 0 ? after : after.slice(0, end), depth).commands)
108
+ })
109
+ }
110
+ return { commands, opaque }
111
+ }
112
+
113
+ // Splits a line into words and operators as the shell does, and collects the scripts nested in
114
+ // $(...), `...`, <(...) and >(...).
115
+ function scan(line: string): { tokens: Token[]; nested: string[]; opaque: boolean } {
116
+ const tokens: Token[] = []
117
+ const nested: string[] = []
118
+ let opaque = false
119
+ let word: string | undefined
120
+ const add = (text: string) => {
121
+ word = (word ?? "") + text
122
+ }
123
+ const flush = () => {
124
+ if (word !== undefined) tokens.push({ kind: "word", text: word })
125
+ word = undefined
126
+ }
127
+ // Records a nested script starting at `start`, and returns the index of its closing character.
128
+ const substitution = (start: number, close: ")" | "`"): number => {
129
+ const end = closing(line, start, close)
130
+ nested.push(line.slice(start, end))
131
+ if (end >= line.length) opaque = true
132
+ return end
133
+ }
134
+
135
+ let i = 0
136
+ while (i < line.length) {
137
+ const c = line[i]!
138
+ if (c === "\\") {
139
+ if (line[i + 1] !== "\n") add(line[i + 1] ?? "")
140
+ i += 2
141
+ } else if (c === "'") {
142
+ const end = line.indexOf("'", i + 1)
143
+ if (end < 0) opaque = true
144
+ add(line.slice(i + 1, end < 0 ? undefined : end))
145
+ i = end < 0 ? line.length : end + 1
146
+ } else if (c === '"') {
147
+ add("")
148
+ i++
149
+ while (i < line.length && line[i] !== '"') {
150
+ if (line[i] === "\\" && '"\\$`'.includes(line[i + 1] ?? "")) {
151
+ add(line[i + 1]!)
152
+ i += 2
153
+ } else if (line.startsWith("$(", i) || line[i] === "`") {
154
+ const start = line[i] === "`" ? i + 1 : i + 2
155
+ const end = substitution(start, line[i] === "`" ? "`" : ")")
156
+ add(line.slice(i, end + 1))
157
+ i = end + 1
158
+ } else {
159
+ add(line[i]!)
160
+ i++
161
+ }
162
+ }
163
+ if (i >= line.length) opaque = true
164
+ i++
165
+ } else if (line.startsWith("$(", i) || line.startsWith("<(", i) || line.startsWith(">(", i) || c === "`") {
166
+ const start = c === "`" ? i + 1 : i + 2
167
+ const end = substitution(start, c === "`" ? "`" : ")")
168
+ add(line.slice(i, end + 1))
169
+ i = end + 1
170
+ } else if (c === "\n") {
171
+ flush()
172
+ tokens.push({ kind: "op", text: ";" })
173
+ i++
174
+ } else if (c === " " || c === "\t") {
175
+ flush()
176
+ i++
177
+ } else if (c === "#" && word === undefined) {
178
+ const end = line.indexOf("\n", i)
179
+ i = end < 0 ? line.length : end
180
+ } else {
181
+ const op = operators.find((candidate) => line.startsWith(candidate, i))
182
+ if (!op) {
183
+ add(c)
184
+ i++
185
+ continue
186
+ }
187
+ // A file descriptor written right before a redirection belongs to it, as in 2>&1.
188
+ if (word !== undefined && /^\d+$/.test(word) && /[<>]/.test(op)) word = undefined
189
+ flush()
190
+ tokens.push({ kind: "op", text: op })
191
+ i += op.length
192
+ }
193
+ }
194
+ flush()
195
+ return { tokens, nested, opaque }
196
+ }
197
+
198
+ function closing(line: string, start: number, close: ")" | "`"): number {
199
+ if (close === "`") {
200
+ const end = line.indexOf("`", start)
201
+ return end < 0 ? line.length : end
202
+ }
203
+ let depth = 1
204
+ for (let i = start; i < line.length; i++) {
205
+ const c = line[i]
206
+ if (c === "\\") i++
207
+ else if (c === "'") {
208
+ const end = line.indexOf("'", i + 1)
209
+ if (end < 0) return line.length
210
+ i = end
211
+ } else if (c === "(") depth++
212
+ else if (c === ")" && --depth === 0) return i
213
+ }
214
+ return line.length
215
+ }
package/src/store.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { constants } from "node:fs"
2
+ import { lstat, mkdir, open, rename, unlink, writeFile } from "node:fs/promises"
3
+ import { basename, dirname, join } from "node:path"
4
+
5
+ export async function readJson<T>(path: string): Promise<T | undefined> {
6
+ const file = Bun.file(path)
7
+ return (await file.exists()) ? ((await file.json()) as T) : undefined
8
+ }
9
+
10
+ export async function writeJson(path: string, value: unknown): Promise<void> {
11
+ await mkdir(dirname(path), { recursive: true })
12
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`)
13
+ }
14
+
15
+ // Finish and sync a private file before atomically replacing the old credentials.
16
+ export async function writeSecret(path: string, value: unknown): Promise<void> {
17
+ const text = JSON.stringify(value, null, 2)
18
+ if (text === undefined) throw new Error("Credentials must be JSON data")
19
+ const dir = dirname(path)
20
+ await mkdir(dir, { recursive: true, mode: 0o700 })
21
+ const directory = await open(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
22
+ try {
23
+ await directory.chmod(0o700)
24
+ } finally {
25
+ await directory.close()
26
+ }
27
+ const existing = await lstat(path).catch((error) => {
28
+ if (error.code === "ENOENT") return undefined
29
+ throw error
30
+ })
31
+ if (existing && (!existing.isFile() || existing.nlink !== 1)) throw new Error("Credentials must be a regular file with one link")
32
+ const temporary = join(dir, `.${basename(path)}.${crypto.randomUUID()}.tmp`)
33
+ const file = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
34
+ try {
35
+ try {
36
+ await file.chmod(0o600)
37
+ await file.writeFile(`${text}\n`)
38
+ await file.sync()
39
+ } finally {
40
+ await file.close()
41
+ }
42
+ await rename(temporary, path)
43
+ } finally {
44
+ await unlink(temporary).catch((error) => { if (error.code !== "ENOENT") throw error })
45
+ }
46
+ }
@@ -0,0 +1,254 @@
1
+ // Everything that talks to the terminal: reading lines and secrets, and showing turns and changes.
2
+ import { homedir } from "node:os"
3
+ import { basename } from "node:path"
4
+ import { createInterface, type Interface } from "node:readline"
5
+ import { type Board, boardFrom } from "./board"
6
+ import type { TurnEvent } from "./conversation"
7
+ import { type DiffLine, hunk } from "./diff"
8
+ import { type FaceSet, withFace } from "./faces"
9
+ import { Problem, t } from "./i18n"
10
+ import type { Verdict } from "./policy"
11
+ import type { ApprovalRequest, Change, ToolContext } from "./tools"
12
+
13
+ const shownDiffLines = 40
14
+ const tty = process.stdout.isTTY
15
+
16
+ // Shows a turn as it happens. The face follows along: thinking while the model works on a reply,
17
+ // working on a tool, troubled by a failure; a reply after good work comes with a happy face.
18
+ export async function show(events: AsyncIterable<TurnEvent>, faces: FaceSet): Promise<void> {
19
+ let midLine = false
20
+ let thinking = false
21
+ let replying = false
22
+ let worked = false
23
+ let stumbled = false
24
+ // Text and tool work are set apart by a blank line, and so is the turn from the line the user typed;
25
+ // tool calls in a row stay together.
26
+ let part: "text" | "tools" | undefined
27
+ // The thinking face is a placeholder on the current line, wiped once something real arrives.
28
+ const settle = () => {
29
+ if (thinking) process.stdout.write("\r\x1b[2K")
30
+ thinking = false
31
+ }
32
+ const line = (content: string) => {
33
+ settle()
34
+ if (midLine) process.stdout.write("\n")
35
+ console.log(content)
36
+ midLine = false
37
+ replying = false
38
+ }
39
+ const enter = (next: "text" | "tools") => {
40
+ if (part === next) return
41
+ settle()
42
+ process.stdout.write(midLine ? "\n\n" : "\n")
43
+ midLine = false
44
+ replying = false
45
+ part = next
46
+ }
47
+
48
+ for await (const event of events) {
49
+ if (event.kind === "thinking") {
50
+ replying = false
51
+ if (tty && !midLine && !thinking) {
52
+ process.stdout.write(faces.thinking)
53
+ thinking = true
54
+ }
55
+ } else if (event.kind === "text") {
56
+ enter("text")
57
+ settle()
58
+ if (!replying) process.stdout.write(withFace(stumbled ? faces.troubled : worked ? faces.happy : faces.replying, ""))
59
+ replying = true
60
+ process.stdout.write(event.delta)
61
+ midLine = !event.delta.endsWith("\n")
62
+ } else if (event.kind === "compacted") {
63
+ enter("tools")
64
+ line(dim(t.compacted(event.outputs)))
65
+ } else if (event.kind === "tool_call") {
66
+ enter("tools")
67
+ const board = event.call.name === "board" ? boardIn(event.call.arguments) : undefined
68
+ line(withFace(faces.working, board ? `→ board\n${boardChange(board)}` : `→ ${event.call.name} ${clip(event.call.arguments, 80)}`))
69
+ } else if (event.ok) {
70
+ worked = true
71
+ } else {
72
+ stumbled = true
73
+ line(withFace(faces.troubled, `✗ ${event.call.name}`))
74
+ }
75
+ }
76
+ settle()
77
+ }
78
+
79
+ // A board in the user's language, each column with its items.
80
+ export function boardView(board: Board): string {
81
+ return (["doing", "todo", "done"] as const)
82
+ .filter((column) => board[column].length > 0)
83
+ .map((column) => [`${t.boardColumns[column]}:`, ...board[column].map((item) => ` - ${item}`)].join("\n"))
84
+ .join("\n")
85
+ }
86
+
87
+ // One project in the list: its name, what it is doing, and what comes next.
88
+ export function projectLine(workspace: string, board: Board): string {
89
+ return [
90
+ basename(workspace),
91
+ ...(board.doing[0] ? [t.boardNow(clip(board.doing[0], 24))] : []),
92
+ ...(board.todo[0] ? [t.boardNext(clip(board.todo[0], 24))] : []),
93
+ ].join(" ")
94
+ }
95
+
96
+ // What the board says now, in a few lines: what is being done, what comes next, and the newest work done.
97
+ function boardChange(board: Board): string {
98
+ return [
99
+ ...(["doing", "todo"] as const)
100
+ .filter((column) => board[column].length > 0)
101
+ .map((column) => ` ${t.boardColumns[column]}: ${board[column].join(" / ")}`),
102
+ ...(board.done[0] ? [` ${t.boardColumns.done}: ${board.done[0]}`] : []),
103
+ ].join("\n")
104
+ }
105
+
106
+ // The board the model wrote, when its arguments read as one.
107
+ function boardIn(args: string): Board | undefined {
108
+ try {
109
+ return boardFrom(JSON.parse(args))
110
+ } catch {
111
+ return undefined
112
+ }
113
+ }
114
+
115
+ // A path as people write it, with the home directory as ~.
116
+ export function tilde(path: string): string {
117
+ const home = homedir()
118
+ return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path
119
+ }
120
+
121
+ export function dim(text: string): string {
122
+ return tty ? `\x1b[2m${text}\x1b[0m` : text
123
+ }
124
+
125
+ // The chat's one reader of the terminal. A pending question takes the next line; other lines queue up
126
+ // as messages, and Ctrl+D ends the chat once the current message is done.
127
+ export function chatInput(input: Interface) {
128
+ const prompt = input.getPrompt()
129
+ let answer: ((line: string) => void) | undefined
130
+ return {
131
+ question(asked: string): Promise<string> {
132
+ return new Promise((resolve) => {
133
+ answer = resolve
134
+ input.setPrompt(asked)
135
+ input.prompt()
136
+ })
137
+ },
138
+ async each(handle: (line: string) => Promise<void>): Promise<void> {
139
+ let queue = Promise.resolve()
140
+ let closed = false
141
+ input.on("line", (line) => {
142
+ if (answer) {
143
+ const resolve = answer
144
+ answer = undefined
145
+ input.setPrompt(prompt)
146
+ resolve(line)
147
+ return
148
+ }
149
+ queue = queue.then(async () => {
150
+ await handle(line)
151
+ if (!closed) input.prompt()
152
+ })
153
+ })
154
+ input.prompt()
155
+ await new Promise((resolve) => input.once("close", resolve))
156
+ closed = true
157
+ // A question left open by Ctrl+D is answered with nothing, which declines.
158
+ answer?.("")
159
+ await queue
160
+ },
161
+ }
162
+ }
163
+
164
+ // One question outside a chat. Without a terminal there is no one to answer, so the answer is empty.
165
+ export async function askOnce(prompt: string): Promise<string> {
166
+ if (!process.stdin.isTTY) {
167
+ console.log(t.noTerminal)
168
+ return ""
169
+ }
170
+ const input = createInterface({ input: process.stdin, output: process.stdout })
171
+ const answer = await new Promise<string>((resolve) => input.question(prompt, resolve))
172
+ input.close()
173
+ return answer
174
+ }
175
+
176
+ // Shows each action and asks for approval. "a" approves the same tool for the rest of the session;
177
+ // what it does is still shown.
178
+ export function approver(question: (prompt: string) => Promise<string>, faces: FaceSet): ToolContext["approve"] {
179
+ const always = new Set<string>()
180
+ return async (request) => {
181
+ showRequest(request)
182
+ if (always.has(request.tool)) {
183
+ console.log(t.approvedForSession)
184
+ return true
185
+ }
186
+ const answer = (await question(withFace(faces.waiting, t.approve))).trim().toLowerCase()
187
+ if (answer === "a") always.add(request.tool)
188
+ return answer === "y" || answer === "a"
189
+ }
190
+ }
191
+
192
+ // What the user's rules decided without asking.
193
+ export function notice(request: ApprovalRequest, verdict: Verdict): void {
194
+ showRequest(request)
195
+ if (verdict.kind === "allow") console.log(t.allowedByRule(verdict.rules.join(", ")))
196
+ if (verdict.kind === "deny") console.log(t.deniedByRule(verdict.rule))
197
+ }
198
+
199
+ function showRequest(request: ApprovalRequest): void {
200
+ if ("change" in request) return showChange(request.tool, request.change)
201
+ console.log(t.commandHeader(request.command))
202
+ }
203
+
204
+ function showChange(tool: string, change: Change): void {
205
+ console.log(t.changeHeader(tool, change.path, change.before === undefined))
206
+ const lines = hunk(change.before, change.after)
207
+ for (const line of lines.slice(0, shownDiffLines)) console.log(colored(line))
208
+ if (lines.length > shownDiffLines) console.log(t.moreLines(lines.length - shownDiffLines))
209
+ }
210
+
211
+ function colored({ mark, text }: DiffLine): string {
212
+ const content = ` ${mark} ${text}`
213
+ if (!process.stdout.isTTY) return content
214
+ const color = { "-": 31, "+": 32, " ": 2 }[mark]
215
+ return `\x1b[${color}m${content}\x1b[0m`
216
+ }
217
+
218
+ // Reads one line without echoing it, so that keys do not stay on the screen.
219
+ export async function readSecret(question: string): Promise<string> {
220
+ process.stdout.write(question)
221
+ if (!process.stdin.isTTY) {
222
+ const value = (await Bun.stdin.text()).trim()
223
+ process.stdout.write("\n")
224
+ return value
225
+ }
226
+ process.stdin.setRawMode(true)
227
+ let value = ""
228
+ try {
229
+ for await (const chunk of process.stdin) {
230
+ for (const char of String(chunk)) {
231
+ if (char === "\u0003") throw new Problem("cancelled")
232
+ if (char === "\r" || char === "\n") return value.trim()
233
+ value = char === "\u007f" ? value.slice(0, -1) : value + char
234
+ }
235
+ }
236
+ return value.trim()
237
+ } finally {
238
+ process.stdin.setRawMode(false)
239
+ process.stdout.write("\n")
240
+ }
241
+ }
242
+
243
+ export function report(error: unknown): void {
244
+ console.error(error instanceof Problem ? t.problem[error.key](error.detail) : error)
245
+ }
246
+
247
+ export function openBrowser(url: string): void {
248
+ const opener = process.platform === "darwin" ? "open" : Bun.which("xdg-open")
249
+ if (opener) Bun.spawn([opener, url], { stdout: "ignore", stderr: "ignore" })
250
+ }
251
+
252
+ function clip(text: string, length: number): string {
253
+ return text.length > length ? `${text.slice(0, length - 1)}…` : text
254
+ }
@@ -0,0 +1,46 @@
1
+ import type { Tool } from "."
2
+ import { judge } from "../policy"
3
+ import { type CommandResult, runSandboxed } from "../sandbox"
4
+
5
+ const defaultTimeout = 120
6
+ const maxTimeout = 600
7
+
8
+ export const bash: Tool = {
9
+ name: "bash",
10
+ kind: "act",
11
+ description:
12
+ "Run a shell command with bash in the working directory. It can read outside the working directory, " +
13
+ "except blocked credential locations such as ~/.ssh and hagumi's storage directory. Other sensitive files are not automatically hidden. " +
14
+ "Read an outside path only when the user explicitly requests it. It runs in a sandbox: it can write to " +
15
+ "the working directory and temporary directories and cannot use the network. " +
16
+ `Commands follow the user's allow/deny rules and approval. It stops after timeout_seconds (default ${defaultTimeout}).`,
17
+ parameters: {
18
+ type: "object",
19
+ properties: {
20
+ command: { type: "string", description: "The command line, run with bash -c." },
21
+ timeout_seconds: { type: "integer", minimum: 1, maximum: maxTimeout },
22
+ },
23
+ required: ["command"],
24
+ additionalProperties: false,
25
+ },
26
+ async plan(input, context) {
27
+ const command = String(input.command)
28
+ const timeoutSeconds = Math.min(Number(input.timeout_seconds ?? defaultTimeout), maxTimeout)
29
+ return {
30
+ request: { tool: "bash", command },
31
+ verdict: judge(command, context.rules),
32
+ perform: async () =>
33
+ describe(await runSandboxed({ command, workspace: context.workspace, timeoutSeconds }), timeoutSeconds),
34
+ }
35
+ },
36
+ }
37
+
38
+ function describe(result: CommandResult, timeoutSeconds: number): string {
39
+ return [
40
+ result.timedOut ? `Stopped after ${timeoutSeconds} seconds.` : `Exit code: ${result.exitCode}`,
41
+ result.stdout && `stdout:\n${result.stdout}`,
42
+ result.stderr && `stderr:\n${result.stderr}`,
43
+ ]
44
+ .filter(Boolean)
45
+ .join("\n")
46
+ }
@@ -0,0 +1,34 @@
1
+ import type { Tool } from "."
2
+ import { boardFrom, writeBoard } from "../board"
3
+ import { boardRule } from "../prompt"
4
+
5
+ const column = (description: string) => ({ type: "array", items: { type: "string" }, description })
6
+
7
+ // Keeps the board of this project as the work moves, without asking: it is hagumi's own note of the work,
8
+ // shown on the screen at every change (docs/concept.md §6.1).
9
+ export const board: Tool = {
10
+ name: "board",
11
+ kind: "look",
12
+ description: [
13
+ "Keep the board of this project: what you are doing now, what to do next with the most important first, and what is done.",
14
+ boardRule,
15
+ "Write the whole board each time, as it stands now, so that the work can be picked up in a later session.",
16
+ "Skip it for a quick question with no steps. Write each item as one short line, in the language you are speaking.",
17
+ ].join(" "),
18
+ parameters: {
19
+ type: "object",
20
+ properties: {
21
+ doing: column("What you are doing now."),
22
+ todo: column("What to do next, most important first."),
23
+ done: column("What is done, newest first."),
24
+ },
25
+ required: ["doing", "todo", "done"],
26
+ additionalProperties: false,
27
+ },
28
+ async run(input, context) {
29
+ const board = boardFrom(input)
30
+ if (!board) throw new Error("Write doing, todo and done as lists of short lines.")
31
+ await writeBoard(context.workspace, board, context.boards)
32
+ return "The board is kept."
33
+ },
34
+ }
@@ -0,0 +1,79 @@
1
+ // Changes to files: planned by a tool, approved by the user, then written here.
2
+ import { type BigIntStats, constants, fstatSync, ftruncateSync, readFileSync, statSync, writeSync } from "node:fs"
3
+ import { type FileHandle, mkdir, open, realpath } from "node:fs/promises"
4
+ import { dirname, relative, sep } from "node:path"
5
+ import type { ToolContext } from "."
6
+ import { fingerprint, resolveInWorkspace } from "./workspace"
7
+
8
+ // `before` is undefined for a new file.
9
+ export type Change = { path: string; target: string; before: string | undefined; version?: string; after: string }
10
+
11
+ // A file's identity and revision, without losing precision for inode numbers or timestamps.
12
+ function version(state: BigIntStats): string {
13
+ if (!state.isFile() || state.nlink !== 1n) throw new Error("Changes require a regular file with only one hard link")
14
+ return `${state.dev}:${state.ino}:${state.size}:${state.mtimeNs}:${state.ctimeNs}`
15
+ }
16
+
17
+ // Resolves where a change would land, and what is there now.
18
+ export async function current(path: string, context: ToolContext): Promise<Omit<Change, "after">> {
19
+ const target = await resolveInWorkspace(path, context.workspace)
20
+ // Git runs hooks from here; nothing the model writes should land in it.
21
+ const fromRoot = relative(await realpath(context.workspace), target)
22
+ if (fromRoot.split(sep).includes(".git")) throw new Error(`${path} is inside .git`)
23
+ const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK).catch((error) => {
24
+ if (error.code === "ENOENT") return undefined
25
+ throw error
26
+ })
27
+ if (!file) return { path, target, before: undefined }
28
+
29
+ // A file is changed only as the model last read it, so that no one's edits are lost.
30
+ try {
31
+ const stamp = version(await file.stat({ bigint: true }))
32
+ const before = await file.readFile("utf8")
33
+ if (target !== await resolveInWorkspace(path, context.workspace) || version(statSync(target, { bigint: true })) !== stamp ||
34
+ version(await file.stat({ bigint: true })) !== stamp || context.seen.get(target) !== fingerprint(before)) {
35
+ throw new Error(`Read ${path} first: it has not been read in this session, or it changed since`)
36
+ }
37
+ return { path, target, before, version: stamp }
38
+ } finally {
39
+ await file.close()
40
+ }
41
+ }
42
+
43
+ export async function apply(change: Change, context: ToolContext): Promise<string> {
44
+ const changed = () => new Error(`Read ${change.path} again: it changed while awaiting approval`)
45
+ let file: FileHandle | undefined
46
+ try {
47
+ const latest = await current(change.path, context)
48
+ if (latest.target !== change.target || latest.before !== change.before || latest.version !== change.version) throw changed()
49
+ await mkdir(dirname(change.target), { recursive: true })
50
+ if (await resolveInWorkspace(change.path, context.workspace) !== change.target) throw changed()
51
+
52
+ // Never truncate on open. A new file must not exist; an existing file must still be the approved inode.
53
+ const flags = constants.O_RDWR | constants.O_NOFOLLOW | constants.O_NONBLOCK |
54
+ (change.before === undefined ? constants.O_CREAT | constants.O_EXCL : 0)
55
+ file = await open(change.target, flags)
56
+ if (await resolveInWorkspace(change.path, context.workspace) !== change.target) throw changed()
57
+ const state = fstatSync(file.fd, { bigint: true })
58
+ const stamp = version(state)
59
+ if (change.before === undefined ? state.size !== 0n : stamp !== change.version || readFileSync(file.fd, "utf8") !== change.before) throw changed()
60
+ if (version(fstatSync(file.fd, { bigint: true })) !== stamp || version(statSync(change.target, { bigint: true })) !== stamp) throw changed()
61
+
62
+ // Write to the checked descriptor at explicit offsets, with no async gap after the final checks.
63
+ const content = Buffer.from(change.after)
64
+ for (let offset = 0; offset < content.length;) {
65
+ const written = writeSync(file.fd, content, offset, content.length - offset, offset)
66
+ if (written === 0) throw new Error(`Could not finish writing ${change.path}`)
67
+ offset += written
68
+ }
69
+ ftruncateSync(file.fd, content.length)
70
+ } catch (error) {
71
+ context.seen.delete(change.target)
72
+ throw error
73
+ } finally {
74
+ await file?.close()
75
+ }
76
+ context.seen.set(change.target, fingerprint(change.after))
77
+ const lines = change.after.split("\n").length
78
+ return `${change.before === undefined ? "Created" : "Updated"} ${change.path} (${lines} lines)`
79
+ }