@first-tree-ai/context-tree 0.1.13-alpha.202609090249 → 0.1.13
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/README.md +70 -250
- package/dist/cli/index.mjs +353 -70
- package/package.json +1 -1
- package/scripts/postinstall.mjs +1 -1
- package/skills/context-tree-schedule-cleanup/SKILL.md +25 -7
package/README.md
CHANGED
|
@@ -1,306 +1,126 @@
|
|
|
1
1
|
# Context Tree
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
rationale. Source repositories still own implementation detail, task history,
|
|
9
|
-
and credentials.
|
|
10
|
-
|
|
11
|
-
## Requirements
|
|
12
|
-
|
|
13
|
-
- Node.js 22.13 or newer
|
|
14
|
-
- Git
|
|
15
|
-
- GitHub CLI (`gh`) only for connecting a GitHub tree or publishing
|
|
16
|
-
|
|
17
|
-
Git and GitHub authentication remain owned by the host tools. Repository inputs
|
|
18
|
-
are credential-free `OWNER/REPO` identities, never URLs containing credentials.
|
|
3
|
+
Context Tree gives coding agents lasting project memory: decisions, constraints,
|
|
4
|
+
and the reasons behind them. Use it to avoid repeating context in new sessions,
|
|
5
|
+
keep different agents aligned, or share knowledge across related repositories.
|
|
6
|
+
Context lives in a separate Git repository. Keep it local or share it through
|
|
7
|
+
private GitHub storage.
|
|
19
8
|
|
|
20
9
|
## Install
|
|
21
10
|
|
|
11
|
+
Requires **Node.js 22.13+** and **Git**. For GitHub sharing, also install and
|
|
12
|
+
sign in to the GitHub CLI (`gh auth login`).
|
|
13
|
+
|
|
22
14
|
```bash
|
|
23
15
|
npm install --global @first-tree-ai/context-tree
|
|
24
16
|
```
|
|
25
17
|
|
|
26
|
-
|
|
27
|
-
|
|
18
|
+
This installs the CLI and skills for installed **Codex, Claude Code, and Pi**
|
|
19
|
+
agents. Restart your agent to discover the skills.
|
|
28
20
|
|
|
29
|
-
|
|
30
|
-
✓ claude → ~/.claude/skills/ (8 skills)
|
|
31
|
-
✓ codex → ~/.codex/skills/ (8 skills)
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
Restart your agent so it discovers them, then try asking:
|
|
21
|
+
## Get started
|
|
35
22
|
|
|
36
|
-
|
|
23
|
+
Open your project in your agent and ask:
|
|
37
24
|
|
|
38
|
-
>
|
|
25
|
+
> Set up a local Context Tree for this project, then read the relevant context.
|
|
39
26
|
|
|
40
|
-
|
|
41
|
-
|
|
27
|
+
You can also ask to connect an existing tree or create a private GitHub tree.
|
|
28
|
+
To create a local tree yourself, run this from your project directory:
|
|
42
29
|
|
|
43
30
|
```bash
|
|
44
|
-
context-tree
|
|
45
|
-
context-tree install --host codex # one agent
|
|
46
|
-
context-tree install --project . # ./.claude/skills and ./.codex/skills
|
|
47
|
-
context-tree uninstall # remove context-tree-* skills
|
|
31
|
+
context-tree create
|
|
48
32
|
```
|
|
49
33
|
|
|
50
|
-
|
|
51
|
-
Install never touches skills it does not own or creates a configuration directory
|
|
52
|
-
for an agent that is not present; uninstall removes every owned-prefix directory
|
|
53
|
-
and nothing else. Adding support for another agent is one entry in the host table
|
|
54
|
-
in `src/core/install.ts`.
|
|
55
|
-
|
|
56
|
-
`create` and `connect` leave project instructions unchanged. When a project has
|
|
57
|
-
a regular `AGENTS.md` and no `CLAUDE.md` entry, they best-effort create a
|
|
58
|
-
`CLAUDE.md` symlink to `AGENTS.md`. Connections are stored separately.
|
|
59
|
-
|
|
60
|
-
## Seven skills
|
|
34
|
+
The tree is saved under `~/.context-tree/trees` and connected to your project.
|
|
61
35
|
|
|
62
|
-
|
|
36
|
+
## Read and save context
|
|
63
37
|
|
|
64
|
-
|
|
65
|
-
`context-tree-setup` once, which offers an existing tree, a new local tree, a new
|
|
66
|
-
private GitHub tree, or skipping Context Tree for this session. Existing targets
|
|
67
|
-
can be managed names, GitHub `OWNER/REPO`, or exact checkout paths. Choices
|
|
68
|
-
already supplied are reused without asking again. New private GitHub trees are
|
|
69
|
-
created locally and then published; local-only creation needs no GitHub prompt.
|
|
38
|
+
Ask your agent to use the tree as you work:
|
|
70
39
|
|
|
71
|
-
|
|
72
|
-
project. Skipping continues the user's task without Context Tree and suppresses
|
|
73
|
-
further read/write and setup attempts for that project during the session,
|
|
74
|
-
unless the user reopens them. The preference stays in the conversation, not
|
|
75
|
-
project configuration. Failure or an unanswered setup question defers the
|
|
76
|
-
Context Tree operation without silently choosing a fallback or claiming success.
|
|
77
|
-
An installed skill does not require the user to adopt Context Tree.
|
|
40
|
+
> Read the Context Tree before planning this change.
|
|
78
41
|
|
|
79
|
-
|
|
42
|
+
> Save our decision to use a single writer, including why we rejected multiple writers.
|
|
80
43
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
44
|
+
The read skill retrieves relevant context; the write skill updates it and commits
|
|
45
|
+
changes, pushing them when the tree is shared on GitHub. Save decisions and
|
|
46
|
+
constraints that future work should respect, with their rationale.
|
|
84
47
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
48
|
+
To invoke a skill explicitly, use `$context-tree-read` in Codex,
|
|
49
|
+
`/context-tree-read` in Claude Code, or `/skill:context-tree-read` in Pi.
|
|
50
|
+
Replace `read` with `write`, `setup`, `create`, `connect`, `publish`, `cleanup`,
|
|
51
|
+
or `schedule-cleanup` for the other workflows.
|
|
88
52
|
|
|
89
|
-
|
|
53
|
+
## Share or connect an existing tree
|
|
90
54
|
|
|
91
|
-
|
|
55
|
+
Publish your local tree as a **new private GitHub repository**:
|
|
92
56
|
|
|
93
57
|
```bash
|
|
94
|
-
context-tree
|
|
58
|
+
context-tree publish OWNER/REPO
|
|
95
59
|
```
|
|
96
60
|
|
|
97
|
-
|
|
61
|
+
From another project or machine, connect to it:
|
|
98
62
|
|
|
99
63
|
```bash
|
|
100
|
-
context-tree connect OWNER/REPO
|
|
64
|
+
context-tree connect OWNER/REPO
|
|
101
65
|
```
|
|
102
66
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
```bash
|
|
106
|
-
context-tree connect --tree-path /path/to/a/tree --project-path ./service
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
`connect --tree-path` requires an exact, clean, fully valid Git root with no
|
|
110
|
-
symlink components. Trees without an origin connect as local state;
|
|
111
|
-
credential-free GitHub origins connect as GitHub state. External disk trees
|
|
112
|
-
are never copied, moved, or deleted.
|
|
113
|
-
|
|
114
|
-
An identical connection is idempotent. An explicit connect automatically
|
|
115
|
-
switches the project. GitHub checkouts use the repository's lowercase name in
|
|
116
|
-
the same flat managed namespace as created trees.
|
|
117
|
-
|
|
118
|
-
`context-tree list` reports valid, clean managed trees; `context-tree list --json`
|
|
119
|
-
returns them as `{ schemaVersion: 1, trees: [{ name, tree }] }`, and a missing
|
|
120
|
-
managed directory is an empty list.
|
|
121
|
-
|
|
122
|
-
### Read
|
|
67
|
+
You can also reuse a local tree by name or connect a checkout on disk:
|
|
123
68
|
|
|
124
69
|
```bash
|
|
125
|
-
context-tree
|
|
126
|
-
context-tree
|
|
70
|
+
context-tree list
|
|
71
|
+
context-tree connect my-project-context-tree
|
|
72
|
+
context-tree connect --tree-path /absolute/path/to/tree
|
|
127
73
|
```
|
|
128
74
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
branch. Reads navigate from indexes to narrow, task-relevant children.
|
|
75
|
+
Connecting switches the current project's tree. Run these commands from the
|
|
76
|
+
project directory, or add `--project-path /path/to/project`.
|
|
132
77
|
|
|
133
|
-
|
|
78
|
+
## Cleanup and scheduling
|
|
134
79
|
|
|
135
|
-
|
|
136
|
-
context-tree prepare-write --project-path ./service
|
|
137
|
-
# Edit only the returned worktreePath.
|
|
138
|
-
context-tree finish-write --project-path ./service \
|
|
139
|
-
--worktree-path /path/from/prepare \
|
|
140
|
-
--message "Record runtime constraint"
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
Preparation synchronizes first and creates a random isolated worktree at that
|
|
144
|
-
exact commit. Finishing validates the worktree, stages every pending change,
|
|
145
|
-
creates one unsigned commit using the host identity, and attempts one
|
|
146
|
-
fast-forward merge for local trees or one non-force push for GitHub trees.
|
|
80
|
+
Ask your agent to remove outdated clutter and consolidate duplicate context:
|
|
147
81
|
|
|
148
|
-
|
|
149
|
-
preserves the worktree. Prepare again, reread affected nodes and their current
|
|
150
|
-
placement, and adapt the intended semantic change once to any moved or
|
|
151
|
-
consolidated content; there is no automatic rebase, retry loop, or pull-request fallback.
|
|
82
|
+
> Run the context-tree-cleanup skill for this project and publish the changes.
|
|
152
83
|
|
|
153
|
-
|
|
154
|
-
`context-tree/write/<name>` branch in the tree. The next `prepare-write` reclaims
|
|
155
|
-
one of these only when it holds no commit your checkout lacks, has no pending
|
|
156
|
-
change, and has gone untouched for twenty-four hours, so a worktree you are still
|
|
157
|
-
editing and a `WRITE_OUTDATED` worktree awaiting its retry are both left alone.
|
|
158
|
-
Those keep their pending edits until you clear them with
|
|
159
|
-
`git worktree remove <path>` and `git branch -D <branch>` in the connected tree.
|
|
160
|
-
|
|
161
|
-
### Publish
|
|
84
|
+
For recurring cleanup, use the schedule-cleanup skill or run:
|
|
162
85
|
|
|
163
86
|
```bash
|
|
164
|
-
context-tree
|
|
165
|
-
|
|
87
|
+
context-tree cleanup schedule --agent codex --every 2h
|
|
88
|
+
context-tree cleanup status
|
|
89
|
+
context-tree cleanup logs
|
|
90
|
+
context-tree cleanup remove
|
|
166
91
|
```
|
|
167
92
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
### Cleanup and scheduling
|
|
175
|
-
|
|
176
|
-
`context-tree-cleanup` removes noise, consolidates duplicates, and improves
|
|
177
|
-
placement across shared content and all member directories, then publishes one
|
|
178
|
-
commit if anything changed. It preserves useful context and protected decisions.
|
|
179
|
-
|
|
180
|
-
> Run `$context-tree-cleanup` for the project at `<absolute-project-path>`.
|
|
181
|
-
> Clean the entire tree and publish the changes. If another writer advances it,
|
|
182
|
-
> defer until the next run.
|
|
183
|
-
|
|
184
|
-
Use `context-tree-schedule-cleanup` to create or update a persistent local
|
|
185
|
-
cleanup task:
|
|
186
|
-
|
|
187
|
-
```text
|
|
188
|
-
# Codex
|
|
189
|
-
$context-tree-schedule-cleanup every 2 hours
|
|
93
|
+
Choose `codex`, `claude`, or `pi`; the agent CLI must be installed and authenticated.
|
|
94
|
+
Schedules run locally on macOS or Linux while the machine is awake, and skip trees
|
|
95
|
+
unused for 24 hours. Use one designated cleaner per shared tree.
|
|
96
|
+
`context-tree cleanup run` runs the configured cleanup now; `cleanup logs --list`
|
|
97
|
+
lists previous runs.
|
|
190
98
|
|
|
191
|
-
|
|
192
|
-
/context-tree-schedule-cleanup every 2 hours
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
The CLI manages one schedule per tree on this machine:
|
|
99
|
+
## Useful commands
|
|
196
100
|
|
|
197
101
|
```bash
|
|
198
|
-
context-tree
|
|
199
|
-
context-tree
|
|
200
|
-
context-tree
|
|
201
|
-
context-tree
|
|
202
|
-
context-tree
|
|
102
|
+
context-tree resolve # show this project's tree
|
|
103
|
+
context-tree read --tree-path /path/to/tree # browse its root index
|
|
104
|
+
context-tree verify --tree-path /path/to/tree # check its structure
|
|
105
|
+
context-tree install # add skills for a new agent
|
|
106
|
+
context-tree install --project . # install skills for this project
|
|
107
|
+
context-tree uninstall # remove Context Tree skills
|
|
108
|
+
context-tree --help
|
|
203
109
|
```
|
|
204
110
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
`2h`, `1d`, up to `365d`). Repeating schedule updates the same tree's entry.
|
|
208
|
-
Remove an active schedule before changing it. Local identity is the resolved
|
|
209
|
-
path; GitHub identity is the repository, case-insensitively. Connection changes
|
|
210
|
-
require explicit removal and rescheduling.
|
|
211
|
-
|
|
212
|
-
macOS uses user LaunchAgents, each invoking an executable named
|
|
213
|
-
`context-tree-cleanup` at `~/.context-tree/cleanup/launchers/<schedule-id>/`.
|
|
214
|
-
This private launcher executes the configured Node cleanup command and is removed
|
|
215
|
-
with the schedule. Linux uses systemd user timers and services. The
|
|
216
|
-
machine must be awake and the user scheduler available. No desktop app, root
|
|
217
|
-
installation, daemon, or Linux lingering is needed. Cancel any previously created
|
|
218
|
-
Codex desktop task or Claude Desktop routine before replacing it: the CLI cannot
|
|
219
|
-
inspect or remove those tasks. Keep one designated cleaner per tree across machines.
|
|
220
|
-
|
|
221
|
-
Agents use existing CLI authentication. Defaults are `gpt-5.6-luna` with low
|
|
222
|
-
reasoning effort and `claude-haiku-4-5`; `--model` selects an explicit override.
|
|
223
|
-
Codex uses workspace-write sandboxing and Claude uses file-editing permissions
|
|
224
|
-
with a restricted tool list. Permission and authentication failures stop the run;
|
|
225
|
-
models are never silently substituted. See [Codex noninteractive mode](https://learn.chatgpt.com/docs/non-interactive-mode)
|
|
226
|
-
and the [Claude CLI reference](https://code.claude.com/docs/en/cli-reference).
|
|
227
|
-
|
|
228
|
-
Scheduling opens a 24-hour activity window. Successful ordinary `create`,
|
|
229
|
-
`connect`, `sync`, `read`, `prepare-write`, and `finish-write` use refreshes it.
|
|
230
|
-
Cleanup and status never do. Missing or older activity skips before network or
|
|
231
|
-
model work; successfully inspected unchanged commits skip the model. Each run
|
|
232
|
-
uses a fresh isolated worktree, one agent with a 15-minute timeout, shared
|
|
233
|
-
editorial instructions, verification, and at most one publication attempt.
|
|
234
|
-
|
|
235
|
-
Private atomic state in `~/.context-tree/cleanup` holds configuration, activity,
|
|
236
|
-
the last successful commit, and only the latest outcome. `status` reports native
|
|
237
|
-
registration/running state and whether inactivity prevents cleanup. `remove`
|
|
238
|
-
disables future runs and stops the native scheduled process and its children,
|
|
239
|
-
preserving unfinished worktrees. Publication already underway may have completed;
|
|
240
|
-
uncertain outcomes are reported without rollback or retries. Failures and
|
|
241
|
-
`WRITE_OUTDATED` never advance the successful-inspection checkpoint.
|
|
242
|
-
|
|
243
|
-
## Project identity
|
|
244
|
-
|
|
245
|
-
Git project paths resolve to the exact root of that checkout. A clone or Git
|
|
246
|
-
worktree is independent even if it shares an origin or Git common directory.
|
|
247
|
-
Non-Git projects match only the exact connected directory; nested directories
|
|
248
|
-
do not inherit the connection.
|
|
249
|
-
|
|
250
|
-
Connection data is written atomically with mode `0600` at
|
|
251
|
-
`~/.context-tree/connections.json`. Duplicate project records are corruption.
|
|
252
|
-
Stored local/GitHub state is not reclassified from mutable remotes.
|
|
253
|
-
|
|
254
|
-
Every command that touches a connected tree reports why it refused:
|
|
255
|
-
`NO_CONNECTION` (nothing connected), `DIRTY_TREE` (your uncommitted edits —
|
|
256
|
-
commit or discard them), `INVALID_TREE` (structure fails `verify`),
|
|
257
|
-
`STALE_CONNECTION` (the stored path is gone; connect again), and
|
|
258
|
-
`CORRUPT_CONNECTION` (unreadable or duplicated records).
|
|
259
|
-
|
|
260
|
-
## CLI plumbing
|
|
261
|
-
|
|
262
|
-
The public command inventory is:
|
|
263
|
-
|
|
264
|
-
```text
|
|
265
|
-
install uninstall create connect list resolve sync prepare-write
|
|
266
|
-
finish-write publish read verify
|
|
267
|
-
```
|
|
268
|
-
|
|
269
|
-
Setup, create, connect, read, write, publish, cleanup, and schedule-cleanup ship as
|
|
270
|
-
eight skills; setup
|
|
271
|
-
orchestrates the five concrete workflows. `install` is the distribution
|
|
272
|
-
entry point, run for you by `npm install`; `uninstall` is its supported reverse.
|
|
273
|
-
`resolve`, `sync`, `prepare-write`,
|
|
274
|
-
`finish-write`, and `verify` are plumbing or diagnostic commands rather than
|
|
275
|
-
separate user intentions; `list` backs setup's connect-target discovery.
|
|
276
|
-
|
|
277
|
-
### Output
|
|
278
|
-
|
|
279
|
-
`create`, `connect`, `list`, `resolve`, `publish`, `read`, and `verify` print
|
|
280
|
-
human-readable text by default and accept `--json` to emit their strict schema
|
|
281
|
-
version `1` payload for scripts and agents; in text mode a failure prints a
|
|
282
|
-
sanitized message to stderr with a non-zero exit code. The eight skills always
|
|
283
|
-
pass `--json`. `sync`, `prepare-write`, `finish-write`, `install`, and `uninstall` are
|
|
284
|
-
low-level plumbing and always emit that JSON (with the error envelope on stdout).
|
|
285
|
-
`--help` and `--version` are always plain text.
|
|
286
|
-
|
|
287
|
-
```bash
|
|
288
|
-
context-tree verify # human-readable report
|
|
289
|
-
context-tree verify --json # { "ok": true, "schemaVersion": 1, ... }
|
|
290
|
-
```
|
|
111
|
+
For scripts and custom integrations, see the [CLI specification](docs/specification.md),
|
|
112
|
+
including synchronization, prepared writes, and JSON output.
|
|
291
113
|
|
|
292
|
-
|
|
293
|
-
an operation reports invalid tree content.
|
|
114
|
+
## Working with OpenTag
|
|
294
115
|
|
|
295
|
-
|
|
116
|
+
Create or publish a tree as above, then choose it on each OpenTag Computer:
|
|
296
117
|
|
|
297
118
|
```bash
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
pnpm test
|
|
302
|
-
pnpm check:package
|
|
119
|
+
opentag context-tree connect OWNER/REPO
|
|
120
|
+
# Or use a local tree:
|
|
121
|
+
opentag context-tree connect my-project-context-tree
|
|
303
122
|
```
|
|
304
123
|
|
|
305
|
-
|
|
306
|
-
|
|
124
|
+
OpenTag connects each agent workspace to that tree when a session starts.
|
|
125
|
+
Use the same read, write, and cleanup prompts in those sessions. For a GitHub tree,
|
|
126
|
+
GitHub authentication must work on each Computer.
|
package/dist/cli/index.mjs
CHANGED
|
@@ -3,12 +3,13 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import path, { basename, delimiter, dirname, isAbsolute, join, parse, posix, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { EventEmitter } from "node:events";
|
|
5
5
|
import childProcess, { spawn, spawnSync } from "node:child_process";
|
|
6
|
-
import fs, { accessSync, chmodSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import fs, { accessSync, chmodSync, closeSync, constants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
7
7
|
import process$1 from "node:process";
|
|
8
8
|
import { stripVTControlCharacters } from "node:util";
|
|
9
9
|
import { createHash, randomUUID } from "node:crypto";
|
|
10
10
|
import { homedir, tmpdir } from "node:os";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { StringDecoder } from "node:string_decoder";
|
|
12
13
|
//#region \0rolldown/runtime.js
|
|
13
14
|
var __defProp = Object.defineProperty;
|
|
14
15
|
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
|
|
@@ -7352,7 +7353,11 @@ const contextContentClassCountsSchema = object({
|
|
|
7352
7353
|
member: number().int().nonnegative(),
|
|
7353
7354
|
"repo-infra": number().int().nonnegative()
|
|
7354
7355
|
}).strict();
|
|
7355
|
-
const SKILL_HOSTS = [
|
|
7356
|
+
const SKILL_HOSTS = [
|
|
7357
|
+
"claude",
|
|
7358
|
+
"codex",
|
|
7359
|
+
"pi"
|
|
7360
|
+
];
|
|
7356
7361
|
const skillHostSchema = _enum(SKILL_HOSTS);
|
|
7357
7362
|
const skillInstallationSchema = object({
|
|
7358
7363
|
host: skillHostSchema,
|
|
@@ -7469,13 +7474,17 @@ object({
|
|
|
7469
7474
|
ok: literal(false),
|
|
7470
7475
|
schemaVersion: literal(1)
|
|
7471
7476
|
}).strict();
|
|
7472
|
-
const cleanupAgentSchema = union([
|
|
7477
|
+
const cleanupAgentSchema = union([
|
|
7478
|
+
literal("codex"),
|
|
7479
|
+
literal("claude"),
|
|
7480
|
+
literal("pi")
|
|
7481
|
+
]);
|
|
7473
7482
|
const cleanupScheduleSchema = object({
|
|
7474
7483
|
id: string$2().regex(/^[a-f0-9]{64}$/u),
|
|
7475
7484
|
projectPath: string$2().refine(isAbsolute),
|
|
7476
7485
|
identity: string$2(),
|
|
7477
7486
|
agent: cleanupAgentSchema,
|
|
7478
|
-
model: string$2().min(1),
|
|
7487
|
+
model: string$2().min(1).optional(),
|
|
7479
7488
|
everyMinutes: number().int().positive().max(525600),
|
|
7480
7489
|
nodePath: string$2().refine(isAbsolute),
|
|
7481
7490
|
cliPath: string$2().refine(isAbsolute),
|
|
@@ -7485,6 +7494,7 @@ const cleanupScheduleSchema = object({
|
|
|
7485
7494
|
}).strict();
|
|
7486
7495
|
const cleanupOutcomeSchema = object({
|
|
7487
7496
|
at: number(),
|
|
7497
|
+
runId: string$2().uuid().optional(),
|
|
7488
7498
|
outcome: union([
|
|
7489
7499
|
literal("inactive"),
|
|
7490
7500
|
literal("unchanged"),
|
|
@@ -7509,6 +7519,29 @@ const cleanupResultSchema = object({
|
|
|
7509
7519
|
latest: cleanupOutcomeSchema.nullable()
|
|
7510
7520
|
}).strict();
|
|
7511
7521
|
const cleanupRunResultSchema = cleanupOutcomeSchema.extend({ schemaVersion: literal(1) }).strict();
|
|
7522
|
+
const cleanupLogEventSchema = object({
|
|
7523
|
+
at: number().finite(),
|
|
7524
|
+
source: union([
|
|
7525
|
+
literal("runner"),
|
|
7526
|
+
literal("stdout"),
|
|
7527
|
+
literal("stderr")
|
|
7528
|
+
]),
|
|
7529
|
+
text: string$2()
|
|
7530
|
+
}).strict();
|
|
7531
|
+
const cleanupRunMetadataSchema = object({
|
|
7532
|
+
runId: string$2().uuid(),
|
|
7533
|
+
startedAt: number().finite(),
|
|
7534
|
+
agent: cleanupAgentSchema,
|
|
7535
|
+
model: string$2().optional(),
|
|
7536
|
+
truncated: boolean(),
|
|
7537
|
+
terminal: cleanupOutcomeSchema.optional()
|
|
7538
|
+
}).strict();
|
|
7539
|
+
const cleanupLogsResultSchema = object({
|
|
7540
|
+
schemaVersion: literal(1),
|
|
7541
|
+
runs: array(cleanupRunMetadataSchema),
|
|
7542
|
+
selectedRunId: string$2().uuid().nullable(),
|
|
7543
|
+
events: array(cleanupLogEventSchema)
|
|
7544
|
+
}).strict();
|
|
7512
7545
|
//#endregion
|
|
7513
7546
|
//#region src/core/internal/errors.ts
|
|
7514
7547
|
/**
|
|
@@ -7525,18 +7558,27 @@ var ContextTreeError = class extends Error {
|
|
|
7525
7558
|
};
|
|
7526
7559
|
//#endregion
|
|
7527
7560
|
//#region src/core/internal/git.ts
|
|
7528
|
-
function defaultRunner(command, args) {
|
|
7561
|
+
function defaultRunner(command, args, timeoutMs = 12e4) {
|
|
7529
7562
|
const result = spawnSync(command, args, {
|
|
7530
7563
|
encoding: "utf8",
|
|
7531
7564
|
stdio: [
|
|
7532
7565
|
"ignore",
|
|
7533
7566
|
"pipe",
|
|
7534
7567
|
"pipe"
|
|
7535
|
-
]
|
|
7568
|
+
],
|
|
7569
|
+
timeout: timeoutMs,
|
|
7570
|
+
killSignal: "SIGKILL",
|
|
7571
|
+
detached: process.platform !== "win32"
|
|
7536
7572
|
});
|
|
7573
|
+
const timedOut = result.error !== void 0 && "code" in result.error && result.error.code === "ETIMEDOUT";
|
|
7574
|
+
if (timedOut && process.platform !== "win32" && result.pid) try {
|
|
7575
|
+
process.kill(-result.pid, "SIGKILL");
|
|
7576
|
+
} catch (error) {
|
|
7577
|
+
if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) throw error;
|
|
7578
|
+
}
|
|
7537
7579
|
return {
|
|
7538
7580
|
status: result.status,
|
|
7539
|
-
stderr: typeof result.stderr === "string" ? result.stderr : "",
|
|
7581
|
+
stderr: timedOut ? `Command timed out after ${timeoutMs} ms.` : result.error ? "Unable to start command." : typeof result.stderr === "string" ? result.stderr : "",
|
|
7540
7582
|
stdout: typeof result.stdout === "string" ? result.stdout : ""
|
|
7541
7583
|
};
|
|
7542
7584
|
}
|
|
@@ -26008,23 +26050,36 @@ function reclaimAbandonedWrites(root, checkoutBranch, runner) {
|
|
|
26008
26050
|
}
|
|
26009
26051
|
//#endregion
|
|
26010
26052
|
//#region src/core/cleanup/agent.ts
|
|
26053
|
+
/** Pi built-in tools mirroring the Claude editing allowlist; Bash stays disabled. */
|
|
26054
|
+
const PI_CLEANUP_TOOLS = "read,edit,write,grep,find,ls";
|
|
26011
26055
|
function agentArguments(config) {
|
|
26012
|
-
|
|
26013
|
-
|
|
26014
|
-
|
|
26015
|
-
|
|
26016
|
-
|
|
26017
|
-
|
|
26018
|
-
|
|
26019
|
-
|
|
26020
|
-
|
|
26021
|
-
|
|
26022
|
-
"--
|
|
26023
|
-
"-"
|
|
26024
|
-
|
|
26056
|
+
if (config.agent === "codex") {
|
|
26057
|
+
const args = [
|
|
26058
|
+
"exec",
|
|
26059
|
+
"--sandbox",
|
|
26060
|
+
"workspace-write",
|
|
26061
|
+
"-c",
|
|
26062
|
+
"approval_policy=\"never\"",
|
|
26063
|
+
"-c",
|
|
26064
|
+
"model_reasoning_effort=\"low\""
|
|
26065
|
+
];
|
|
26066
|
+
if (config.model !== void 0) args.push("--model", config.model);
|
|
26067
|
+
args.push("--ephemeral", "-");
|
|
26068
|
+
return args;
|
|
26069
|
+
}
|
|
26070
|
+
if (config.agent === "pi") {
|
|
26071
|
+
const args = [
|
|
26072
|
+
"-p",
|
|
26073
|
+
"--tools",
|
|
26074
|
+
PI_CLEANUP_TOOLS,
|
|
26075
|
+
"--no-session",
|
|
26076
|
+
"--no-extensions"
|
|
26077
|
+
];
|
|
26078
|
+
if (config.model !== void 0) args.push("--model", config.model);
|
|
26079
|
+
return args;
|
|
26080
|
+
}
|
|
26081
|
+
const args = [
|
|
26025
26082
|
"-p",
|
|
26026
|
-
"--model",
|
|
26027
|
-
config.model,
|
|
26028
26083
|
"--permission-mode",
|
|
26029
26084
|
"acceptEdits",
|
|
26030
26085
|
"--tools",
|
|
@@ -26033,8 +26088,10 @@ function agentArguments(config) {
|
|
|
26033
26088
|
"Read,Edit,Write,Glob,Grep",
|
|
26034
26089
|
"--no-session-persistence"
|
|
26035
26090
|
];
|
|
26091
|
+
if (config.model !== void 0) args.push("--model", config.model);
|
|
26092
|
+
return args;
|
|
26036
26093
|
}
|
|
26037
|
-
async function runAgent(config, worktree, prompt, signal, timeoutMs = 900 * 1e3) {
|
|
26094
|
+
async function runAgent(config, worktree, prompt, signal, timeoutMs = 900 * 1e3, output) {
|
|
26038
26095
|
if (signal.aborted) throw new Error("Cleanup cancelled.");
|
|
26039
26096
|
await new Promise((resolve, reject) => {
|
|
26040
26097
|
const child = spawn(config.agentPath, agentArguments(config), {
|
|
@@ -26046,8 +26103,8 @@ async function runAgent(config, worktree, prompt, signal, timeoutMs = 900 * 1e3)
|
|
|
26046
26103
|
},
|
|
26047
26104
|
stdio: [
|
|
26048
26105
|
"pipe",
|
|
26049
|
-
"
|
|
26050
|
-
"
|
|
26106
|
+
"pipe",
|
|
26107
|
+
"pipe"
|
|
26051
26108
|
]
|
|
26052
26109
|
});
|
|
26053
26110
|
let failure;
|
|
@@ -26067,6 +26124,39 @@ async function runAgent(config, worktree, prompt, signal, timeoutMs = 900 * 1e3)
|
|
|
26067
26124
|
for (const pid of descendants.reverse()) kill(pid, "SIGTERM");
|
|
26068
26125
|
child.kill("SIGTERM");
|
|
26069
26126
|
};
|
|
26127
|
+
const promptLines = new Set(prompt.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean));
|
|
26128
|
+
const flushers = [];
|
|
26129
|
+
for (const source of ["stdout", "stderr"]) {
|
|
26130
|
+
const decoder = new StringDecoder("utf8");
|
|
26131
|
+
let pending = "";
|
|
26132
|
+
let oversized = false;
|
|
26133
|
+
const emit = (text) => {
|
|
26134
|
+
try {
|
|
26135
|
+
output?.(source, promptLines.has(text.trim()) ? "[Prompt line suppressed]" : text);
|
|
26136
|
+
} catch {
|
|
26137
|
+
stop("Unable to store cleanup logs; worktree preserved.");
|
|
26138
|
+
}
|
|
26139
|
+
};
|
|
26140
|
+
const consume = (text) => {
|
|
26141
|
+
for (const part of text.split(/(?<=\n)/u)) {
|
|
26142
|
+
if (!oversized) pending += part;
|
|
26143
|
+
if (pending.length > 16384) {
|
|
26144
|
+
pending = "";
|
|
26145
|
+
oversized = true;
|
|
26146
|
+
}
|
|
26147
|
+
if (part.endsWith("\n")) {
|
|
26148
|
+
emit(oversized ? "[Oversized output line suppressed]" : pending.slice(0, -1));
|
|
26149
|
+
pending = "";
|
|
26150
|
+
oversized = false;
|
|
26151
|
+
}
|
|
26152
|
+
}
|
|
26153
|
+
};
|
|
26154
|
+
child[source].on("data", (chunk) => consume(decoder.write(chunk)));
|
|
26155
|
+
flushers.push(() => {
|
|
26156
|
+
consume(decoder.end());
|
|
26157
|
+
if (oversized || pending) emit(oversized ? "[Oversized output line suppressed]" : pending);
|
|
26158
|
+
});
|
|
26159
|
+
}
|
|
26070
26160
|
const abort = () => stop("Cleanup cancelled.");
|
|
26071
26161
|
const timer = setTimeout(() => stop("Cleanup agent exceeded its 15-minute timeout."), timeoutMs);
|
|
26072
26162
|
signal.addEventListener("abort", abort, { once: true });
|
|
@@ -26076,6 +26166,7 @@ async function runAgent(config, worktree, prompt, signal, timeoutMs = 900 * 1e3)
|
|
|
26076
26166
|
failure = "Unable to launch cleanup agent.";
|
|
26077
26167
|
});
|
|
26078
26168
|
child.on("close", async (code) => {
|
|
26169
|
+
for (const flush of flushers) flush();
|
|
26079
26170
|
clearTimeout(timer);
|
|
26080
26171
|
signal.removeEventListener("abort", abort);
|
|
26081
26172
|
await termination;
|
|
@@ -26161,6 +26252,128 @@ function activity(id) {
|
|
|
26161
26252
|
const parsed = number().finite().safeParse(readState(statePath(id, "activity")));
|
|
26162
26253
|
return parsed.success ? parsed.data : null;
|
|
26163
26254
|
}
|
|
26255
|
+
function directory(treeId) {
|
|
26256
|
+
if (!/^[a-f0-9]{64}$/u.test(treeId)) throw new Error("Invalid cleanup tree ID.");
|
|
26257
|
+
return privateDirectory(join(cleanupRoot(), "logs", treeId));
|
|
26258
|
+
}
|
|
26259
|
+
function runDirectory(root, runId) {
|
|
26260
|
+
string$2().uuid().parse(runId);
|
|
26261
|
+
const path = join(root, runId);
|
|
26262
|
+
if (!lstatSync(path, { throwIfNoEntry: false })) throw new Error("Unknown cleanup run ID.");
|
|
26263
|
+
return privateDirectory(path);
|
|
26264
|
+
}
|
|
26265
|
+
function summaries(root) {
|
|
26266
|
+
return readdirSync(root).flatMap((id) => {
|
|
26267
|
+
const state = readState(join(runDirectory(root, id), "metadata"));
|
|
26268
|
+
if (state === void 0) return [];
|
|
26269
|
+
const metadata = cleanupRunMetadataSchema.parse(state);
|
|
26270
|
+
if (metadata.runId !== id) throw new Error("Cleanup log identity is corrupt.");
|
|
26271
|
+
return metadata;
|
|
26272
|
+
}).sort((a, b) => b.startedAt - a.startedAt || b.runId.localeCompare(a.runId));
|
|
26273
|
+
}
|
|
26274
|
+
function readEvents(path) {
|
|
26275
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
26276
|
+
try {
|
|
26277
|
+
const stat = fstatSync(fd);
|
|
26278
|
+
if (!stat.isFile() || stat.size > 5242880) throw new Error("Unsafe cleanup events file.");
|
|
26279
|
+
const lines = readFileSync(fd, "utf8").split("\n");
|
|
26280
|
+
lines.pop();
|
|
26281
|
+
return lines.map((line) => cleanupLogEventSchema.parse(JSON.parse(line)));
|
|
26282
|
+
} finally {
|
|
26283
|
+
closeSync(fd);
|
|
26284
|
+
}
|
|
26285
|
+
}
|
|
26286
|
+
function readCleanupHistory(treeId, options = {}) {
|
|
26287
|
+
if (options.list && options.run !== void 0) throw new Error("--list and --run are mutually exclusive.");
|
|
26288
|
+
if (options.run !== void 0) string$2().uuid().parse(options.run);
|
|
26289
|
+
const root = directory(treeId);
|
|
26290
|
+
const runs = summaries(root);
|
|
26291
|
+
const selected = options.list ? void 0 : options.run ?? runs[0]?.runId;
|
|
26292
|
+
if (options.run && !runs.some((run) => run.runId === options.run)) throw new Error("Unknown cleanup run ID.");
|
|
26293
|
+
return cleanupLogsResultSchema.parse({
|
|
26294
|
+
schemaVersion: 1,
|
|
26295
|
+
runs,
|
|
26296
|
+
selectedRunId: selected ?? null,
|
|
26297
|
+
events: selected ? readEvents(join(runDirectory(root, selected), "events.jsonl")) : []
|
|
26298
|
+
});
|
|
26299
|
+
}
|
|
26300
|
+
function sanitizeLogText(text) {
|
|
26301
|
+
return sanitizeCommandOutput(stripVTControlCharacters(text).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, "")).replace(/\b(?:https?|ssh):\/\/[^\s]+/giu, "<redacted URL>").replace(/\b[A-Z_][A-Z0-9_]*=[^\s]+/gu, "<redacted assignment>");
|
|
26302
|
+
}
|
|
26303
|
+
var CleanupHistory = class {
|
|
26304
|
+
runId;
|
|
26305
|
+
path;
|
|
26306
|
+
metadata;
|
|
26307
|
+
bytes = 0;
|
|
26308
|
+
failed = false;
|
|
26309
|
+
constructor(config) {
|
|
26310
|
+
const root = directory(config.id);
|
|
26311
|
+
const previous = summaries(root);
|
|
26312
|
+
let count = previous.length;
|
|
26313
|
+
for (const run of [...previous].reverse()) {
|
|
26314
|
+
if (count < 50) break;
|
|
26315
|
+
if (!run.terminal) continue;
|
|
26316
|
+
rmSync(runDirectory(root, run.runId), { recursive: true });
|
|
26317
|
+
count--;
|
|
26318
|
+
}
|
|
26319
|
+
this.runId = randomUUID();
|
|
26320
|
+
this.path = privateDirectory(join(root, this.runId));
|
|
26321
|
+
this.metadata = {
|
|
26322
|
+
runId: this.runId,
|
|
26323
|
+
startedAt: Date.now(),
|
|
26324
|
+
agent: config.agent,
|
|
26325
|
+
...config.model === void 0 ? {} : { model: sanitizeLogText(config.model) },
|
|
26326
|
+
truncated: false
|
|
26327
|
+
};
|
|
26328
|
+
closeSync(openSync(join(this.path, "events.jsonl"), constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 384));
|
|
26329
|
+
atomicState(join(this.path, "metadata"), this.metadata);
|
|
26330
|
+
}
|
|
26331
|
+
event(source, text) {
|
|
26332
|
+
this.assertHealthy();
|
|
26333
|
+
try {
|
|
26334
|
+
if (this.metadata.truncated) return;
|
|
26335
|
+
const line = `${JSON.stringify(cleanupLogEventSchema.parse({
|
|
26336
|
+
at: Date.now(),
|
|
26337
|
+
source,
|
|
26338
|
+
text: sanitizeLogText(text)
|
|
26339
|
+
}))}\n`;
|
|
26340
|
+
const size = Buffer.byteLength(line);
|
|
26341
|
+
if (this.bytes + size > 5242880) {
|
|
26342
|
+
this.metadata.truncated = true;
|
|
26343
|
+
this.save();
|
|
26344
|
+
return;
|
|
26345
|
+
}
|
|
26346
|
+
privateDirectory(this.path);
|
|
26347
|
+
const fd = openSync(join(this.path, "events.jsonl"), constants.O_APPEND | constants.O_WRONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
26348
|
+
try {
|
|
26349
|
+
if (!fstatSync(fd).isFile()) throw new Error("Unsafe cleanup events file.");
|
|
26350
|
+
const buffer = Buffer.from(line);
|
|
26351
|
+
let written = 0;
|
|
26352
|
+
while (written < buffer.length) written += writeSync(fd, buffer, written, buffer.length - written);
|
|
26353
|
+
} finally {
|
|
26354
|
+
closeSync(fd);
|
|
26355
|
+
}
|
|
26356
|
+
this.bytes += size;
|
|
26357
|
+
} catch {
|
|
26358
|
+
this.failed = true;
|
|
26359
|
+
throw new Error("Unable to store cleanup logs; worktree preserved.");
|
|
26360
|
+
}
|
|
26361
|
+
}
|
|
26362
|
+
assertHealthy() {
|
|
26363
|
+
if (this.failed) throw new Error("Unable to store cleanup logs; worktree preserved.");
|
|
26364
|
+
}
|
|
26365
|
+
finish(outcome) {
|
|
26366
|
+
this.metadata.terminal = {
|
|
26367
|
+
...outcome,
|
|
26368
|
+
...outcome.message === void 0 ? {} : { message: sanitizeLogText(outcome.message) }
|
|
26369
|
+
};
|
|
26370
|
+
this.save();
|
|
26371
|
+
}
|
|
26372
|
+
save() {
|
|
26373
|
+
privateDirectory(this.path);
|
|
26374
|
+
atomicState(join(this.path, "metadata"), this.metadata);
|
|
26375
|
+
}
|
|
26376
|
+
};
|
|
26164
26377
|
//#endregion
|
|
26165
26378
|
//#region src/core/cleanup/scheduler.ts
|
|
26166
26379
|
function xml(value) {
|
|
@@ -26248,7 +26461,11 @@ function nativeScheduler(platform = process.platform, home = homedir(), runner =
|
|
|
26248
26461
|
remove(config) {
|
|
26249
26462
|
const name = label(config);
|
|
26250
26463
|
if (platform === "darwin") {
|
|
26251
|
-
|
|
26464
|
+
try {
|
|
26465
|
+
command(["bootout", `${domain}/${name}`], true);
|
|
26466
|
+
} catch (error) {
|
|
26467
|
+
if (status(config).registered) throw error;
|
|
26468
|
+
}
|
|
26252
26469
|
const launcherDir = launcherDirectory(config);
|
|
26253
26470
|
const launcher = join(launcherDir, "context-tree-cleanup");
|
|
26254
26471
|
const entry = lstatSync(launcher, { throwIfNoEntry: false });
|
|
@@ -26271,6 +26488,11 @@ function nativeScheduler(platform = process.platform, home = homedir(), runner =
|
|
|
26271
26488
|
}
|
|
26272
26489
|
//#endregion
|
|
26273
26490
|
//#region src/core/cleanup/index.ts
|
|
26491
|
+
/** Low-cost editorial default for agents that need an explicit model; `--model` overrides it. */
|
|
26492
|
+
const DEFAULT_CLEANUP_MODEL = {
|
|
26493
|
+
claude: "claude-haiku-4-5",
|
|
26494
|
+
codex: "gpt-5.6-luna"
|
|
26495
|
+
};
|
|
26274
26496
|
function parseCleanupInterval(value = "1h") {
|
|
26275
26497
|
const match = /^(\d+)(m|h|d)$/u.exec(value);
|
|
26276
26498
|
const minutes = Number(match?.[1]) * (match?.[2] === "d" ? 1440 : match?.[2] === "h" ? 60 : 1);
|
|
@@ -26296,6 +26518,12 @@ function findSchedule(project) {
|
|
|
26296
26518
|
const connection = findConnectionRecord(canonical);
|
|
26297
26519
|
return connection ? schedules().find((config) => config.id === identityId(treeIdentity(connection.tree))) : void 0;
|
|
26298
26520
|
}
|
|
26521
|
+
function cleanupLogs(project, options = {}) {
|
|
26522
|
+
const connection = findConnectionRecord(canonicalProjectRoot(project));
|
|
26523
|
+
const id = connection ? identityId(treeIdentity(connection.tree)) : findSchedule(project)?.id;
|
|
26524
|
+
if (!id) throw new Error("No Context Tree connection or cleanup schedule exists for this project.");
|
|
26525
|
+
return readCleanupHistory(id, options);
|
|
26526
|
+
}
|
|
26299
26527
|
function cleanupStatus(project, scheduler = nativeScheduler()) {
|
|
26300
26528
|
const config = findSchedule(project);
|
|
26301
26529
|
if (!config) return {
|
|
@@ -26349,7 +26577,7 @@ function scheduleCleanupUnlocked(options, scheduler = nativeScheduler()) {
|
|
|
26349
26577
|
projectPath: connection.projectPath,
|
|
26350
26578
|
identity,
|
|
26351
26579
|
agent,
|
|
26352
|
-
model: options.model ??
|
|
26580
|
+
model: options.model ?? DEFAULT_CLEANUP_MODEL[agent],
|
|
26353
26581
|
everyMinutes: parseCleanupInterval(options.every),
|
|
26354
26582
|
nodePath: realpathSync(process.execPath),
|
|
26355
26583
|
cliPath: resolvePackagedResource("dist", "cli", "index.mjs"),
|
|
@@ -26466,14 +26694,30 @@ async function runCleanup(project, savedId, dependencies = {}) {
|
|
|
26466
26694
|
process.on("SIGINT", cancel);
|
|
26467
26695
|
let worktreePath;
|
|
26468
26696
|
let publishing = false;
|
|
26697
|
+
let history;
|
|
26469
26698
|
const record = (outcome, extra = {}) => {
|
|
26470
26699
|
const value = cleanupOutcomeSchema.parse({
|
|
26471
26700
|
at: Date.now(),
|
|
26701
|
+
runId: history?.runId,
|
|
26472
26702
|
outcome,
|
|
26473
26703
|
...worktreePath ? { worktreePath } : {},
|
|
26474
26704
|
...extra
|
|
26475
26705
|
});
|
|
26476
26706
|
atomicState(statePath(config.id, "latest"), value);
|
|
26707
|
+
if (outcome !== "running") try {
|
|
26708
|
+
try {
|
|
26709
|
+
history?.event("runner", `${outcome}${value.message ? `: ${value.message}` : ""}`);
|
|
26710
|
+
} finally {
|
|
26711
|
+
history?.finish(value);
|
|
26712
|
+
}
|
|
26713
|
+
} catch {
|
|
26714
|
+
if (![
|
|
26715
|
+
"failed",
|
|
26716
|
+
"cancelled",
|
|
26717
|
+
"publication-uncertain"
|
|
26718
|
+
].includes(outcome)) throw new Error("Unable to store cleanup logs; worktree preserved.");
|
|
26719
|
+
}
|
|
26720
|
+
else history?.event("runner", value.message ?? "Agent execution started.");
|
|
26477
26721
|
return value;
|
|
26478
26722
|
};
|
|
26479
26723
|
const check = () => {
|
|
@@ -26487,13 +26731,17 @@ async function runCleanup(project, savedId, dependencies = {}) {
|
|
|
26487
26731
|
return tree;
|
|
26488
26732
|
};
|
|
26489
26733
|
try {
|
|
26734
|
+
history = new CleanupHistory(config);
|
|
26735
|
+
history.event("runner", "Preparing cleanup.");
|
|
26490
26736
|
check();
|
|
26491
26737
|
const lastActivity = activity(config.id);
|
|
26492
26738
|
if (lastActivity === null || Date.now() - lastActivity > 864e5) return record("inactive");
|
|
26493
26739
|
identityCheck();
|
|
26740
|
+
record("running", { message: "Synchronizing Context Tree." });
|
|
26494
26741
|
const synced = (dependencies.sync ?? syncProject)(config.projectPath);
|
|
26495
26742
|
check();
|
|
26496
26743
|
if (readState(statePath(config.id, "success")) === synced.sha) return record("unchanged", { sha: synced.sha });
|
|
26744
|
+
record("running", { message: "Preparing cleanup worktree." });
|
|
26497
26745
|
worktreePath = (dependencies.prepare ?? prepareContextWrite)(config.projectPath).worktreePath;
|
|
26498
26746
|
check();
|
|
26499
26747
|
const head = git(worktreePath, ["rev-parse", "HEAD"]);
|
|
@@ -26508,10 +26756,12 @@ async function runCleanup(project, savedId, dependencies = {}) {
|
|
|
26508
26756
|
}
|
|
26509
26757
|
}, 250);
|
|
26510
26758
|
try {
|
|
26511
|
-
await (dependencies.agent ?? runAgent)(config, worktreePath, `${editorial}\n\nYou are the editorial worker in an already prepared isolated worktree. Read all normal and member Markdown content directly using file tools. Edit and check references only. Do not invoke Context Tree lifecycle commands, stage, commit, change Git configuration, or publish. Do not follow other skills that request those operations. Report unresolved issues outside tree files.`, controller.signal);
|
|
26759
|
+
await (dependencies.agent ?? runAgent)(config, worktreePath, `${editorial}\n\nYou are the editorial worker in an already prepared isolated worktree. Read all normal and member Markdown content directly using file tools. Edit and check references only. Do not invoke Context Tree lifecycle commands, stage, commit, change Git configuration, or publish. Do not follow other skills that request those operations. Report unresolved issues outside tree files.`, controller.signal, void 0, (source, text) => history?.event(source, text));
|
|
26512
26760
|
} finally {
|
|
26513
26761
|
clearInterval(monitor);
|
|
26514
26762
|
}
|
|
26763
|
+
history.assertHealthy();
|
|
26764
|
+
history.event("runner", "Verifying cleanup edits.");
|
|
26515
26765
|
check();
|
|
26516
26766
|
if (git(worktreePath, ["rev-parse", "HEAD"]) !== head) throw new Error("Cleanup agent committed changes.");
|
|
26517
26767
|
const changed = inspectEdits(worktreePath, before);
|
|
@@ -26529,7 +26779,6 @@ async function runCleanup(project, savedId, dependencies = {}) {
|
|
|
26529
26779
|
worktreePath,
|
|
26530
26780
|
message: "Clean up Context Tree content"
|
|
26531
26781
|
});
|
|
26532
|
-
publishing = false;
|
|
26533
26782
|
atomicState(statePath(config.id, "success"), finished.sha);
|
|
26534
26783
|
return record("published", { sha: finished.sha });
|
|
26535
26784
|
} catch (error) {
|
|
@@ -26718,12 +26967,22 @@ function createProject(projectPath, runner) {
|
|
|
26718
26967
|
}
|
|
26719
26968
|
//#endregion
|
|
26720
26969
|
//#region src/core/install.ts
|
|
26721
|
-
/**
|
|
26722
|
-
const
|
|
26723
|
-
claude:
|
|
26724
|
-
|
|
26970
|
+
/** Home configuration paths detect hosts independently of their skills destinations. */
|
|
26971
|
+
const HOST_DIRECTORIES = {
|
|
26972
|
+
claude: {
|
|
26973
|
+
config: [".claude"],
|
|
26974
|
+
skills: ".claude"
|
|
26975
|
+
},
|
|
26976
|
+
codex: {
|
|
26977
|
+
config: [".codex"],
|
|
26978
|
+
skills: ".agents"
|
|
26979
|
+
},
|
|
26980
|
+
pi: {
|
|
26981
|
+
config: [".pi", "agent"],
|
|
26982
|
+
skills: ".agents"
|
|
26983
|
+
}
|
|
26725
26984
|
};
|
|
26726
|
-
/**
|
|
26985
|
+
/** Subdirectory below each skills destination. */
|
|
26727
26986
|
const SKILLS_DIRECTORY = "skills";
|
|
26728
26987
|
/** Only directories carrying this prefix are ever replaced or removed. */
|
|
26729
26988
|
const OWNED_SKILL_PREFIX = "context-tree-";
|
|
@@ -26770,18 +27029,21 @@ function packagedSkillNames(skillsRoot) {
|
|
|
26770
27029
|
}
|
|
26771
27030
|
/** Resolve one host's destination, or the reason it was skipped. */
|
|
26772
27031
|
function hostDestination(host, root, isProjectInstall) {
|
|
26773
|
-
const
|
|
27032
|
+
const directories = HOST_DIRECTORIES[host];
|
|
26774
27033
|
if (!isProjectInstall) {
|
|
26775
|
-
|
|
26776
|
-
const
|
|
26777
|
-
|
|
26778
|
-
|
|
27034
|
+
let hostRoot = root;
|
|
27035
|
+
for (const segment of directories.config) {
|
|
27036
|
+
hostRoot = join(hostRoot, segment);
|
|
27037
|
+
const entry = lstatSync(hostRoot, { throwIfNoEntry: false });
|
|
27038
|
+
if (entry === void 0) return { reason: `${hostRoot} does not exist; install ${host} first, then run context-tree install.` };
|
|
27039
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) return { reason: `${hostRoot} is not a real directory.` };
|
|
27040
|
+
}
|
|
26779
27041
|
}
|
|
26780
|
-
return { destination: ensureRealDirectory(root, [
|
|
27042
|
+
return { destination: ensureRealDirectory(root, [directories.skills, SKILLS_DIRECTORY]) };
|
|
26781
27043
|
}
|
|
26782
27044
|
/** Resolve one host's existing skills root without creating or following anything. */
|
|
26783
27045
|
function hostSkillsRoot(host, root) {
|
|
26784
|
-
const hostRoot = join(root,
|
|
27046
|
+
const hostRoot = join(root, HOST_DIRECTORIES[host].skills);
|
|
26785
27047
|
const hostEntry = lstatSync(hostRoot, { throwIfNoEntry: false });
|
|
26786
27048
|
if (hostEntry === void 0) return { reason: `${hostRoot} does not exist; nothing to remove.` };
|
|
26787
27049
|
if (hostEntry.isSymbolicLink() || !hostEntry.isDirectory()) return { reason: `${hostRoot} is not a real directory.` };
|
|
@@ -26807,6 +27069,7 @@ function installSkills(options = {}) {
|
|
|
26807
27069
|
const root = projectRoot ?? realHome();
|
|
26808
27070
|
const installed = [];
|
|
26809
27071
|
const skipped = [];
|
|
27072
|
+
const written = /* @__PURE__ */ new Set();
|
|
26810
27073
|
for (const host of hosts) {
|
|
26811
27074
|
const resolved = hostDestination(host, root, projectRoot !== void 0);
|
|
26812
27075
|
if ("reason" in resolved) {
|
|
@@ -26816,13 +27079,16 @@ function installSkills(options = {}) {
|
|
|
26816
27079
|
});
|
|
26817
27080
|
continue;
|
|
26818
27081
|
}
|
|
26819
|
-
|
|
26820
|
-
const
|
|
26821
|
-
|
|
26822
|
-
|
|
26823
|
-
|
|
26824
|
-
|
|
26825
|
-
|
|
27082
|
+
if (!written.has(resolved.destination)) {
|
|
27083
|
+
for (const skill of skills) {
|
|
27084
|
+
const target = join(resolved.destination, skill);
|
|
27085
|
+
if (lstatSync(target, { throwIfNoEntry: false }) !== void 0) rmSync(target, {
|
|
27086
|
+
force: true,
|
|
27087
|
+
recursive: true
|
|
27088
|
+
});
|
|
27089
|
+
copyRealTree(join(skillsRoot, skill), target);
|
|
27090
|
+
}
|
|
27091
|
+
written.add(resolved.destination);
|
|
26826
27092
|
}
|
|
26827
27093
|
installed.push({
|
|
26828
27094
|
host,
|
|
@@ -26843,6 +27109,7 @@ function uninstallSkills(options = {}) {
|
|
|
26843
27109
|
const root = options.projectPath === void 0 ? realHome() : resolve(options.projectPath);
|
|
26844
27110
|
const removed = [];
|
|
26845
27111
|
const skipped = [];
|
|
27112
|
+
const removedByDestination = /* @__PURE__ */ new Map();
|
|
26846
27113
|
for (const host of hosts) {
|
|
26847
27114
|
const resolved = hostSkillsRoot(host, root);
|
|
26848
27115
|
if ("reason" in resolved) {
|
|
@@ -26852,29 +27119,34 @@ function uninstallSkills(options = {}) {
|
|
|
26852
27119
|
});
|
|
26853
27120
|
continue;
|
|
26854
27121
|
}
|
|
26855
|
-
|
|
26856
|
-
|
|
26857
|
-
|
|
26858
|
-
const
|
|
26859
|
-
|
|
26860
|
-
|
|
26861
|
-
|
|
26862
|
-
|
|
26863
|
-
|
|
26864
|
-
|
|
27122
|
+
let skills = removedByDestination.get(resolved.destination);
|
|
27123
|
+
if (skills === void 0) {
|
|
27124
|
+
skills = [];
|
|
27125
|
+
for (const entry of readdirSync(resolved.destination, { withFileTypes: true })) {
|
|
27126
|
+
if (!entry.name.startsWith(OWNED_SKILL_PREFIX)) continue;
|
|
27127
|
+
const target = join(resolved.destination, entry.name);
|
|
27128
|
+
const targetEntry = lstatSync(target, { throwIfNoEntry: false });
|
|
27129
|
+
if (targetEntry === void 0) continue;
|
|
27130
|
+
if (targetEntry.isSymbolicLink() || !targetEntry.isDirectory()) {
|
|
27131
|
+
skipped.push({
|
|
27132
|
+
host,
|
|
27133
|
+
reason: `${target} is not a real directory.`
|
|
27134
|
+
});
|
|
27135
|
+
continue;
|
|
27136
|
+
}
|
|
27137
|
+
rmSync(target, {
|
|
27138
|
+
force: true,
|
|
27139
|
+
recursive: true
|
|
26865
27140
|
});
|
|
26866
|
-
|
|
27141
|
+
skills.push(entry.name);
|
|
26867
27142
|
}
|
|
26868
|
-
|
|
26869
|
-
|
|
26870
|
-
recursive: true
|
|
26871
|
-
});
|
|
26872
|
-
skills.push(entry.name);
|
|
27143
|
+
skills.sort();
|
|
27144
|
+
removedByDestination.set(resolved.destination, skills);
|
|
26873
27145
|
}
|
|
26874
27146
|
removed.push({
|
|
26875
27147
|
host,
|
|
26876
27148
|
path: resolved.destination,
|
|
26877
|
-
skills
|
|
27149
|
+
skills
|
|
26878
27150
|
});
|
|
26879
27151
|
}
|
|
26880
27152
|
return {
|
|
@@ -27197,13 +27469,13 @@ function createContextTreeCli(io = defaultIo) {
|
|
|
27197
27469
|
emit(io, options.json, result, formatVerify);
|
|
27198
27470
|
if (!result.ok) process.exitCode = 1;
|
|
27199
27471
|
});
|
|
27200
|
-
program.command("install").description("Install the packaged Context Tree skills into each agent's skill directory.").option("--host <host>", "restrict to one host: claude, codex, or all", "all").option("--project <path>", "install below this project root instead of the home directory").action((options) => {
|
|
27472
|
+
program.command("install").description("Install the packaged Context Tree skills into each agent's skill directory.").option("--host <host>", "restrict to one host: claude, codex, pi, or all", "all").option("--project <path>", "install below this project root instead of the home directory").action((options) => {
|
|
27201
27473
|
const request = {};
|
|
27202
27474
|
if (options.host !== "all") request.hosts = [skillHostSchema.parse(options.host)];
|
|
27203
27475
|
if (options.project !== void 0) request.projectPath = resolve(io.cwd(), options.project);
|
|
27204
27476
|
line(io, JSON.stringify(installSkills(request)));
|
|
27205
27477
|
});
|
|
27206
|
-
program.command("uninstall").description("Remove packaged Context Tree skills from each agent's skill directory.").option("--host <host>", "restrict to one host: claude, codex, or all", "all").option("--project <path>", "remove below this project root instead of the home directory").action((options) => {
|
|
27478
|
+
program.command("uninstall").description("Remove packaged Context Tree skills from each agent's skill directory.").option("--host <host>", "restrict to one host: claude, codex, pi, or all", "all").option("--project <path>", "remove below this project root instead of the home directory").action((options) => {
|
|
27207
27479
|
const request = {};
|
|
27208
27480
|
if (options.host !== "all") request.hosts = [skillHostSchema.parse(options.host)];
|
|
27209
27481
|
if (options.project !== void 0) request.projectPath = resolve(io.cwd(), options.project);
|
|
@@ -27214,13 +27486,24 @@ function createContextTreeCli(io = defaultIo) {
|
|
|
27214
27486
|
"schedule",
|
|
27215
27487
|
"status",
|
|
27216
27488
|
"remove",
|
|
27217
|
-
"run"
|
|
27489
|
+
"run",
|
|
27490
|
+
"logs"
|
|
27218
27491
|
]) {
|
|
27219
27492
|
const command = cleanup.command(operation).option("--project-path <path>", "project directory", ".").option(...jsonOption);
|
|
27220
|
-
if (operation === "schedule") command.requiredOption("--agent <agent>", "codex or
|
|
27493
|
+
if (operation === "schedule") command.requiredOption("--agent <agent>", "codex, claude, or pi").option("--model <model>", "explicit model override").option("--every <duration>", "positive whole-minute interval, e.g. 30m or 1h", "1h");
|
|
27494
|
+
if (operation === "logs") command.option("--list", "list recorded runs").option("--run <run-id>", "select a recorded run");
|
|
27221
27495
|
if (operation === "run") command.addOption(new Option("--schedule-id <id>").hideHelp());
|
|
27222
27496
|
command.action(async (options) => {
|
|
27223
27497
|
const projectPath = resolve(io.cwd(), options.projectPath);
|
|
27498
|
+
if (operation === "logs") {
|
|
27499
|
+
const result = cleanupLogs(projectPath, options);
|
|
27500
|
+
emit(io, options.json, result, (value) => {
|
|
27501
|
+
if (!value.runs.length) return `No cleanup logs recorded for project ${projectPath}.\nUse --project-path <path> to query another project's Context Tree.`;
|
|
27502
|
+
if (options.list) return value.runs.map((run) => `${run.runId} ${new Date(run.startedAt).toISOString()} ${run.agent}/${run.model ?? "agent default"} ${run.terminal?.outcome ?? "incomplete"}${run.truncated ? " truncated" : ""}`).join("\n");
|
|
27503
|
+
return value.events.map((event) => `[${new Date(event.at).toISOString()}] [${event.source}] ${event.text}`).join("\n");
|
|
27504
|
+
});
|
|
27505
|
+
return;
|
|
27506
|
+
}
|
|
27224
27507
|
if (operation === "run") {
|
|
27225
27508
|
const result = await runCleanup(projectPath, options.scheduleId);
|
|
27226
27509
|
const wireResult = cleanupRunResultSchema.parse({
|
|
@@ -27248,7 +27531,7 @@ function createContextTreeCli(io = defaultIo) {
|
|
|
27248
27531
|
` Registered: ${value.registered}; running: ${value.running}`,
|
|
27249
27532
|
` Project: ${config.projectPath}`,
|
|
27250
27533
|
` Every: ${config.everyMinutes} minutes`,
|
|
27251
|
-
` Agent: ${config.agent}; model: ${config.model}`,
|
|
27534
|
+
` Agent: ${config.agent}; model: ${config.model ?? "agent default"}`,
|
|
27252
27535
|
` Last activity: ${value.lastActivity === null ? "missing" : new Date(value.lastActivity).toISOString()}`,
|
|
27253
27536
|
` Inactivity: ${value.inactive ? "cleanup prevented (no activity within 24 hours)" : "cleanup permitted"}`,
|
|
27254
27537
|
` Latest: ${value.latest?.outcome ?? "none"}${value.latest?.message ? ` — ${value.latest.message}` : ""}`,
|
package/package.json
CHANGED
package/scripts/postinstall.mjs
CHANGED
|
@@ -50,7 +50,7 @@ const skipped = Array.isArray(payload?.skipped) ? payload.skipped : [];
|
|
|
50
50
|
|
|
51
51
|
if (installed.length === 0) {
|
|
52
52
|
process.stdout.write(
|
|
53
|
-
"Context Tree: no agent directory found. Install Claude Code or
|
|
53
|
+
"Context Tree: no agent directory found. Install Claude Code, Codex, or Pi, then run `context-tree install`.\n",
|
|
54
54
|
);
|
|
55
55
|
process.exit(0);
|
|
56
56
|
}
|
|
@@ -19,11 +19,11 @@ runs cleanup immediately. Use the installed CLI on PATH; if missing, report
|
|
|
19
19
|
desktop task using its existing controls before creating the CLI schedule.
|
|
20
20
|
The CLI cannot discover or cancel old desktop tasks. Do not create a duplicate
|
|
21
21
|
while cancellation is unconfirmed.
|
|
22
|
-
2. Select the requested installed agent, or the current host's CLI: `codex
|
|
23
|
-
`claude`. Use the requested model if supplied; otherwise keep CLI
|
|
24
|
-
Use a positive whole-minute cadence such as `30m`, `1h`, or `1d`;
|
|
25
|
-
every hour. Do not silently approximate unsupported schedules.
|
|
26
|
-
3. Run `context-tree cleanup schedule --project-path "<absolute-project-path>" --agent <codex-or-
|
|
22
|
+
2. Select the requested installed agent, or the current host's CLI: `codex`,
|
|
23
|
+
`claude`, or `pi`. Use the requested model if supplied; otherwise keep CLI
|
|
24
|
+
defaults. Use a positive whole-minute cadence such as `30m`, `1h`, or `1d`;
|
|
25
|
+
default to every hour. Do not silently approximate unsupported schedules.
|
|
26
|
+
3. Run `context-tree cleanup schedule --project-path "<absolute-project-path>" --agent <codex-claude-or-pi> --every <duration> --json`.
|
|
27
27
|
Add `--model <model>` only for an explicit override. Quote real arguments
|
|
28
28
|
safely. Connection or scheduler errors stop without setup or repair.
|
|
29
29
|
4. Read back `context-tree cleanup status --project-path "<absolute-project-path>" --json`.
|
|
@@ -43,8 +43,10 @@ activity, unchanged commits, and overlap. Do not run it merely when scheduling.
|
|
|
43
43
|
macOS uses user LaunchAgents; Linux uses systemd user timers/services. No desktop
|
|
44
44
|
app, daemon, root installation, or Linux lingering is required. The machine must
|
|
45
45
|
be awake and the user scheduler available; timing follows the native scheduler.
|
|
46
|
-
Defaults are `gpt-5.6-luna` with low reasoning effort
|
|
47
|
-
existing CLI authentication.
|
|
46
|
+
Defaults are `gpt-5.6-luna` with low reasoning effort for Codex and
|
|
47
|
+
`claude-haiku-4-5` for Claude, using existing CLI authentication. Pi uses its
|
|
48
|
+
configured default model. Pi runs ephemeral with extensions disabled; `--model`
|
|
49
|
+
may also name a `provider/model`. Do not change credentials, bypass permissions,
|
|
48
50
|
silently switch models, or retry publication.
|
|
49
51
|
|
|
50
52
|
Initial scheduling starts a 24-hour activity window. Successful ordinary create,
|
|
@@ -54,3 +56,19 @@ activity skips before network or model work; unchanged successfully cleaned
|
|
|
54
56
|
commits skip the model. Failures preserve worktrees and success checkpoints.
|
|
55
57
|
The runner owns preparation, verification, and publication; the fresh agent
|
|
56
58
|
only edits using the cleanup skill's shared required editorial resource.
|
|
59
|
+
|
|
60
|
+
Inspect runner history with `context-tree cleanup logs --project-path <project> --json`.
|
|
61
|
+
Use `--list` for newest-first summaries or `--run <run-id>` for a particular run;
|
|
62
|
+
these selectors are mutually exclusive. The versioned result includes summaries
|
|
63
|
+
and the selected run's labeled runner/stdout/stderr events. Reads are snapshots,
|
|
64
|
+
do not refresh activity, and do not query the scheduler. Agent output varies by
|
|
65
|
+
CLI. Missing terminal outcomes mean incomplete runs. Outcomes include `runId`
|
|
66
|
+
when history was recorded.
|
|
67
|
+
|
|
68
|
+
History is local, shared across connected projects, and survives schedule removal.
|
|
69
|
+
Retention is 50 runs (oldest completed runs are pruned; incomplete entries are
|
|
70
|
+
protected), with 5 MiB of serialized output per run and an explicit truncation
|
|
71
|
+
flag. Final outcome metadata survives output truncation. Credentials and terminal
|
|
72
|
+
controls are sanitized; oversized lines are suppressed. Only runner attempts are
|
|
73
|
+
recorded, including inactivity and unchanged skips. Old transcripts and cleanup
|
|
74
|
+
performed directly through a host skill are unavailable.
|