@timurproko/a1 0.2.7-dev.1287 → 0.2.7-dev.1290

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.
Files changed (50) hide show
  1. package/README.md +4 -0
  2. package/bin/cli.js +4 -0
  3. package/dist/cli/capabilities.d.ts +1 -0
  4. package/dist/cli/capabilities.js +1 -1
  5. package/dist/cli/dispatch.d.ts +4 -0
  6. package/dist/cli/dispatch.js +34 -1
  7. package/dist/cli/session-color.d.ts +6 -0
  8. package/dist/cli/session-color.js +44 -0
  9. package/dist/cli/session-context.js +5 -0
  10. package/dist/composition/owned-settings.d.ts +2 -2
  11. package/dist/composition/owned-settings.js +2 -2
  12. package/dist/composition/owned-ui.js +31 -11
  13. package/dist/composition/session-colors.d.ts +5 -0
  14. package/dist/composition/session-colors.js +33 -0
  15. package/dist/composition/session-runtime-registry.d.ts +3 -1
  16. package/dist/composition/session-runtime-registry.js +32 -3
  17. package/dist/contracts/owned-ui/index.d.ts +2 -2
  18. package/dist/contracts/owned-ui/index.js +1 -0
  19. package/dist/contracts/presentation/progress-palette.generated.d.ts +31 -5
  20. package/dist/contracts/presentation/progress-palette.generated.js +1 -1
  21. package/dist/features/launch/session-context.d.ts +9 -0
  22. package/dist/features/launch/session-context.js +7 -1
  23. package/dist/features/owned-ui/settings-app.js +1 -1
  24. package/dist/foundation/lifecycle/index.d.ts +6 -2
  25. package/dist/foundation/lifecycle/index.js +3 -1
  26. package/dist/foundation/lifecycle/session-control.d.ts +26 -0
  27. package/dist/foundation/lifecycle/session-control.js +222 -0
  28. package/dist/foundation/lifecycle/session-repository-context.d.ts +27 -0
  29. package/dist/foundation/lifecycle/session-repository-context.js +106 -7
  30. package/dist/foundation/lifecycle/session-worktree-completion.d.ts +35 -0
  31. package/dist/foundation/lifecycle/session-worktree-completion.js +190 -0
  32. package/dist/foundation/release/early-accent.d.ts +2 -1
  33. package/dist/integrations/pi/components/upstream/theme/theme.js +8 -6
  34. package/dist/integrations/pi/engine/runtime-integration.js +1 -1
  35. package/dist/integrations/pi/engine/worktree-tools.d.ts +5 -2
  36. package/dist/integrations/pi/engine/worktree-tools.js +18 -3
  37. package/dist/ui/settings/declarations.d.ts +18 -2
  38. package/dist/ui/settings/declarations.js +14 -4
  39. package/dist/ui/settings/early-accent.d.ts +16 -0
  40. package/dist/ui/settings/early-accent.js +1 -3
  41. package/dist/ui/settings/manager.d.ts +4 -2
  42. package/dist/ui/settings/manager.js +36 -4
  43. package/dist/ui/settings/resolution.js +3 -2
  44. package/docs/architecture/project-structure.md +1 -1
  45. package/docs/features/session-colors.md +66 -0
  46. package/docs/features/worktrees.md +30 -2
  47. package/docs/local-worktree-cleanup.md +3 -1
  48. package/docs/openspec-archive-automation.md +3 -3
  49. package/docs/worktree-link-recovery.md +2 -0
  50. package/package.json +3 -3
@@ -0,0 +1,190 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { detachSessionRepositoryContextForCompletion, readActiveSessionRepositoryContext, readSessionStartupRoot, sessionWorktreeClaimState, sessionWorktreeCompletionPath, } from "./session-repository-context.js";
6
+ const SCHEMA = "a1-session-worktree-completion-v1";
7
+ const CLEANUP_DECLARATION = ".a1/worktree.json";
8
+ const MAX_RECORD_BYTES = 16 * 1024;
9
+ const MAX_REPORT_CHARACTERS = 4_000;
10
+ /** A request the runtime did not settle promptly is dropped, never run later by surprise. */
11
+ const REQUEST_LIFETIME_MS = 5 * 60_000;
12
+ const CLEANUP_TIMEOUT_MS = 5 * 60_000;
13
+ /**
14
+ * Leave the attached checkout so its cleanup can run after the issuing shell has exited.
15
+ * Unpublished content, a Git lock, or uncertain ownership keeps the session attached.
16
+ */
17
+ export async function requestSessionWorktreeCompletion(identity, selectors, options) {
18
+ if (selectors.change !== undefined && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(selectors.change)) {
19
+ throw failure("worktree-completion-selector", "the change selector must be a kebab-case change id");
20
+ }
21
+ if (selectors.pr !== undefined && (!Number.isSafeInteger(selectors.pr) || selectors.pr < 1)) {
22
+ throw failure("worktree-completion-selector", "the pull-request selector must be a positive number");
23
+ }
24
+ const attached = await readActiveSessionRepositoryContext(identity, options);
25
+ if (attached === null)
26
+ throw failure("worktree-not-attached", "no task worktree is attached");
27
+ const status = await execute("git", ["-c", "core.hooksPath=/dev/null", "status", "--porcelain", "--untracked-files=all"], attached.cwd, options.signal);
28
+ const changed = status.split(/\r?\n/u).filter(Boolean);
29
+ if (changed.length > 0) {
30
+ const listed = changed.slice(0, 5).map(line => line.slice(3)).join(", ");
31
+ throw failure("worktree-content", `the attached worktree holds uncommitted or untracked content (${listed}${changed.length > 5 ? ", ..." : ""})`);
32
+ }
33
+ const path = sessionWorktreeCompletionPath(identity, options);
34
+ const detached = await detachSessionRepositoryContextForCompletion(identity, options, async (left) => {
35
+ if (!samePath(left.worktreeRoot, attached.cwd))
36
+ throw failure("worktree-context-changed", "the attachment changed during completion");
37
+ const record = {
38
+ schema: SCHEMA, sessionId: identity.sessionId, startupRoot: left.startupRoot, commonDir: left.commonDir,
39
+ worktreeRoot: left.worktreeRoot, worktreeGitDir: left.worktreeGitDir, branch: left.branch,
40
+ ...(selectors.change === undefined ? {} : { change: selectors.change }),
41
+ ...(selectors.pr === undefined ? {} : { pr: selectors.pr }),
42
+ requestedAt: Date.now(),
43
+ };
44
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
45
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
46
+ try {
47
+ await writeFile(temporary, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
48
+ await rename(temporary, path);
49
+ }
50
+ finally {
51
+ await rm(temporary, { force: true }).catch(() => undefined);
52
+ }
53
+ });
54
+ return { startupRoot: detached.startupRoot, cwd: detached.worktreeRoot, branch: detached.branch };
55
+ }
56
+ /**
57
+ * Run a pending completion's repository cleanup from the startup checkout. The request is consumed
58
+ * before anything runs, so it executes at most once; null means nothing was pending.
59
+ */
60
+ export async function settleSessionWorktreeCompletion(identity, options) {
61
+ const path = sessionWorktreeCompletionPath(identity, options);
62
+ const record = await readRecord(path);
63
+ await rm(path, { force: true });
64
+ if (record === null || record.sessionId !== identity.sessionId)
65
+ return null;
66
+ const request = { startupRoot: record.startupRoot, cwd: record.worktreeRoot, branch: record.branch };
67
+ const result = (kind, detail) => ({ ...request, kind, detail });
68
+ try {
69
+ if ((options.now ?? Date.now)() - record.requestedAt > REQUEST_LIFETIME_MS)
70
+ return result("cleanup-skipped", "the completion request expired before the runtime could settle it");
71
+ const startup = await realpath(await readSessionStartupRoot(identity));
72
+ if (!samePath(startup, record.startupRoot))
73
+ return result("cleanup-skipped", "the session's startup checkout is not the recorded primary checkout");
74
+ const claim = await sessionWorktreeClaimState(record.commonDir, record.worktreeGitDir, options);
75
+ if (claim === "live")
76
+ return result("cleanup-skipped", "a live session attached the checkout again");
77
+ if (claim === "unverifiable")
78
+ return result("cleanup-skipped", "the checkout's editing claim cannot be verified");
79
+ // Security: the command is the primary checkout's committed declaration, never a file of the task checkout being left.
80
+ const declared = await readCleanupDeclaration(join(startup, CLEANUP_DECLARATION));
81
+ if (declared === null)
82
+ return result("cleanup-unavailable", `this repository declares no cleanup command in ${CLEANUP_DECLARATION}; the checkout was retained`);
83
+ if (declared === "invalid")
84
+ return result("cleanup-unavailable", `${CLEANUP_DECLARATION} must hold {"cleanup": ["program", "argument", ...]}; the checkout was retained`);
85
+ const values = {
86
+ primary: startup, worktree: record.worktreeRoot, branch: record.branch,
87
+ change: record.change, pr: record.pr === undefined ? undefined : String(record.pr),
88
+ };
89
+ const missing = new Set();
90
+ const [command, ...arguments_] = declared.map(part => part.replace(/\{([a-z]+)\}/gu, (_match, name) => {
91
+ const value = values[name];
92
+ if (value === undefined)
93
+ missing.add(name);
94
+ return value ?? "";
95
+ }));
96
+ if (missing.size > 0 || command === undefined) {
97
+ return result("cleanup-unavailable", `the declared cleanup needs ${[...missing].map(name => `{${name}}`).join(" and ")}; run completion with the matching selector or the repository cleanup from primary`);
98
+ }
99
+ const run = options.run ?? execute;
100
+ try {
101
+ return result("cleanup-finished", bounded(await run(command, arguments_, startup, options.signal)));
102
+ }
103
+ catch (error) {
104
+ const output = isRecord(error) ? [error.stdout, error.stderr].filter(value => typeof value === "string" && value.length > 0).join("\n") : "";
105
+ return result("cleanup-failed", bounded(output || (error instanceof Error ? error.message : String(error))));
106
+ }
107
+ }
108
+ catch (error) {
109
+ return result("cleanup-skipped", error instanceof Error ? error.message : String(error));
110
+ }
111
+ }
112
+ /** The agent-facing account of a settled completion; the session is on the startup checkout in every case. */
113
+ export function describeWorktreeCompletion(result) {
114
+ const headline = {
115
+ "cleanup-finished": "the repository cleanup command ran; its report below is authoritative",
116
+ "cleanup-failed": "the repository cleanup command failed; the checkout may remain and is retried with repository cleanup from primary",
117
+ "cleanup-unavailable": "cleanup is unavailable; the checkout was retained",
118
+ "cleanup-skipped": "cleanup was skipped; the checkout was retained",
119
+ };
120
+ return [
121
+ `Worktree completion (${result.kind}): ${headline[result.kind]}.`,
122
+ `Session uses ${result.startupRoot}; left ${result.cwd} (${result.branch}). Do not attach it again while cleanup is partial.`,
123
+ result.detail,
124
+ ].join("\n");
125
+ }
126
+ /** Null when the repository declares nothing; "invalid" when the declaration cannot be used as an argument list. */
127
+ async function readCleanupDeclaration(path) {
128
+ const metadata = await lstat(path).catch(error => { if (isRecord(error) && (error.code === "ENOENT" || error.code === "ENOTDIR"))
129
+ return null; throw error; });
130
+ if (metadata === null)
131
+ return null;
132
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size === 0 || metadata.size > MAX_RECORD_BYTES)
133
+ return "invalid";
134
+ try {
135
+ const value = JSON.parse(await readFile(path, "utf8"));
136
+ const cleanup = isRecord(value) ? value.cleanup : undefined;
137
+ return Array.isArray(cleanup) && cleanup.length > 0 && cleanup.length <= 64
138
+ && cleanup.every(part => typeof part === "string" && part.length > 0 && part.length <= 2_048 && !part.includes("\0"))
139
+ // Compatibility: the element check above establishes the string array.
140
+ ? cleanup : "invalid";
141
+ }
142
+ catch {
143
+ return "invalid";
144
+ }
145
+ }
146
+ async function readRecord(path) {
147
+ try {
148
+ const metadata = await lstat(path);
149
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size === 0 || metadata.size > MAX_RECORD_BYTES)
150
+ return null;
151
+ const value = JSON.parse(await readFile(path, "utf8"));
152
+ if (!isRecord(value) || value.schema !== SCHEMA)
153
+ return null;
154
+ const text = (name) => typeof value[name] === "string" && value[name].length > 0 && value[name].length <= 32_768;
155
+ if (!["sessionId", "startupRoot", "commonDir", "worktreeRoot", "worktreeGitDir", "branch"].every(text))
156
+ return null;
157
+ if (typeof value.requestedAt !== "number" || !Number.isFinite(value.requestedAt))
158
+ return null;
159
+ if (value.change !== undefined && typeof value.change !== "string")
160
+ return null;
161
+ if (value.pr !== undefined && (typeof value.pr !== "number" || !Number.isSafeInteger(value.pr) || value.pr < 1))
162
+ return null;
163
+ // Compatibility: the checks above establish every field of the stored shape.
164
+ return value;
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ }
170
+ async function execute(command, arguments_, cwd, signal) {
171
+ const { execFile } = await import("node:child_process");
172
+ const { stdout } = await promisify(execFile)(command, [...arguments_], {
173
+ cwd, encoding: "utf8", windowsHide: true, timeout: CLEANUP_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, signal,
174
+ });
175
+ return stdout;
176
+ }
177
+ function bounded(text) {
178
+ const trimmed = text.trim();
179
+ return trimmed.length <= MAX_REPORT_CHARACTERS ? trimmed : `...${trimmed.slice(-MAX_REPORT_CHARACTERS)}`;
180
+ }
181
+ function samePath(left, right) {
182
+ const key = (value) => process.platform === "win32" ? resolve(value).toLowerCase() : resolve(value);
183
+ return key(left) === key(right);
184
+ }
185
+ function isRecord(value) {
186
+ return typeof value === "object" && value !== null && !Array.isArray(value);
187
+ }
188
+ function failure(code, message) {
189
+ return Object.assign(new Error(message), { code });
190
+ }
@@ -1,4 +1,5 @@
1
- type UiAccentColor = "blue" | "purple" | "cyan" | "green" | "orange" | "pink";
1
+ import { SHARED_PROGRESS_PALETTE } from "../../contracts/presentation/index.js";
2
+ type UiAccentColor = keyof typeof SHARED_PROGRESS_PALETTE;
2
3
  type EarlyAccentAppearance = "dark" | "light";
3
4
  type EarlyAccentMode = "truecolor" | "256color";
4
5
  /** Read only global profile data, never project settings or the interactive resource pipeline. */
@@ -45,6 +45,7 @@ const ACCENT_PALETTE = Object.freeze({
45
45
  green: Object.freeze({ dark: okhslColor(159, 0.59, 0.67), light: okhslColor(159, 0.75, 0.46) }),
46
46
  orange: Object.freeze({ dark: okhslColor(48, 0.75, 0.67), light: okhslColor(48, 0.90, 0.47) }),
47
47
  pink: Object.freeze({ dark: okhslColor(337, 0.72, 0.67), light: okhslColor(337, 0.75, 0.48) }),
48
+ grey: Object.freeze({ dark: okhslColor(0, 0, 0.67), light: okhslColor(0, 0, 0.47) }),
48
49
  });
49
50
  /** Canonical named color used by both the interactive theme and pre-load palette generation. */
50
51
  export function namedPiAccent(color, appearance) {
@@ -59,6 +60,7 @@ function wrapHue(hue) {
59
60
  /** Derive the complete semantic family from any primary accent, including future custom colors. */
60
61
  export function derivePiAccentProjection(accent, appearance) {
61
62
  const { h, s, l } = colorToOkhsl(accent);
63
+ const neutral = s < 0.001;
62
64
  const warm = h < 90 || h >= 270;
63
65
  const magenta = h >= 270 && h < 325;
64
66
  const blue = h >= 210 && h < 270;
@@ -69,10 +71,10 @@ export function derivePiAccentProjection(accent, appearance) {
69
71
  const selectedLightness = dark ? l * 0.37 : 1 - (1 - l) * 0.075;
70
72
  return Object.freeze({
71
73
  accent,
72
- border: okhslColor(borderHue, clamp(s * 0.78, 0.30, 0.64), clamp(l - (dark ? 0.17 : 0.11), 0, 1)),
73
- secondaryHeading: okhslColor(secondaryHue, clamp(s + 0.20, 0.62, 0.82), clamp(l + (dark ? 0.09 : -0.09), 0, 1)),
74
- selectedBg: okhslColor(h, selectedSaturation, selectedLightness),
75
- userMessageBg: okhslColor(h, selectedSaturation * 2 / 3, dark ? l * 0.34 : 1 - (1 - l) * 0.055),
74
+ border: okhslColor(borderHue, neutral ? 0 : clamp(s * 0.78, 0.30, 0.64), clamp(l - (dark ? 0.17 : 0.11), 0, 1)),
75
+ secondaryHeading: okhslColor(secondaryHue, neutral ? 0 : clamp(s + 0.20, 0.62, 0.82), clamp(l + (dark ? 0.09 : -0.09), 0, 1)),
76
+ selectedBg: okhslColor(h, neutral ? 0 : selectedSaturation, selectedLightness),
77
+ userMessageBg: okhslColor(h, neutral ? 0 : selectedSaturation * 2 / 3, dark ? l * 0.34 : 1 - (1 - l) * 0.055),
76
78
  });
77
79
  }
78
80
  let activeBaseTheme;
@@ -118,8 +120,8 @@ export function derivePiCanvasBackground(style, accent) {
118
120
  return null;
119
121
  if (style === "dark")
120
122
  return okhslColor(229, 0.03, 0.12);
121
- const { h } = colorToOkhsl(accent);
122
- return okhslColor(h, 0.12, 0.12);
123
+ const { h, s } = colorToOkhsl(accent);
124
+ return okhslColor(h, s < 0.001 ? 0 : 0.12, 0.12);
123
125
  }
124
126
  /** Resolves the selected canvas through the active accent and terminal color mode. */
125
127
  export function piCanvasBackgroundAnsi(style) {
@@ -142,7 +142,7 @@ export async function createPiRuntimeIntegration(options) {
142
142
  services,
143
143
  sessionManager: targetSessionManager,
144
144
  ...(options.worktreeCwdReader === undefined ? {} : {
145
- customTools: createSessionWorktreeTools(cwd, signal => options.worktreeCwdReader(targetSessionManager.getSessionId(), targetSessionManager.getSessionFile(), signal ?? options.signal ?? new AbortController().signal)).filter(tool => !services.resourceLoader.getExtensions().extensions.some(extension => extension.tools.has(tool.name))),
145
+ customTools: createSessionWorktreeTools(cwd, signal => options.worktreeCwdReader(targetSessionManager.getSessionId(), targetSessionManager.getSessionFile(), signal ?? options.signal ?? new AbortController().signal), options.worktreeCwdReader.settleShell === undefined ? undefined : signal => options.worktreeCwdReader.settleShell(targetSessionManager.getSessionId(), targetSessionManager.getSessionFile(), signal ?? options.signal ?? new AbortController().signal)).filter(tool => !services.resourceLoader.getExtensions().extensions.some(extension => extension.tools.has(tool.name))),
146
146
  }),
147
147
  ...(sessionStartEvent ? { sessionStartEvent } : {}),
148
148
  ...(modelScope.model && !hasExistingSession ? { model: modelScope.model } : {}),
@@ -1,4 +1,7 @@
1
1
  import { type ToolDefinition } from "../startup-public.js";
2
- export type PiWorktreeCwdReader = (sessionId: string, sessionFile: string, signal: AbortSignal) => Promise<string>;
2
+ export type PiWorktreeCwdReader = ((sessionId: string, sessionFile: string, signal: AbortSignal) => Promise<string>) & {
3
+ /** Settles work a finished shell command left for the runtime; the text is appended to that command's result. */
4
+ readonly settleShell?: (sessionId: string, sessionFile: string, signal: AbortSignal) => Promise<string | null>;
5
+ };
3
6
  /** Built-in tool definitions borrow the current attachment per call, without process-global chdir. */
4
- export declare function createSessionWorktreeTools(startupCwd: string, resolveCwd: (signal?: AbortSignal) => Promise<string>): ToolDefinition[];
7
+ export declare function createSessionWorktreeTools(startupCwd: string, resolveCwd: (signal?: AbortSignal) => Promise<string>, settleShell?: (signal?: AbortSignal) => Promise<string | null>): ToolDefinition[];
@@ -1,20 +1,35 @@
1
1
  import { createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, defineTool, } from "../startup-public.js";
2
2
  /** Built-in tool definitions borrow the current attachment per call, without process-global chdir. */
3
- export function createSessionWorktreeTools(startupCwd, resolveCwd) {
3
+ export function createSessionWorktreeTools(startupCwd, resolveCwd, settleShell) {
4
4
  function wrap(factory) {
5
5
  const definition = factory(startupCwd);
6
6
  return defineTool({
7
7
  ...definition,
8
8
  ...(definition.name === "bash" ? { promptGuidelines: [
9
9
  ...(definition.promptGuidelines ?? []),
10
- "For task worktrees, use a1 worktree enter <branch> or a1 worktree attach <absolute-path>; success confirms attachment and subsequent built-in tools use that root. a1 worktree detach preserves files. Coordination and recovery are internal; no follow-up inventory check is required.",
10
+ "For task worktrees, use a1 worktree enter <branch> or a1 worktree attach <absolute-path>; success confirms attachment and subsequent built-in tools use that root. a1 worktree detach preserves files. After a verified merge run a1 worktree complete instead of cleaning the attached checkout yourself; it returns this session to the startup checkout first and reports the repository cleanup result. Coordination and recovery are internal; no follow-up inventory check is required.",
11
11
  ] } : {}),
12
12
  defaultActive: false,
13
13
  async execute(...args) {
14
14
  const [id, params, signal, onUpdate, context] = args;
15
15
  const cwd = await resolveCwd(signal);
16
16
  signal?.throwIfAborted();
17
- return await factory(cwd).execute(id, params, signal, onUpdate, { ...context, cwd });
17
+ if (definition.name !== "bash" || settleShell === undefined)
18
+ return await factory(cwd).execute(id, params, signal, onUpdate, { ...context, cwd });
19
+ // Invariant: the shell has exited before settlement, so it no longer pins the checkout it was started in.
20
+ const settle = () => settleShell(signal).catch(error => `Worktree completion could not be settled: ${error instanceof Error ? error.message : String(error)}`);
21
+ let result;
22
+ try {
23
+ result = await factory(cwd).execute(id, params, signal, onUpdate, { ...context, cwd });
24
+ }
25
+ catch (error) {
26
+ const settled = await settle();
27
+ if (settled !== null && error instanceof Error)
28
+ error.message = `${error.message}\n\n${settled}`;
29
+ throw error;
30
+ }
31
+ const settled = await settle();
32
+ return settled === null ? result : { ...result, content: [...result.content, { type: "text", text: settled }] };
18
33
  },
19
34
  });
20
35
  }
@@ -9,6 +9,8 @@ export interface OwnedUiSettingDeclaration {
9
9
  };
10
10
  readonly description: string;
11
11
  readonly application: OwnedUiSettingApplication;
12
+ readonly storage?: "session";
13
+ readonly initialValueFrom?: string;
12
14
  readonly defaultValue: OwnedUiSettingValue;
13
15
  readonly allowedValues: readonly OwnedUiSettingValue[];
14
16
  /**
@@ -34,9 +36,23 @@ export declare const OWNED_SETTING_DECLARATIONS: Readonly<{
34
36
  defaultValue: "auto";
35
37
  allowedValues: readonly ["auto", "manual"];
36
38
  }>;
39
+ readonly defaultAccentColor: Readonly<{
40
+ id: "defaultAccentColor";
41
+ label: "Default color";
42
+ section: Readonly<{
43
+ id: "appearance";
44
+ title: "Appearance";
45
+ }>;
46
+ description: "Startup color for future sessions. Changing this does not recolor running sessions.";
47
+ application: "restart";
48
+ defaultValue: "blue";
49
+ allowedValues: readonly ["blue", "green", "orange", "pink", "purple", "cyan", "grey"];
50
+ }>;
37
51
  readonly accentColor: Readonly<{
38
52
  id: "accentColor";
39
- label: "Accent color";
53
+ label: "Current color";
54
+ storage: "session";
55
+ initialValueFrom: "defaultAccentColor";
40
56
  section: Readonly<{
41
57
  id: "appearance";
42
58
  title: "Appearance";
@@ -44,7 +60,7 @@ export declare const OWNED_SETTING_DECLARATIONS: Readonly<{
44
60
  description: "Color used by highlighted interface controls, list markers, and tonal interface surfaces.";
45
61
  application: "live";
46
62
  defaultValue: "blue";
47
- allowedValues: readonly ["blue", "purple", "cyan", "green", "orange", "pink"];
63
+ allowedValues: readonly ["blue", "green", "orange", "pink", "purple", "cyan", "grey"];
48
64
  }>;
49
65
  readonly backgroundStyle: Readonly<{
50
66
  id: "backgroundStyle";
@@ -1,6 +1,5 @@
1
- const UI_ACCENT_COLOR_CHOICES = Object.freeze([
2
- "blue", "purple", "cyan", "green", "orange", "pink",
3
- ]);
1
+ import { UI_ACCENT_COLORS } from "../../contracts/owned-ui/index.js";
2
+ const UI_ACCENT_COLOR_CHOICES = UI_ACCENT_COLORS;
4
3
  const MAX_ID_LENGTH = 64;
5
4
  const ID_PATTERN = /^[a-z][a-z0-9]*(?:[A-Z][a-z0-9]*)*$/;
6
5
  const GENERIC_SECTION = Object.freeze({ id: "generic", title: "Generic" });
@@ -17,9 +16,20 @@ export const OWNED_SETTING_DECLARATIONS = Object.freeze({
17
16
  defaultValue: "auto",
18
17
  allowedValues: Object.freeze(["auto", "manual"]),
19
18
  }),
19
+ defaultAccentColor: Object.freeze({
20
+ id: "defaultAccentColor",
21
+ label: "Default color",
22
+ section: APPEARANCE_SECTION,
23
+ description: "Startup color for future sessions. Changing this does not recolor running sessions.",
24
+ application: "restart",
25
+ defaultValue: "blue",
26
+ allowedValues: UI_ACCENT_COLOR_CHOICES,
27
+ }),
20
28
  accentColor: Object.freeze({
21
29
  id: "accentColor",
22
- label: "Accent color",
30
+ label: "Current color",
31
+ storage: "session",
32
+ initialValueFrom: "defaultAccentColor",
23
33
  section: APPEARANCE_SECTION,
24
34
  description: "Color used by highlighted interface controls, list markers, and tonal interface surfaces.",
25
35
  application: "live",
@@ -99,6 +99,22 @@ export declare function preTrustPalette(color: UiAccentColor, appearance: Appear
99
99
  accent: string;
100
100
  border: string;
101
101
  selectedBg: string;
102
+ } | {
103
+ accent: string;
104
+ border: string;
105
+ selectedBg: string;
106
+ } | {
107
+ accent: string;
108
+ border: string;
109
+ selectedBg: string;
110
+ } | {
111
+ accent: string;
112
+ border: string;
113
+ selectedBg: string;
114
+ } | {
115
+ accent: string;
116
+ border: string;
117
+ selectedBg: string;
102
118
  };
103
119
  export declare function preTrustTerminalAppearance(environment?: NodeJS.ProcessEnv): Appearance;
104
120
  export declare function preTrustTerminalColorMode(environment?: NodeJS.ProcessEnv): ColorMode;
@@ -21,9 +21,7 @@ export function readPreTrustAccent(environment = process.env) {
21
21
  const namespace = root[PRODUCT_IDENTITY.commandName];
22
22
  if (namespace === null || typeof namespace !== "object" || Array.isArray(namespace))
23
23
  return "blue";
24
- const value = namespace.accentColor;
25
- if (value === "default")
26
- return "purple";
24
+ const value = namespace.defaultAccentColor;
27
25
  return typeof value === "string" && Object.hasOwn(SHARED_PROGRESS_PALETTE, value) ? value : "blue";
28
26
  }
29
27
  catch {
@@ -30,8 +30,9 @@ export interface OwnedUiSettingsChangeOutcome {
30
30
  }
31
31
  export type OwnedUiSettingsListener = (manager: OwnedSettingsManager) => void;
32
32
  /**
33
- * One profile's A1 settings: resolves the declaration table against the stored document,
34
- * persists each accepted change atomically without discarding unknown keys, offers the
33
+ * Owns a profile's durable settings and this session's ephemeral overrides. Durable changes
34
+ * persist atomically without discarding unknown or legacy session keys; session changes stay local.
35
+ * Offers the
35
36
  * grouped sections a surface presents, and routes every accepted change only to its
36
37
  * owning backend, with agent settings reached through the engine settings port.
37
38
  */
@@ -42,6 +43,7 @@ export declare class OwnedSettingsManager {
42
43
  get resolution(): OwnedUiSettingsResolution;
43
44
  /** Reads and resolves the product namespace. Never throws: every failure becomes a notice. */
44
45
  read(): OwnedUiSettingsResolution;
46
+ startSession(): void;
45
47
  load(): Promise<void>;
46
48
  /**
47
49
  * Adopts values another process stored in the shared document. Notifies only when a
@@ -7,8 +7,9 @@ import { readProfileSettingsNamespace, repairProfileSettingsDocument, updateProf
7
7
  const PROFILE_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
8
8
  const MAX_DOCUMENT_BYTES = 256 * 1024;
9
9
  /**
10
- * One profile's A1 settings: resolves the declaration table against the stored document,
11
- * persists each accepted change atomically without discarding unknown keys, offers the
10
+ * Owns a profile's durable settings and this session's ephemeral overrides. Durable changes
11
+ * persist atomically without discarding unknown or legacy session keys; session changes stay local.
12
+ * Offers the
12
13
  * grouped sections a surface presents, and routes every accepted change only to its
13
14
  * owning backend, with agent settings reached through the engine settings port.
14
15
  */
@@ -26,6 +27,7 @@ export class OwnedSettingsManager {
26
27
  #agentSnapshot = null;
27
28
  #pending = new Map();
28
29
  #restartEffective = new Map();
30
+ #sessionValues = new Map();
29
31
  constructor(options) {
30
32
  if (!PROFILE_ID_PATTERN.test(options.profileId)) {
31
33
  throw new Error(`owned UI settings profile id is not a bounded slug: ${options.profileId}`);
@@ -42,6 +44,7 @@ export class OwnedSettingsManager {
42
44
  this.#hiddenAgentSettingIds = new Set(options.hiddenAgentSettingIds ?? []);
43
45
  this.#agentSettingLabelOverrides = { ...(options.agentSettingLabelOverrides ?? {}) };
44
46
  this.#resolution = this.read();
47
+ this.startSession();
45
48
  for (const setting of this.#resolution.settings) {
46
49
  if (setting.declaration.application === "restart")
47
50
  this.#restartEffective.set(setting.declaration.id, setting.value);
@@ -71,11 +74,31 @@ export class OwnedSettingsManager {
71
74
  return this.#resolveNamespace(null, shared.status === "invalid" ? shared.detail : undefined);
72
75
  }
73
76
  #resolveNamespace(values, unreadableDetail) {
74
- return resolveOwnedUiSettings({
77
+ const resolution = resolveOwnedUiSettings({
75
78
  declarations: this.#declarations,
76
79
  values,
77
80
  ...(unreadableDetail === undefined ? {} : { unreadableDetail }),
78
81
  });
82
+ return { ...resolution, settings: resolution.settings.map(setting => {
83
+ const value = this.#sessionValues.get(setting.declaration.id);
84
+ return setting.declaration.storage !== "session" || value === undefined ? setting : { ...setting, value };
85
+ }) };
86
+ }
87
+ startSession() {
88
+ this.#resolution = this.read();
89
+ for (const declaration of this.#declarations) {
90
+ if (declaration.storage !== "session")
91
+ continue;
92
+ const source = this.#resolution.settings.find(setting => setting.declaration.id === declaration.initialValueFrom);
93
+ const value = source !== undefined && declaration.allowedValues.includes(source.value) ? source.value : declaration.defaultValue;
94
+ this.#sessionValues.set(declaration.id, value);
95
+ if (source !== undefined && source.declaration.application === "restart") {
96
+ this.#restartEffective.set(source.declaration.id, source.value);
97
+ this.#pending.delete(source.declaration.id);
98
+ }
99
+ }
100
+ this.#resolution = this.read();
101
+ this.#notify();
79
102
  }
80
103
  async load() {
81
104
  this.#resolution = this.read();
@@ -159,6 +182,15 @@ export class OwnedSettingsManager {
159
182
  return backend === "a1" ? this.#changeOwned(id, value) : await this.#changeAgentValue(id, value);
160
183
  }
161
184
  #changeOwned(id, value) {
185
+ const declaration = this.#declarations.find(candidate => candidate.id === id);
186
+ if (!declaration?.allowedValues.includes(value))
187
+ return failed(`value ${JSON.stringify(value)} is not allowed for ${id}`);
188
+ if (declaration.storage === "session") {
189
+ this.#sessionValues.set(id, value);
190
+ this.#resolution = this.read();
191
+ this.#notify();
192
+ return changed("applied", "live", value, value);
193
+ }
162
194
  const outcome = this.#write(id, value);
163
195
  if (!outcome.stored)
164
196
  return failed(outcome.failure ?? `${id} could not be stored`);
@@ -285,7 +317,7 @@ function fromAgentOutcome(result) {
285
317
  function compactValues(resolution) {
286
318
  const values = { ...resolution.preserved };
287
319
  for (const setting of resolution.settings) {
288
- if (setting.value !== setting.declaration.defaultValue)
320
+ if (setting.declaration.storage !== "session" && setting.value !== setting.declaration.defaultValue)
289
321
  values[setting.declaration.id] = setting.value;
290
322
  }
291
323
  return values;
@@ -11,7 +11,7 @@ export function resolveOwnedUiSettings(input) {
11
11
  const values = input.values ?? {};
12
12
  const settings = [];
13
13
  for (const declaration of input.declarations) {
14
- if (!Object.hasOwn(values, declaration.id)) {
14
+ if (declaration.storage === "session" || !Object.hasOwn(values, declaration.id)) {
15
15
  settings.push({ declaration, value: declaration.defaultValue, source: "default" });
16
16
  continue;
17
17
  }
@@ -29,7 +29,8 @@ export function resolveOwnedUiSettings(input) {
29
29
  }
30
30
  const preserved = {};
31
31
  for (const [key, value] of Object.entries(values)) {
32
- if (findOwnedUiSettingDeclaration(input.declarations, key) === null)
32
+ const declaration = findOwnedUiSettingDeclaration(input.declarations, key);
33
+ if (declaration === null || declaration.storage === "session")
33
34
  preserved[key] = value;
34
35
  }
35
36
  return {
@@ -119,7 +119,7 @@ A1 owns creation/attachment, bounded retries, and ordinary recovery behind its w
119
119
 
120
120
  Claude Code uses its established `EnterWorktree` tool by task name. The tracked `.claude/settings.json` wires `scripts/governance/claude-worktree-create.mjs` to create `.worktrees/<task-id>` detached at fresh `origin/develop`, or return an already registered path. The agent must still establish ownership before reusing that path and create the topic branch in a new detached worktree before editing. Passing an absolute `path` to `EnterWorktree` is reserved for a worktree created by hand, from primary before any shell command in that session. The matching `WorktreeRemove` hook declines removal: leave with `ExitWorktree` `keep` and use only the guarded cleanup tool for removal. Other agents do not need these tools or hooks.
121
121
 
122
- A1 attachment updates footer repository/PR discovery and subsequent built-in tool working directories without changing process-global cwd. Detach/disposal releases the runtime's claim and preserves Git data. An existing PR is not required. Explicit absolute paths retain their meaning; custom extensions and independent terminals keep their own cwd. Attachment never creates a Git lock, changes cleanup registration, or grants acceptance, finalization, merge, or deletion authority.
122
+ A1 attachment updates footer repository/PR discovery and subsequent built-in tool working directories without changing process-global cwd. Detach/disposal releases the runtime's claim and preserves Git data. After a verified merge, `a1 worktree complete` detaches first and then runs the repository's configured cleanup from the startup checkout; an attachment whose checkout is gone is released automatically. An existing PR is not required. Explicit absolute paths retain their meaning; custom extensions and independent terminals keep their own cwd. Attachment never creates a Git lock, changes cleanup registration, or grants acceptance, finalization, merge, or deletion authority.
123
123
 
124
124
  An agent that directly chooses a transient file path for a PR/comment body, command payload, captured output, temporary patch/diff, or ad hoc log must place it beneath the verified task worktree's exact `.artifacts/` root, never the primary checkout, another worktree, OS temp, home/desktop, or a sibling path. Such scratch remains ignored, unstaged, uncommitted, disposable, non-authoritative, and secret-free. This repository path rule does not relocate storage internally selected by tools, product runtime code, or hermetic tests.
125
125
 
@@ -0,0 +1,66 @@
1
+ # Session colors and local API
2
+
3
+ Bare A1 offers two Settings → Appearance controls:
4
+
5
+ - **Default color** is saved in the active profile as `defaultAccentColor`. It initially resolves to blue. It controls the next session start, not the running interface.
6
+ - **Current color** applies immediately to this active session only. It is not saved to profile settings and does not propagate to other sessions on the same machine, profile, or project.
7
+
8
+ Both menus offer **blue → green → orange → pink → purple → cyan → grey**, with color previews. There is no automatic/dashed option. Grey keeps the derived semantic family and accent canvas neutral; unrelated success/warning/error colors are unchanged.
9
+
10
+ A fresh launch, resumed transcript, or successful new/resume/fork/import replacement initializes Current color from the latest saved Default color. Missing or invalid defaults resolve to blue. A failed replacement retains the old session and color. Changing the saved default does not recolor any running session. Cancel and undo respect each setting's storage scope.
11
+
12
+ The legacy profile `accentColor` key is retained but ignored by interactive color resolution. Current colors never overwrite it. The comparison `a1 pi` profile is unchanged. Standalone installation/update progress keeps its existing saved-profile preference policy.
13
+
14
+ ## Integration commands
15
+
16
+ External applications such as Herdr can invoke the installed CLI as a subprocess. These commands always return JSON without a `--json` switch:
17
+
18
+ | Arguments after `a1` | Result |
19
+ | --- | --- |
20
+ | `session color list` | Available color identifiers in display order; no running session needed |
21
+ | `session color get --session <id>` | One live session's Current color |
22
+ | `session color set pink --session <id>` | Set only that session's Current color and acknowledge the applied value |
23
+
24
+ The selector is a **complete session UUID**, shown by A1's Session Info workflow. It is not a PID, terminal index, abbreviated prefix, transcript path, or machine-wide current session. Get/set require one explicitly targeted live bare-A1 session outside an A1 shell. Within a shell launched by that exact session, omission uses the existing `PI_SESSION_ID`, `PI_SESSION_FILE`, and A1 runtime identity together; it never selects a different session implicitly. Keep the runtime-directory environment consistent when controlling a custom runtime root.
25
+
26
+ Get/set do not change Default color. Configure the startup preference in Settings. An API write immediately updates the Current color value visible there, along with semantic colors and accent-derived backgrounds.
27
+
28
+ ### Responses
29
+
30
+ Inventory returns only the color list, not a hard-coded default that could be confused with the configured startup preference:
31
+
32
+ ```json
33
+ {"colors":["blue","green","orange","pink","purple","cyan","grey"]}
34
+ ```
35
+
36
+ Get and successful set use the same shape:
37
+
38
+ ```json
39
+ {"sessionId":"57e38e80-f348-4b28-b791-7cc080886a70","color":"pink"}
40
+ ```
41
+
42
+ Failures return:
43
+
44
+ ```json
45
+ {"error":{"code":"session-unavailable","message":"Select one running bare session by its complete session id."}}
46
+ ```
47
+
48
+ Exactly one newline-terminated JSON object is written to stdout, without progress or human-readable prefixes. Error messages are explanatory text and can evolve; applications should branch on `error.code`. Exit statuses:
49
+
50
+ - **0:** successful list/get/applied set.
51
+ - **2:** invalid arguments, color, session format, or missing required selector.
52
+ - **1:** unavailable/ambiguous target, timeout, or control failure.
53
+
54
+ Error codes include `invalid-request`, `invalid-color`, `invalid-session`, `session-required`, `session-unavailable`, `ambiguous-session`, `request-expired`, `control-boundary`, `control-limit`, and `control-failed`. Hosts without a color command adapter return `control-unavailable`. Ordinary `session color help` and global help remain human-readable. CLI capability metadata advertises `sessionColors: true`.
55
+
56
+ ### Herdr integration
57
+
58
+ Invoke `a1` with an argument array rather than interpolating a shell string. For example, pass `["session", "color", "set", "pink", "--session", sessionId]`, capture stdout, await process completion, and parse its one JSON object. On exit 0, update Herdr's local UI from the returned `sessionId` and `color`. On nonzero exit, display or handle the structured error; do not treat a queued write or process creation as successful application. No subscription/event feed or host-app icon recoloring is provided.
59
+
60
+ ## Lifetime and safety
61
+
62
+ Control starts alongside the interactive shell and stops with it. It uses an in-process local IPC endpoint: a Windows named pipe or Unix-domain socket, with bounded private discovery records under the runtime directory. It does not introduce a separate resident service, TCP port, or HTTP server. Comparison-profile sessions do not publish endpoints.
63
+
64
+ Requests have a three-second end-to-end budget, bounded payloads, process-liveness checks, runtime identity, a private endpoint nonce, session binding checks, and correlated acknowledgements. Concurrent requests serialize in the target runtime. Set reports success only after its live settings change path applies the color. Requests still queued after expiry, disconnect, stop, or session replacement cannot recolor a later session. A timeout means application was not acknowledged: a change already applied just before a lost response is not rolled back; get the current value before deciding whether to retry. No delayed request is persisted for future replay.
65
+
66
+ Private runtime records are not a public file-editing API. Do not write settings files or edit endpoint records to control Current color. Unverifiable, ambiguous, replaced, stopped, malformed, and comparison-profile targets fail closed without choosing another session. An unavailable control directory or failed endpoint startup leaves the interactive session usable, but external control reports unavailable. Worktree attachment authority is unaffected.