Skip to content

Commit 4ef82ba

Browse files
Merge pull request #313 from corbitsdev/stack/cl-5257-session-global
Move session state under ~/.corbits/projects (CL-5257)
2 parents a24eb9e + 844f616 commit 4ef82ba

27 files changed

Lines changed: 740 additions & 198 deletions

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ Corbits Code defaults to **auto mode** (`auto = true`). Workspace file writes/ed
8282
- Git worktree boundary changes (`add` / `remove` / `prune`; read-only `git worktree list` is fine)
8383
- Shell that references sensitive paths (`.env`, private keys, certs, credential files, …)
8484
- Opaque shell wrappers the policy cannot statically inspect (variable expansion or command substitution in a wrapper payload)
85-
- Paths outside the workspace, writes under `.agent-state`, mutating MCP tools, and unknown built-ins
85+
- Paths outside the workspace, writes under the session state root, mutating MCP tools, and unknown built-ins
86+
8687

8788
### What auto hard-denies (use the file tools instead)
8889

docs/ARCHITECTURE.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ The director returns actions that shape the loop:
2727

2828
- `capabilities.continue()` — run another inference turn (implicit default).
2929
- `capabilities.reply(text)` — inject a synthetic tool result into the next turn's context.
30-
- `capabilities.checkpoint(label)` — persist a named checkpoint to `.agent-state/`.
30+
- `capabilities.checkpoint(label)` — persist a named checkpoint under the session state root (`~/.corbits/projects/...`).
31+
3132
- `capabilities.done()` — terminate the loop.
3233

3334
### Director-layer termination
@@ -148,7 +149,8 @@ Workflows are named, ordered recipes the agent follows step by step — a thin l
148149

149150
- `types.ts``Workflow`, `WorkflowStep` (`prompt`, `capability`, `agent`, `skill`, `workflow` sub-workflow ref, `optional`, `parallel`, `type: "gate"`), and the `WorkflowState` persistence shape. `MAX_WORKFLOW_DEPTH` bounds nesting.
150151
- `capabilities.ts``detectCapabilities` maps the live tool surface to abstract capabilities (`ticket-tracker`, `code-host`, `doc-search`) by name pattern; `resolveStep` decides whether a step runs. A capability override set forces integrations off per run. Adding a capability is a data edit, not a logic change.
151-
- `runtime.ts``WorkflowRuntime` drives execution on a call stack: it skips capability-unsatisfied steps, descends into sub-workflow references, emits step lifecycle events, and snapshots `WorkflowState`. `state.ts` persists that snapshot atomically to `.agent-state/workflow.json` for resume.
152+
- `runtime.ts``WorkflowRuntime` drives execution on a call stack: it skips capability-unsatisfied steps, descends into sub-workflow references, emits step lifecycle events, and snapshots `WorkflowState`. `state.ts` persists that snapshot atomically to `workflow.json` under the session state root for resume.
153+
152154
- `coordinator.ts` — bridges runtime and director: produces the `[WORKFLOW STEP i/total: label]` directive injected into each turn's system prompt, and advances the runtime when `advance_workflow` (or a `submit_output` tagged `{ step }`) completes. Shared by both directors.
153155
- The built-in recipes: the atomics `update-ticket`, `improve-docs`, `write-tests`, `triage-bug`, `code-review`, `scope-project`, and the `build-feature` composite that chains them.
154156

@@ -196,8 +198,8 @@ The agent's identity is **Corbits Code**, framed as a senior coding assistant ru
196198
### State Persistence (`src/session/state.ts`)
197199

198200
- `RunState``running` | `done` | `failed`, turns used, task, timestamps, error
199-
- Atomic JSON save/load to `.agent-state/run.json`, with schema validation on load
200-
- Conversation context is persisted separately by the git-backed store under `.agent-state/context`
201+
- Atomic JSON save/load to `run.json` under the session state root (`~/.corbits/projects/<project-key>/<session-id>/`), with schema validation on load
202+
- Conversation context is persisted separately by the git-backed store under that session's `context/` directory
201203

202204
### Lifecycle Hooks (`src/session/hooks.ts`)
203205

@@ -249,7 +251,8 @@ tool call
249251
- **classify** — Read-only tools (`read_file`, `search_files`, `grep`, `list_dir`) are tier `allow`; everything else is tier `ask`. Builds approval requests: shell yields one request for the full command the model asked to run (security still splits under the gate); file tools keyed on the target path; other tools keyed on tool name.
250252
- **command** — Splits chained commands for security classification and derives command-shape approval scopes. Multi-segment chains only offer an exact-command persist pattern (a prefix like `npm *` must not cover `npm i && rm -rf /` later).
251253
- **auto-shell-policy** — Constrains `run_shell` even when auto mode would otherwise rubber-stamp it. Before matching, `expandShellSubjects` peels `bash`/`sh`/`zsh -c`, `xargs` utility tails, and transparent prefixes (`env`, `nice`, `timeout`, …) so rules see the real payload; an unparseable wrapper (variable expansion or command substitution) sets an opaque flag that forces `ask`. Effects: `deny` blocks outright (file mutations through ad-hoc tooling — output redirection, `tee`, `sed -i`/`perl -i`, interpreter inline programs or heredocs — which must instead go through `write_file`/`edit_file`); `ask` declines to auto-allow and falls through to the operator prompt (recursive `rm`, dependency installs and remote runners: npm/yarn/pnpm/bun, pip, cargo, go, brew, npx/bunx, …, git worktree add/remove/prune, shell that references a sensitive path such as `.env` or a private key, and opaque wrappers). Deny beats ask when multiple subjects match. Quoted spans are stripped before pattern matching so a quoted `>` or install word in an argument is not flagged, and program names are matched only in command position. Adding a table category is a one-line rule append in `AUTO_SHELL_RULES`.
252-
- **gate** — Evaluates a call: `skipPermissions` allows everything; `allow`-tier passes; for `ask`-tier, checks persisted approvals, otherwise requests operator approval. Shell security classifies each chain segment (`||` / `&&` / `|` / `;` / newlines), but the operator is prompted once for the full command block — any unapproved segment fails the whole block, and execution always runs the unsplit original. Safe pipeline tails and pure shell no-ops (`true` / `false` / `:` and bare control-flow keywords stranded by chain-splitting) skip without a prompt. In a non-interactive run an unresolved `ask` becomes a denial. In auto mode: non-shell built-ins in `AUTO_ALLOWED_TOOLS` (writes/edits/deletes, `manage_tasks`, `task`, …) auto-allow when not path-restricted; for `run_shell` the gate consults the auto-shell policy — a `deny` rule fails the call, an `ask` rule skips the auto-allow shortcut and proceeds to the normal approval flow, and anything unmatched is auto-allowed. Paths outside the workspace and writes under `.agent-state` still ask. Mutating MCP and unknown built-ins are not blanket-allowed. Newly granted scopes are appended in memory and persisted.
254+
- **gate** — Evaluates a call: `skipPermissions` allows everything; `allow`-tier passes; for `ask`-tier, checks persisted approvals, otherwise requests operator approval. Shell security classifies each chain segment (`||` / `&&` / `|` / `;` / newlines), but the operator is prompted once for the full command block — any unapproved segment fails the whole block, and execution always runs the unsplit original. Safe pipeline tails and pure shell no-ops (`true` / `false` / `:` and bare control-flow keywords stranded by chain-splitting) skip without a prompt. In a non-interactive run an unresolved `ask` becomes a denial. In auto mode: non-shell built-ins in `AUTO_ALLOWED_TOOLS` (writes/edits/deletes, `manage_tasks`, `task`, …) auto-allow when not path-restricted; for `run_shell` the gate consults the auto-shell policy — a `deny` rule fails the call, an `ask` rule skips the auto-allow shortcut and proceeds to the normal approval flow, and anything unmatched is auto-allowed. Paths outside the workspace and writes under the session state root (`~/.corbits/projects/...` and legacy `.agent-state`) still ask. Mutating MCP and unknown built-ins are not blanket-allowed. Newly granted scopes are appended in memory and persisted.
255+
253256
- **matcher** — Approval pattern matching via `@intx/authz` `matchPattern` (`*` wildcards). Exact-command grants store a backslash before each metacharacter; those patterns match by equality after unescape (the package has no escape syntax).
254257
- **authz-grants** — Maps stored approvals into `@intx/authz` `GrantRule`s and evaluates them with `evaluateGrants` (allow-only; Corbits cwd/provider-model filters applied first). Exact-escaped grants bypass the package path and use equality.
255258
- **store** — Loads/persists approvals scoped to the working directory (Corbits JSON layout; not the package GrantStore).
@@ -367,7 +370,8 @@ CLI argv
367370
↓ gates / errors
368371
[blocked] → operator resolves → [running]
369372
↓ fatal inference/reactor error
370-
[failed] (TUI may surface and allow retry; context persists under .agent-state/)
373+
[failed] (TUI may surface and allow retry; context persists under the session state root)
374+
371375
```
372376

373377
There is no post-submit `build`/`typecheck`/`test` critique step in the current tree; validation is operator- and hook-driven (`postTurn`/`postRun`) plus explicit `run_shell` during agent work.

docs/IMPLEMENTATION.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,8 @@ When auto is on, the gate auto-allows workspace file tools in `AUTO_ALLOWED_TOOL
155155
| **deny** | Shell file mutation (redirects, `tee`, in-place stream editors, interpreter `-c`/`-e`/heredoc) |
156156
| **ask** | Dependency installs / remote runners, recursive `rm`, git worktree add/remove/prune, sensitive-path references, paths outside the workspace (including through a symlink), opaque unparseable wrappers |
157157

158-
Unmatched shell auto-allows. Writes under `.agent-state`, mutating MCP, and unknown built-ins still prompt. Authorization hard-denies (catastrophic commands, open-ended shell search) remain independent of auto mode.
158+
Unmatched shell auto-allows. Writes under the session state root (`~/.corbits/projects/<project-key>/…`, and legacy in-repo `.agent-state` during dual-read), mutating MCP, and unknown built-ins still prompt. Authorization hard-denies (catastrophic commands, open-ended shell search) remain independent of auto mode.
159+
159160

160161
Plan approval is handled separately by `use-gates` (`pendingPlan`), independent of auto mode.
161162

@@ -292,7 +293,8 @@ Positional arguments are joined into the optional initial task delivered when th
292293

293294
### Agent Source
294295

295-
`createAgent` is configured with a single OpenAI-compatible source built from the resolved config, `defaults.maxTokens = 16384`, and a git-backed `contextDir` at `.agent-state/context`.
296+
`createAgent` is configured with a single OpenAI-compatible source built from the resolved config, `defaults.maxTokens = 16384`, and a git-backed `contextDir` at `~/.corbits/projects/<project-key>/<session-id>/context`.
297+
296298

297299
## Protocols and Formats
298300

@@ -303,8 +305,13 @@ Positional arguments are joined into the optional initial task delivered when th
303305

304306
### State Persistence
305307

306-
- `.agent-state/run.json``RunState`
307-
- `.agent-state/context/` — git-backed conversation context (`@intx/storage-isogit`)
308+
Session runtime state lives under the global projects tree (not in the repo):
309+
310+
- `~/.corbits/projects/<project-key>/<session-id>/run.json``RunState`
311+
- `~/.corbits/projects/<project-key>/<session-id>/context/` — git-backed conversation context (`@intx/storage-isogit`)
312+
- Project key: slug + short hash of the shared git root (from `--git-common-dir`, so main + linked worktrees share one key; workspace realpath when not a git tree)
313+
314+
- Migration: if a session exists only under in-repo `.agent-state/<session-id>/`, it is moved into the global tree on open/list
308315
- Atomic JSON writes with schema validation on load
309316

310317
`createOptimizedContextStore` (`src/session/optimized-context-store.ts`) wraps the

docs/PRODUCT.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ Continues from the last saved state in the working directory.
6565
- **Denied** (must use `write_file` / `edit_file`): shell file mutations via output redirection, `tee`, `sed -i` / `perl -i`, interpreter inline programs or heredocs.
6666
- **Still asks**: dependency installs and remote runners (npm/yarn/pnpm/bun, pip, cargo, go, brew, `npx`/`bunx`, …), recursive `rm`, git worktree add/remove/prune (list is fine), shell that references sensitive paths, and opaque unparseable wrappers (variable expansion or command substitution).
6767
- **Wrapper peel**: `bash`/`sh`/`zsh -c`, `xargs`, and transparent prefixes (`env`, `nice`, `timeout`, …) are expanded so the same deny/ask rules see the inner payload.
68-
- Paths outside the workspace and writes under `.agent-state` still ask; mutating MCP and unknown tools still prompt.
68+
- Paths outside the workspace and writes under the session state root still ask; mutating MCP and unknown tools still prompt.
69+
6970
- **Path sandboxing** — Tool path arguments are resolved against the working directory; paths that escape it are blocked.
7071
- **Write verification** — After every write/edit the file is re-read and compared to confirm the change actually landed.
7172

@@ -90,7 +91,8 @@ Config-driven `postTurn` and `postRun` hooks (TypeScript or shell) run automatic
9091

9192
**What the user sees:** The agent stops producing tool calls. After 3 idle turns the run aborts with `Agent stalled: no tool calls for 3 turns.`
9293

93-
**Recovery:** State is saved; inspect `.agent-state/run.json`, adjust the task or prompt, and start a new run.
94+
**Recovery:** State is saved; inspect `~/.corbits/projects/<project-key>/<session-id>/run.json` (or a legacy in-repo `.agent-state/` tree if not yet migrated), adjust the task or prompt, and start a new run.
95+
9496

9597
### Permission denied (exec)
9698

docs/perftrace-attribution-guide.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ import { snapshot } from "../src/perf/index.js";
4747
import { dumpSpans } from "../src/perf/dump.js";
4848

4949
const path = await dumpSpans(snapshot(), {
50-
dir: ".agent-state/<sessionId>",
50+
dir: "~/.corbits/projects/<project-key>/<sessionId>",
5151
sessionId: "<sessionId>",
5252
});
53-
//.agent-state/<sessionId>/perftrace-<sessionId>.json
53+
//~/.corbits/projects/<project-key>/<sessionId>/perftrace-<sessionId>.json
5454
```
5555

5656
The dump is privacy-strict (allowlisted tags only). Safe to keep offline or
@@ -61,13 +61,13 @@ share with teammates without prompts/paths.
6161
From a local dump file alone:
6262

6363
```bash
64-
bun scripts/perf-report.ts .agent-state/<sessionId>/perftrace-<sessionId>.json
64+
bun scripts/perf-report.ts ~/.corbits/projects/<project-key>/<sessionId>/perftrace-<sessionId>.json
6565
```
6666

6767
Machine-readable JSON:
6868

6969
```bash
70-
bun scripts/perf-report.ts --json .agent-state/<sessionId>/perftrace-<sessionId>.json
70+
bun scripts/perf-report.ts --json ~/.corbits/projects/<project-key>/<sessionId>/perftrace-<sessionId>.json
7171
```
7272

7373
Golden multi-tool demo (no dump file needed — uses

src/permission/classify.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import { runShellAuthzBlockReason, runShellAuthzSegmentBlockReason } from "../sh
2020
const READ_ONLY_TOOLS = new Set(["read_file", "search_files", "grep", "list_dir", "lsp"]);
2121

2222
// Tools that take a single path-like argument the gate should check against
23-
// restriction (outside the workspace boundary, or writes under .agent-state).
23+
// restriction (outside the workspace boundary, or writes under the session state root).
24+
2425
// Covers both read-only tools (dropped from allow to ask) and the mutating
2526
// file tools (dropped from auto-allow to ask in auto mode).
2627
const PATH_ARG_TOOLS = new Set(["read_file", "search_files", "grep", "list_dir", "lsp", "write_file", "edit_file", "delete_file"]);
@@ -32,7 +33,8 @@ function pathArgKey(toolName: string): string {
3233

3334
// write_file/edit_file/delete_file mutate the target; every other path-arg tool only
3435
// reads it. Restriction policy (see path-restriction.ts) treats reads and
35-
// writes of an .agent-state path differently, so callers need to tell the
36+
// writes of a session-state path differently, so callers need to tell the
37+
3638
// gate which mode a given tool call is in.
3739
function isWriteTool(toolName: string): boolean {
3840
return toolName === "write_file" || toolName === "edit_file" || toolName === "delete_file";

src/permission/gate.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,8 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
323323
const effectiveCwd = subAgentIdentity?.cwd ?? resolvedCwd;
324324
const isRestrictedHere = bindRestrictedToProcessCwd(isRestricted, effectiveCwd);
325325
// A call targeting a restricted path (outside the workspace, or a write
326-
// under .agent-state) drops from allow to ask, so it never auto-allows on
326+
// under the session state root) drops from allow to ask, so it never auto-allows on
327+
327328
// tier or shell-safety below.
328329
const restricted = callTargetsRestricted(call, isRestrictedHere);
329330
const shellCmd =
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { afterEach, beforeEach, expect, test } from "bun:test";
2+
import { mkdir, rm } from "node:fs/promises";
3+
import { join } from "node:path";
4+
import { tmpdir } from "node:os";
5+
6+
import { createPathRestriction } from "./path-restriction.js";
7+
import { projectSessionsRoot } from "../session/project-key.js";
8+
9+
let cwd = "";
10+
let home = "";
11+
12+
beforeEach(async () => {
13+
const stamp = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
14+
cwd = join(tmpdir(), `corbits-path-rest-${stamp}`);
15+
home = join(tmpdir(), `corbits-path-rest-home-${stamp}`);
16+
await mkdir(cwd, { recursive: true });
17+
await mkdir(home, { recursive: true });
18+
});
19+
20+
afterEach(async () => {
21+
await rm(cwd, { recursive: true, force: true });
22+
await rm(home, { recursive: true, force: true });
23+
});
24+
25+
test("legacy .agent-state: reads allow, writes restricted", () => {
26+
const r = createPathRestriction(cwd, () => [], home);
27+
expect(r.isRestricted(".agent-state/run.json", false)).toBe(false);
28+
expect(r.isRestricted(".agent-state/run.json", true)).toBe(true);
29+
});
30+
31+
test("global projects session root: reads allow, writes restricted", () => {
32+
const r = createPathRestriction(cwd, () => [], home);
33+
const globalRun = join(projectSessionsRoot(cwd, home), "sess-1", "run.json");
34+
expect(r.isRestricted(globalRun, false)).toBe(false);
35+
expect(r.isRestricted(globalRun, true)).toBe(true);
36+
});
37+
38+
test("other paths under home remain outside-workspace restricted", () => {
39+
const r = createPathRestriction(cwd, () => [], home);
40+
const other = join(home, ".corbits", "settings.json");
41+
expect(r.isRestricted(other, false)).toBe(true);
42+
expect(r.isRestricted(other, true)).toBe(true);
43+
});
44+
45+
test("workspace-relative paths are unrestricted", () => {
46+
const r = createPathRestriction(cwd, () => [], home);
47+
expect(r.isRestricted("src/index.ts", false)).toBe(false);
48+
expect(r.isRestricted("src/index.ts", true)).toBe(false);
49+
});

0 commit comments

Comments
 (0)