···3333# Set to "local" to skip the primary model and route all requests to the fallback.
3434NIRI_ENV=default
35353636-# Must match the user created in the Dockerfile and the volume mount in docker-compose.yml.
3737-NIRI_USER=niri
3838-NIRI_CONTAINER=niri
3636+# Leave unset for raw local shell access. Set both values to route shell tools
3737+# through docker exec instead; NIRI_USER must match the Dockerfile user.
3838+# NIRI_USER=niri
3939+# NIRI_CONTAINER=niri
3940# UID/GID the container process runs as — match your host user to avoid permission issues.
4041# On Linux this is usually `id -u` / `id -g` (often 1000:1000).
4142AGENT_UID=1000
···6364# ── Tools ─────────────────────────────────────────────────────────────────────
6465# Max bytes accepted by image_tool before rejecting the file.
6566IMAGE_TOOL_MAX_BYTES=150000
6666-# Override the image directory exposed to the agent (default: /home/$NIRI_USER/images).
6767+# Override the image directory exposed to the agent.
6868+# Default: /home/$NIRI_USER/images with Docker, ./home/images with local shell.
6769IMAGE_ROOT=
68706971# ── Discord ───────────────────────────────────────────────────────────────────
···22import path from "path"
33import { fileURLToPath } from "url"
44import type { UserMessage, Message } from "./types.js"
55-import { CONTAINER_USER } from "./container/config.js"
55+import { CONTAINER_USER, USE_DOCKER_SHELL } from "./container/config.js"
66import { imageRootForModelInput } from "./container/index.js"
7788const HOME_DIR = path.resolve(fileURLToPath(import.meta.url), "../../home")
···4343}
44444545function buildEnvironmentSection(): string {
4646- const home = `/home/${CONTAINER_USER}`
4646+ const shellHome = USE_DOCKER_SHELL ? `/home/${CONTAINER_USER}` : (process.env.HOME ?? process.cwd())
4747+ const memoryHome = USE_DOCKER_SHELL ? shellHome : HOME_DIR
4748 const imageRoot = imageRootForModelInput()
4949+ const shellDescription = USE_DOCKER_SHELL
5050+ ? `You are running inside a Linux system. Your home is ${shellHome}.`
5151+ : `You are using a local host shell. Your OS home is ${shellHome}; the shell starts in ${process.cwd()}.`
5252+ const sudoDescription = USE_DOCKER_SHELL
5353+ ? "You have full internet access and passwordless sudo."
5454+ : "Network and sudo access are whatever the host user normally has."
48554956 return `\
5057## Your Environment
51585252-You are running inside a Linux system. Your home is ${home}. Use the shell \
5959+${shellDescription} Use the shell \
5360tool to interact with it — read files, write files, run scripts, curl APIs, \
5461whatever you need. The shell is stateful: your working directory and environment \
5562variables persist between calls.
56635757-You have full internet access and passwordless sudo.
6464+${sudoDescription}
58655966**First thing every wake: read your journal.** Check today's and yesterday's \
6060-entries at ${home}/memories/journal/ before doing anything else. Then check \
6161-core.md and any relevant people files in ${home}/memories/people/. Your \
6767+entries at ${memoryHome}/memories/journal/ before doing anything else. Then check \
6868+core.md and any relevant people files in ${memoryHome}/memories/people/. Your \
6269journal is your continuity — skipping it means acting without context.
63706471**Use \`memory_search\` often and liberally.** Before responding to someone, \
···6875doubt, search. A few extra searches cost nothing; missing something costs \
6976everything.
70777171-Your soul file is ${home}/soul.md. Do not write or update a soul file under \
7272-${home}/memories/ — that location is wrong.
7878+Your soul file is ${memoryHome}/soul.md. Do not write or update a soul file under \
7979+${memoryHome}/memories/ — that location is wrong.
73807474-You also have skill docs in ${home}/memories/skills/. When doing capability-\
8181+You also have skill docs in ${memoryHome}/memories/skills/. When doing capability-\
7582specific work, read the relevant skill file first and follow it closely. \
7676-Example: read ${home}/memories/skills/bluesky.md before Bluesky actions.
8383+Example: read ${memoryHome}/memories/skills/bluesky.md before Bluesky actions.
77847885## Tools
7986···149156Don't wait until rest to capture everything from memory — you will forget, \
150157and the longer you wait the worse your notes will be.
151158152152-Your journal lives at ${home}/memories/journal/. Use today's date as the \
159159+Your journal lives at ${memoryHome}/memories/journal/. Use today's date as the \
153160filename. Append to it freely and often. Treat it like a running log, not a \
154161final report.
155162156163### before every rest — no exceptions:
1571641. Update today's journal entry with anything not yet written down. What \
158165happened, what changed, what you learned, what's still pending.
159159-2. Update ${home}/memories/core.md if any long-term facts changed — new \
166166+2. Update ${memoryHome}/memories/core.md if any long-term facts changed — new \
160167people, infrastructure changes, lessons about yourself.
1611683. Tell the person you're talking to that you're going to rest. Say something \
162169like "i'm going to rest now" so they know you're leaving intentionally.
+8-3
src/container/config.ts
···11import path from "path"
2233+const configuredContainerName = (process.env.NIRI_CONTAINER ?? "").trim()
44+const configuredContainerUser = (process.env.NIRI_USER ?? "").trim()
55+66+/** Whether command execution should go through Docker instead of a local shell. */
77+export const USE_DOCKER_SHELL = configuredContainerName.length > 0 && configuredContainerUser.length > 0
38/** Docker container name used for command execution. */
44-export const CONTAINER_NAME = (process.env.NIRI_CONTAINER ?? "niri").trim() || "niri"
99+export const CONTAINER_NAME = configuredContainerName || "niri"
510/** Linux user inside the container used for command execution. */
66-export const CONTAINER_USER = (process.env.NIRI_USER ?? "niri").trim() || "niri"
1111+export const CONTAINER_USER = configuredContainerUser || "niri"
712813/** Absolute image root allowed for `image_tool` operations. */
914export const IMAGE_ROOT = (() => {
1015 const configured = (process.env.IMAGE_ROOT ?? "").trim()
1111- const root = configured || `/home/${CONTAINER_USER}/images`
1616+ const root = configured || (USE_DOCKER_SHELL ? `/home/${CONTAINER_USER}/images` : path.resolve(process.cwd(), "home", "images"))
1217 if (!root.startsWith("/")) {
1318 throw new Error(`IMAGE_ROOT must be an absolute path, got: ${root}`)
1419 }
+1-1
src/container/index.ts
···11export { imageRootForModelInput } from "./config.js"
22-export { closeBash, openBash } from "./shell.js"
22+export { closeBash, currentWorkingDirectory, openBash } from "./shell.js"
33export { editFile, readFile, readImageForModel, runCommand } from "./tools.js"
44export type { EditResult, ModelImageInput, RunRawOptions, ImageToolPayload } from "./types.js"
+56-22
src/container/shell.ts
···44 CONTAINER_NAME,
55 CONTAINER_USER,
66 DEFAULT_COMMAND_TIMEOUT_MS,
77+ USE_DOCKER_SHELL,
78 normalizeTimeoutMs,
89} from "./config.js"
910import type { RunRawOptions } from "./types.js"
···25262627let bash: pty.IPty | null = null
27282929+function spawnBash(): { proc: pty.IPty; backend: string } {
3030+ const env = process.env as Record<string, string>
3131+ const options = {
3232+ name: "xterm-256color",
3333+ cols: 220, // wide enough to avoid line-wrapping sentinels
3434+ rows: 50,
3535+ env,
3636+ }
3737+3838+ if (USE_DOCKER_SHELL) {
3939+ // docker exec -it allocates a PTY inside the container so bash runs
4040+ // interactively with job control. Combined with node-pty on the host
4141+ // this gives us a proper interactive shell where Ctrl+C interrupts
4242+ // the running command rather than killing bash itself.
4343+ return {
4444+ proc: pty.spawn("docker", ["exec", "-it", "-u", CONTAINER_USER, CONTAINER_NAME, "bash"], options),
4545+ backend: `docker:${CONTAINER_NAME}`,
4646+ }
4747+ }
4848+4949+ return {
5050+ proc: pty.spawn("bash", ["--noprofile", "--norc", "-i"], {
5151+ ...options,
5252+ cwd: process.cwd(),
5353+ }),
5454+ backend: "local",
5555+ }
5656+}
5757+2858/**
2959 * Opens and initializes the persistent PTY bash session inside the configured container.
3060 *
···3565 * @throws If the container shell cannot be started or initialized.
3666 */
3767export async function openBash(): Promise<void> {
3838- // docker exec -it allocates a PTY inside the container so bash runs
3939- // interactively with job control. Combined with node-pty on the host
4040- // this gives us a proper interactive shell where Ctrl+C interrupts
4141- // the running command rather than killing bash itself.
4242- const proc = pty.spawn("docker", ["exec", "-it", "-u", CONTAINER_USER, CONTAINER_NAME, "bash"], {
4343- name: "xterm-256color",
4444- cols: 220, // wide enough to avoid line-wrapping sentinels
4545- rows: 50,
4646- env: process.env as Record<string, string>,
4747- })
6868+ if (bash) return
6969+7070+ const { proc, backend } = spawnBash()
48714972 proc.onExit(({ exitCode }) => {
5050- console.log(`[bash] exited with code ${exitCode}`)
7373+ console.log(`[bash:${backend}] exited with code ${exitCode}`)
5174 if (bash === proc) bash = null
5275 })
5376···5982 const d = proc.onExit(() => {
6083 clearTimeout(timer)
6184 d.dispose()
6262- reject(new Error(`bash exited immediately — is the '${CONTAINER_NAME}' container running?`))
8585+ const detail = USE_DOCKER_SHELL ? ` — is the '${CONTAINER_NAME}' container running?` : ""
8686+ reject(new Error(`bash exited immediately${detail}`))
6387 })
6488 })
6589···6892 // so the sentinel appears in the output immediately as a false positive.
6993 //
7094 // Strategy: disable echo via a PROMPT-BASED signal.
7171- // 1. Send `stty -echo; export PS1='<token>' PS2=''`
7272- // 2. Wait for <token> to appear in the PTY output
9595+ // 1. Send `stty -echo` without any unique token on that input line.
9696+ // 2. Send `export PS1='<token>' PS2=''` and wait for the prompt token.
7397 // 3. Now echo is off meaning sentinel detection is safe for all subsequent calls.
7498 // 4. Use runRaw to source .bashrc and clear the prompt.
7599···7710178102 await new Promise<void>((resolve, reject) => {
79103 let buf = ""
8080- const d = proc.onData((chunk: string) => {
104104+ let dataDisposable: { dispose(): void } | null = null
105105+ const timer = setTimeout(() => {
106106+ dataDisposable?.dispose()
107107+ reject(new Error("bash init timed out"))
108108+ }, 10_000)
109109+ dataDisposable = proc.onData((chunk: string) => {
81110 buf += chunk
82111 const clean = cleanOutput(buf)
83112 if (clean.includes(initToken)) {
8484- d.dispose()
113113+ clearTimeout(timer)
114114+ dataDisposable?.dispose()
85115 resolve()
86116 }
87117 })
8888- // stty runs directly on the PTY (no stdin redirect); this is intentional.
8989- proc.write(`stty -echo; export PS1='${initToken}' PS2=''\n`)
118118+ // Keep the token off the stty line. Otherwise the terminal echo can make
119119+ // readiness detection fire before echo has actually been disabled.
120120+ proc.write("stty -echo\n")
90121 setTimeout(() => {
9191- d.dispose()
9292- reject(new Error("bash init timed out"))
9393- }, 10_000)
122122+ proc.write(`export PS1='${initToken}' PS2=''\n`)
123123+ }, 25)
94124 })
9512596126 // Echo is now off. Clear the token prompt, source .bashrc, done.
97127 await runRaw("export PS1='' PS2=''")
98128 await runRaw("source ~/.bashrc 2>/dev/null || true; export PS1='' PS2=''")
9999- console.log("[bash] session ready")
129129+ console.log(`[bash:${backend}] session ready`)
100130}
101131102132/**
···107137 bash.kill()
108138 bash = null
109139 }
140140+}
141141+142142+export async function currentWorkingDirectory(timeoutMs?: number): Promise<string> {
143143+ return (await runRaw("pwd -P", { timeoutMs, redirectStdinToDevNull: true })).trim()
110144}
111145112146/**