ubume 0.1.0 → 0.1.1

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/bin/codexa.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ubume",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Terminal UI for Ubume — TypeScript + Bun + Ink",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,12 +47,12 @@
47
47
  ],
48
48
  "repository": {
49
49
  "type": "git",
50
- "url": "git+https://github.com/golba98/ubume-cli.git"
50
+ "url": "git+https://github.com/golba98/Ubume.git"
51
51
  },
52
52
  "bugs": {
53
- "url": "https://github.com/golba98/ubume-cli/issues"
53
+ "url": "https://github.com/golba98/Ubume/issues"
54
54
  },
55
- "homepage": "https://github.com/golba98/ubume-cli#readme",
55
+ "homepage": "https://github.com/golba98/Ubume#readme",
56
56
  "publishConfig": {
57
57
  "registry": "https://registry.npmjs.org"
58
58
  },
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/gen-build-info.mjs — do not edit manually.
2
- export const BUILD_COMMIT = "cf100a7e97313dce7d66fab2424dc3ff55695db7" as const;
3
- export const APP_VERSION = "0.1.0" as const;
2
+ export const BUILD_COMMIT = "cd0bbd0c106c85ce0610674130853ca0404b8967" as const;
3
+ export const APP_VERSION = "0.1.1" as const;
@@ -12,7 +12,13 @@ import { resolveDefaultMaxOutputTokens } from "../localOutputBudget.js";
12
12
  import type { LocalHarnessSessionMetadata } from "../../workspace/conversationStore.js";
13
13
  import { resolveUbumeWorkspaceDataDir } from "../../workspace/appData.js";
14
14
  import { getShellWorkspaceGuardMessage, isPathInsideAllowedRoots } from "../../workspace/workspaceGuard.js";
15
- import { ensureSessionScratchDir, pruneStaleScratchDirs } from "../../workspace/scratchDir.js";
15
+ import {
16
+ describeSessionScratchDir,
17
+ ensureSessionScratchDir,
18
+ mentionsScratchDir,
19
+ pruneStaleScratchDirs,
20
+ removeUnusedSessionScratchDir,
21
+ } from "../../workspace/scratchDir.js";
16
22
  import { isDangerousShellCommand } from "../../agent/tools.js";
17
23
  import { traceLocalStream } from "../../debug/localStreamDebug.js";
18
24
  import {
@@ -229,7 +235,8 @@ function resolveDshBin(): string {
229
235
  function prepareSessionScratch(request: ProviderChatRequest, sessionId: string, resumed: boolean): string | null {
230
236
  if (resolveHarnessSandboxMode(request) === "read-only") return null;
231
237
  try {
232
- const scratch = ensureSessionScratchDir(request.workspaceRoot, sessionId);
238
+ // Only name the folder here; the tool/policy bridge creates it once a tool targets it.
239
+ const scratch = describeSessionScratchDir(request.workspaceRoot, sessionId);
233
240
  if (!resumed) pruneStaleScratchDirs(request.workspaceRoot, { keep: sessionId });
234
241
  return `Scratch directory for this session: ${scratch.relativePath}/ (put every temporary test, debug, or probe file there, not in the project).`;
235
242
  } catch (error) {
@@ -817,6 +824,13 @@ export class LocalHarnessProcess implements LocalHarnessRunner {
817
824
  return { kind: "deny", reason: `Path is outside the active workspace: ${candidatePath}` };
818
825
  }
819
826
  }
827
+ if ([command, ...pathsFrom(args)].some(mentionsScratchDir)) {
828
+ try {
829
+ ensureSessionScratchDir(state.request.workspaceRoot, state.sessionId);
830
+ } catch (error) {
831
+ traceLocalStream("harness.scratch.unavailable", { sessionId: state.sessionId, error: error instanceof Error ? error.message : String(error) });
832
+ }
833
+ }
820
834
  const signature = `${tool}:${command || pathsFrom(args).join(",")}`;
821
835
  if (state.approvals.has(signature)) return { kind: "allow" };
822
836
  if (state.request.runtime.policy.approvalPolicy === "on-request") return { kind: "ask", reason: `Allow ${commandFrom(tool, args)}?` };
@@ -963,6 +977,7 @@ export class LocalHarnessProcess implements LocalHarnessRunner {
963
977
  state.settled = true;
964
978
  state.abortCleanup();
965
979
  this.active = null;
980
+ if (state.request?.workspaceRoot) removeUnusedSessionScratchDir(state.request.workspaceRoot, state.sessionId);
966
981
  const completedMessages = [
967
982
  ...(state.request.conversationHistory ?? []),
968
983
  { role: "user", content: state.request.prompt },
@@ -985,6 +1000,8 @@ export class LocalHarnessProcess implements LocalHarnessRunner {
985
1000
  state.settled = true;
986
1001
  state.abortCleanup();
987
1002
  this.active = null;
1003
+ // Best-effort cleanup must never keep a failed run from settling.
1004
+ if (state.request?.workspaceRoot) removeUnusedSessionScratchDir(state.request.workspaceRoot, state.sessionId);
988
1005
  state.handlers.onLocalHarnessSession?.(null, state.sessionId);
989
1006
  const child = this.child;
990
1007
  const transport = this.transport;
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
4
  /** Workspace-relative home for agent throwaway files (test harnesses, probe scripts, logs). */
@@ -7,6 +7,7 @@ export const LEGACY_SCRATCH_RELATIVE_DIR = ".codexa/scratch";
7
7
 
8
8
  const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
9
9
  const SAFE_SESSION_ID = /^[A-Za-z0-9_-]+$/;
10
+ const SCRATCH_GITIGNORE = ".gitignore";
10
11
 
11
12
  export interface SessionScratchDir {
12
13
  absolutePath: string;
@@ -21,21 +22,67 @@ export function resolveLegacyScratchRoot(workspaceRoot: string): string {
21
22
  return join(workspaceRoot, ".codexa", "scratch");
22
23
  }
23
24
 
25
+ /** Name the session scratch folder without touching the filesystem. */
26
+ export function describeSessionScratchDir(workspaceRoot: string, sessionId: string): SessionScratchDir {
27
+ if (!SAFE_SESSION_ID.test(sessionId)) throw new Error(`Unsafe scratch session id: ${sessionId}`);
28
+ return {
29
+ absolutePath: join(resolveScratchRoot(workspaceRoot), sessionId),
30
+ relativePath: `${SCRATCH_RELATIVE_DIR}/${sessionId}`,
31
+ };
32
+ }
33
+
34
+ /** True when a tool path or shell command targets the workspace scratch folder. */
35
+ export function mentionsScratchDir(value: string): boolean {
36
+ return value.replace(/\\/g, "/").includes(SCRATCH_RELATIVE_DIR);
37
+ }
38
+
24
39
  /**
25
40
  * Create `.ubume/scratch/<sessionId>` inside the workspace. The scratch root
26
41
  * carries its own `*` .gitignore so git ignores it without touching the
27
42
  * project's ignore rules. It lives in the workspace (not the OS temp dir)
28
- * because sandboxed shells only get a persistent writable workspace.
43
+ * because sandboxed shells only get a persistent writable workspace. Call it
44
+ * only once a tool actually targets the folder, so unused sessions leave no
45
+ * trace in the project.
29
46
  */
30
47
  export function ensureSessionScratchDir(workspaceRoot: string, sessionId: string): SessionScratchDir {
31
- if (!SAFE_SESSION_ID.test(sessionId)) throw new Error(`Unsafe scratch session id: ${sessionId}`);
48
+ const scratch = describeSessionScratchDir(workspaceRoot, sessionId);
32
49
  const root = resolveScratchRoot(workspaceRoot);
33
50
  mkdirSync(root, { recursive: true });
34
- const gitignore = join(root, ".gitignore");
51
+ const gitignore = join(root, SCRATCH_GITIGNORE);
35
52
  if (!existsSync(gitignore)) writeFileSync(gitignore, "*\n", "utf8");
36
- const absolutePath = join(root, sessionId);
37
- mkdirSync(absolutePath, { recursive: true });
38
- return { absolutePath, relativePath: `${SCRATCH_RELATIVE_DIR}/${sessionId}` };
53
+ mkdirSync(scratch.absolutePath, { recursive: true });
54
+ return scratch;
55
+ }
56
+
57
+ function removeDirIfEmpty(dir: string, ignoredEntries: readonly string[] = []): boolean {
58
+ let entries: string[];
59
+ try {
60
+ entries = readdirSync(dir);
61
+ } catch {
62
+ return false;
63
+ }
64
+ if (entries.some((entry) => !ignoredEntries.includes(entry))) return false;
65
+ for (const entry of entries) rmSync(join(dir, entry), { force: true });
66
+ rmdirSync(dir);
67
+ return true;
68
+ }
69
+
70
+ /**
71
+ * Best-effort removal of an empty session scratch folder, then of the
72
+ * `.ubume/scratch` root (when only its .gitignore remains) and `.ubume`
73
+ * (when nothing else lives there), so a session that never used scratch
74
+ * leaves the project untouched.
75
+ */
76
+ export function removeUnusedSessionScratchDir(workspaceRoot: string, sessionId: string): void {
77
+ try {
78
+ const scratch = describeSessionScratchDir(workspaceRoot, sessionId);
79
+ if (existsSync(scratch.absolutePath) && !removeDirIfEmpty(scratch.absolutePath)) return;
80
+ const root = resolveScratchRoot(workspaceRoot);
81
+ if (!removeDirIfEmpty(root, [SCRATCH_GITIGNORE])) return;
82
+ removeDirIfEmpty(join(workspaceRoot, ".ubume"));
83
+ } catch {
84
+ // A locked or vanished folder must not break the run that triggered cleanup.
85
+ }
39
86
  }
40
87
 
41
88
  /** Best-effort removal of session scratch folders older than `maxAgeMs`. */