@first-tree-ai/context-tree 0.1.12 → 0.1.14-alpha.202609140819

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 CHANGED
@@ -28,7 +28,8 @@ skill directory of every agent you already have:
28
28
 
29
29
  ```text
30
30
  ✓ claude → ~/.claude/skills/ (8 skills)
31
- ✓ codex → ~/.codex/skills/ (8 skills)
31
+ ✓ codex → ~/.agents/skills/ (8 skills)
32
+ ✓ pi → ~/.agents/skills/ (8 skills)
32
33
  ```
33
34
 
34
35
  Restart your agent so it discovers them, then try asking:
@@ -43,7 +44,7 @@ new agent, or scope it to one project:
43
44
  ```bash
44
45
  context-tree install # every agent you have
45
46
  context-tree install --host codex # one agent
46
- context-tree install --project . # ./.claude/skills and ./.codex/skills
47
+ context-tree install --project . # ./.claude/skills and ./.agents/skills
47
48
  context-tree uninstall # remove context-tree-* skills
48
49
  ```
49
50
 
@@ -51,7 +52,9 @@ Install and uninstall own exactly the `context-tree-*` skill directories.
51
52
  Install never touches skills it does not own or creates a configuration directory
52
53
  for an agent that is not present; uninstall removes every owned-prefix directory
53
54
  and nothing else. Adding support for another agent is one entry in the host table
54
- in `src/core/install.ts`.
55
+ in `src/core/install.ts`. Codex and Pi share the cross-agent `.agents/skills`
56
+ location, so that directory is written and removed once and reported for both
57
+ hosts.
55
58
 
56
59
  `create` and `connect` leave project instructions unchanged. When a project has
57
60
  a regular `AGENTS.md` and no `CLAUDE.md` entry, they best-effort create a
@@ -190,6 +193,9 @@ $context-tree-schedule-cleanup every 2 hours
190
193
 
191
194
  # Claude Code
192
195
  /context-tree-schedule-cleanup every 2 hours
196
+
197
+ # Pi
198
+ /skill:context-tree-schedule-cleanup every 2 hours
193
199
  ```
194
200
 
195
201
  The CLI manages one schedule per tree on this machine:
@@ -197,6 +203,7 @@ The CLI manages one schedule per tree on this machine:
197
203
  ```bash
198
204
  context-tree cleanup schedule --project-path /absolute/project --agent codex
199
205
  context-tree cleanup schedule --project-path /absolute/project --agent claude --every 2h
206
+ context-tree cleanup schedule --project-path /absolute/project --agent pi --model anthropic/claude-haiku-4-5
200
207
  context-tree cleanup status --project-path /absolute/project
201
208
  context-tree cleanup run --project-path /absolute/project
202
209
  context-tree cleanup remove --project-path /absolute/project
@@ -218,11 +225,14 @@ installation, daemon, or Linux lingering is needed. Cancel any previously create
218
225
  Codex desktop task or Claude Desktop routine before replacing it: the CLI cannot
219
226
  inspect or remove those tasks. Keep one designated cleaner per tree across machines.
220
227
 
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)
228
+ Agents use existing CLI authentication. Codex defaults to `gpt-5.6-luna` with
229
+ low reasoning effort and Claude to `claude-haiku-4-5`; Pi uses its configured
230
+ default model. `--model` selects an explicit override (Pi also accepts a
231
+ `provider/model` value). Codex uses workspace-write sandboxing, Claude uses
232
+ file-editing permissions, and Pi runs ephemeral with extensions disabled.
233
+ Claude and Pi use restricted editing tool lists with no shell. Permission and
234
+ authentication failures stop the run; models are never silently substituted.
235
+ See [Codex noninteractive mode](https://learn.chatgpt.com/docs/non-interactive-mode)
226
236
  and the [Claude CLI reference](https://code.claude.com/docs/en/cli-reference).
227
237
 
228
238
  Scheduling opens a 24-hour activity window. Successful ordinary `create`,
@@ -304,3 +314,23 @@ pnpm check:package
304
314
 
305
315
  See [docs/specification.md](docs/specification.md) for contracts and safety
306
316
  invariants.
317
+
318
+ Cleanup runner history is available with `context-tree cleanup logs` (latest output),
319
+ `context-tree cleanup logs --list` (newest runs first), or
320
+ `context-tree cleanup logs --run <run-id>`. Each accepts `--project-path` and
321
+ `--json`; `--list` and `--run` are mutually exclusive. JSON returns one versioned
322
+ result containing run summaries and the selected run's events. Reading logs does
323
+ not refresh activity or contact the scheduler. Active runs return a snapshot;
324
+ runs without a terminal outcome are shown as incomplete.
325
+
326
+ History lives under `~/.context-tree/cleanup/logs/<tree-id>/<run-id>/` and is shared
327
+ by projects connected to the same tree. Removing a schedule preserves history.
328
+ The runner keeps 50 runs, pruning oldest completed runs; incomplete entries are
329
+ protected. Each run holds at most 5 MiB of serialized output, with truncation
330
+ reported separately from its final outcome. Logs label lifecycle events, stdout,
331
+ and stderr in observed line order. Agent CLIs differ in how much progress and
332
+ final output they emit. Credentials and terminal controls are sanitized, and
333
+ oversized lines are suppressed. Prompts and environment configuration are not
334
+ recorded by the runner. History covers scheduled and explicit `cleanup run`
335
+ attempts, including skips; previous transcripts cannot be recovered, and cleanup
336
+ performed directly through a host skill is outside this runner.
@@ -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 = ["claude", "codex"];
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([literal("codex"), literal("claude")]);
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
- return config.agent === "codex" ? [
26013
- "exec",
26014
- "--sandbox",
26015
- "workspace-write",
26016
- "-c",
26017
- "approval_policy=\"never\"",
26018
- "-c",
26019
- "model_reasoning_effort=\"low\"",
26020
- "--model",
26021
- config.model,
26022
- "--ephemeral",
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
- "ignore",
26050
- "ignore"
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
- command(["bootout", `${domain}/${name}`], true);
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 ?? (agent === "codex" ? "gpt-5.6-luna" : "claude-haiku-4-5"),
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
- /** Per-host configuration directory, relative to the home directory or to a project root. */
26722
- const HOST_CONFIG_DIRECTORY = {
26723
- claude: ".claude",
26724
- codex: ".codex"
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
- /** Every supported host keeps user skills in the same subdirectory of its configuration directory. */
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 configDirectory = HOST_CONFIG_DIRECTORY[host];
27032
+ const directories = HOST_DIRECTORIES[host];
26774
27033
  if (!isProjectInstall) {
26775
- const hostRoot = join(root, configDirectory);
26776
- const entry = lstatSync(hostRoot, { throwIfNoEntry: false });
26777
- if (entry === void 0) return { reason: `${hostRoot} does not exist; install ${host} first, then run context-tree install.` };
26778
- if (entry.isSymbolicLink() || !entry.isDirectory()) return { reason: `${hostRoot} is not a real directory.` };
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, [configDirectory, SKILLS_DIRECTORY]) };
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, HOST_CONFIG_DIRECTORY[host]);
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
- for (const skill of skills) {
26820
- const target = join(resolved.destination, skill);
26821
- if (lstatSync(target, { throwIfNoEntry: false }) !== void 0) rmSync(target, {
26822
- force: true,
26823
- recursive: true
26824
- });
26825
- copyRealTree(join(skillsRoot, skill), target);
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
- const skills = [];
26856
- for (const entry of readdirSync(resolved.destination, { withFileTypes: true })) {
26857
- if (!entry.name.startsWith(OWNED_SKILL_PREFIX)) continue;
26858
- const target = join(resolved.destination, entry.name);
26859
- const targetEntry = lstatSync(target, { throwIfNoEntry: false });
26860
- if (targetEntry === void 0) continue;
26861
- if (targetEntry.isSymbolicLink() || !targetEntry.isDirectory()) {
26862
- skipped.push({
26863
- host,
26864
- reason: `${target} is not a real directory.`
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
- continue;
27141
+ skills.push(entry.name);
26867
27142
  }
26868
- rmSync(target, {
26869
- force: true,
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: skills.sort()
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 claude").option("--model <model>", "explicit model override").option("--every <duration>", "positive whole-minute interval, e.g. 30m or 1h", "1h");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@first-tree-ai/context-tree",
3
- "version": "0.1.12",
3
+ "version": "0.1.14-alpha.202609140819",
4
4
  "description": "Durable, structured project context for coding agents: a CLI plus framework-neutral skills.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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 Codex, then run `context-tree install`.\n",
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` or
23
- `claude`. Use the requested model if supplied; otherwise keep CLI defaults.
24
- Use a positive whole-minute cadence such as `30m`, `1h`, or `1d`; default to
25
- every hour. Do not silently approximate unsupported schedules.
26
- 3. Run `context-tree cleanup schedule --project-path "<absolute-project-path>" --agent <codex-or-claude> --every <duration> --json`.
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 or `claude-haiku-4-5`, using
47
- existing CLI authentication. Do not change credentials, bypass permissions,
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.