chainlesschain 0.45.10 → 0.45.11

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.
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chainlesschain",
3
- "version": "0.45.10",
3
+ "version": "0.45.11",
4
4
  "description": "CLI for ChainlessChain - install, configure, and manage your personal AI management system",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@ import {
8
8
  } from "../lib/config-manager.js";
9
9
  import { getConfigPath } from "../lib/paths.js";
10
10
  import logger from "../lib/logger.js";
11
+ import { listFeatures, setFeature, getFlagInfo } from "../lib/feature-flags.js";
11
12
 
12
13
  export function registerConfigCommand(program) {
13
14
  const cmd = program
@@ -69,6 +70,49 @@ export function registerConfigCommand(program) {
69
70
  }
70
71
  });
71
72
 
73
+ // ── Feature Flags ──────────────────────────────────────────────────
74
+
75
+ const featuresCmd = cmd
76
+ .command("features")
77
+ .description("Manage feature flags");
78
+
79
+ featuresCmd
80
+ .command("list")
81
+ .alias("ls")
82
+ .description("Show all feature flags and their status")
83
+ .action(() => {
84
+ const flags = listFeatures();
85
+ logger.log(chalk.bold("\n Feature Flags\n"));
86
+ for (const f of flags) {
87
+ const status = f.enabled ? chalk.green("● ON ") : chalk.gray("○ OFF");
88
+ const src = chalk.gray(`[${f.source}]`);
89
+ logger.log(` ${status} ${chalk.cyan(f.name)} ${src}`);
90
+ logger.log(` ${chalk.gray(f.description)}`);
91
+ }
92
+ logger.newline();
93
+ });
94
+
95
+ featuresCmd
96
+ .command("enable")
97
+ .description("Enable a feature flag")
98
+ .argument("<name>", "Flag name (e.g. CONTEXT_SNIP)")
99
+ .action((name) => {
100
+ setFeature(name, true);
101
+ const info = getFlagInfo(name);
102
+ logger.success(`Enabled ${name}${info ? ` — ${info.description}` : ""}`);
103
+ });
104
+
105
+ featuresCmd
106
+ .command("disable")
107
+ .description("Disable a feature flag")
108
+ .argument("<name>", "Flag name (e.g. CONTEXT_SNIP)")
109
+ .action((name) => {
110
+ setFeature(name, false);
111
+ logger.success(`Disabled ${name}`);
112
+ });
113
+
114
+ // ── Reset ──────────────────────────────────────────────────────────
115
+
72
116
  cmd
73
117
  .command("reset")
74
118
  .description("Reset configuration to defaults")
package/src/constants.js CHANGED
@@ -135,6 +135,7 @@ export const DEFAULT_CONFIG = {
135
135
  setupCompleted: false,
136
136
  completedAt: null,
137
137
  edition: "personal",
138
+ features: {},
138
139
  paths: {
139
140
  projectRoot: null,
140
141
  database: null,
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Background Task Manager — daemon task queue with completion notifications.
3
+ *
4
+ * Tasks run in child_process.fork() for isolation.
5
+ * Queue persisted to .chainlesschain/tasks/queue.jsonl.
6
+ * Completion notifications delivered to REPL callback.
7
+ *
8
+ * Feature-flag gated: BACKGROUND_TASKS
9
+ */
10
+
11
+ import { fork } from "node:child_process";
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ appendFileSync,
16
+ readFileSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { createHash } from "node:crypto";
21
+ import { EventEmitter } from "node:events";
22
+ import { getHomeDir } from "./paths.js";
23
+
24
+ function getTasksDir() {
25
+ const dir = join(getHomeDir(), "tasks");
26
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
27
+ return dir;
28
+ }
29
+
30
+ function queuePath() {
31
+ return join(getTasksDir(), "queue.jsonl");
32
+ }
33
+
34
+ // ── Task Status ─────────────────────────────────────────────────────────
35
+
36
+ export const TaskStatus = {
37
+ PENDING: "pending",
38
+ RUNNING: "running",
39
+ COMPLETED: "completed",
40
+ FAILED: "failed",
41
+ TIMEOUT: "timeout",
42
+ };
43
+
44
+ // ── BackgroundTaskManager ───────────────────────────────────────────────
45
+
46
+ export class BackgroundTaskManager extends EventEmitter {
47
+ /**
48
+ * @param {object} options
49
+ * @param {number} [options.maxConcurrent=3] — Max parallel background tasks
50
+ * @param {number} [options.heartbeatTimeout=60000] — Task heartbeat timeout (ms)
51
+ */
52
+ constructor(options = {}) {
53
+ super();
54
+ this.maxConcurrent = options.maxConcurrent || 3;
55
+ this.heartbeatTimeout = options.heartbeatTimeout || 60000;
56
+ this.tasks = new Map(); // id -> task object
57
+ this.processes = new Map(); // id -> child process
58
+ this._checkInterval = null;
59
+ }
60
+
61
+ /**
62
+ * Create and queue a new background task.
63
+ * @param {object} spec
64
+ * @param {string} spec.type — Task type (e.g. "shell", "agent", "script")
65
+ * @param {string} spec.command — Command or script to execute
66
+ * @param {string} [spec.cwd] — Working directory
67
+ * @param {string} [spec.description] — Human-readable description
68
+ * @returns {object} Created task
69
+ */
70
+ create(spec) {
71
+ if (this._runningCount() >= this.maxConcurrent) {
72
+ throw new Error(
73
+ `Max concurrent tasks reached (${this.maxConcurrent}). Wait for a task to finish.`,
74
+ );
75
+ }
76
+
77
+ const id = `task-${Date.now()}-${createHash("sha256").update(Math.random().toString()).digest("hex").slice(0, 6)}`;
78
+
79
+ const task = {
80
+ id,
81
+ type: spec.type || "shell",
82
+ command: spec.command,
83
+ cwd: spec.cwd || process.cwd(),
84
+ description: spec.description || spec.command,
85
+ status: TaskStatus.PENDING,
86
+ createdAt: Date.now(),
87
+ startedAt: null,
88
+ completedAt: null,
89
+ lastHeartbeat: null,
90
+ result: null,
91
+ error: null,
92
+ };
93
+
94
+ this.tasks.set(id, task);
95
+ this._persistTask(task);
96
+ return task;
97
+ }
98
+
99
+ /**
100
+ * Start a pending task (runs in child process).
101
+ */
102
+ start(taskId) {
103
+ const task = this.tasks.get(taskId);
104
+ if (!task) throw new Error(`Task not found: ${taskId}`);
105
+ if (task.status !== TaskStatus.PENDING)
106
+ throw new Error(`Task ${taskId} is not pending (status: ${task.status})`);
107
+
108
+ task.status = TaskStatus.RUNNING;
109
+ task.startedAt = Date.now();
110
+ task.lastHeartbeat = Date.now();
111
+
112
+ // Create a wrapper script that executes the command
113
+ const child = fork(
114
+ join(import.meta.dirname || ".", "background-task-worker.js"),
115
+ [task.command, task.cwd, task.type],
116
+ {
117
+ cwd: task.cwd,
118
+ silent: true,
119
+ env: { ...process.env, CC_TASK_ID: taskId },
120
+ },
121
+ );
122
+
123
+ this.processes.set(taskId, child);
124
+
125
+ child.on("message", (msg) => {
126
+ if (msg.type === "heartbeat") {
127
+ task.lastHeartbeat = Date.now();
128
+ } else if (msg.type === "result") {
129
+ this._complete(taskId, TaskStatus.COMPLETED, msg.data, null);
130
+ } else if (msg.type === "error") {
131
+ this._complete(taskId, TaskStatus.FAILED, null, msg.error);
132
+ }
133
+ });
134
+
135
+ child.on("exit", (code) => {
136
+ if (task.status === TaskStatus.RUNNING) {
137
+ if (code === 0) {
138
+ this._complete(
139
+ taskId,
140
+ TaskStatus.COMPLETED,
141
+ "Process exited (0)",
142
+ null,
143
+ );
144
+ } else {
145
+ this._complete(
146
+ taskId,
147
+ TaskStatus.FAILED,
148
+ null,
149
+ `Process exited with code ${code}`,
150
+ );
151
+ }
152
+ }
153
+ this.processes.delete(taskId);
154
+ });
155
+
156
+ child.on("error", (err) => {
157
+ this._complete(taskId, TaskStatus.FAILED, null, err.message);
158
+ this.processes.delete(taskId);
159
+ });
160
+
161
+ this._persistTask(task);
162
+ this._ensureHeartbeatChecker();
163
+ return task;
164
+ }
165
+
166
+ /**
167
+ * Create and immediately start a task.
168
+ */
169
+ run(spec) {
170
+ const task = this.create(spec);
171
+ this.start(task.id);
172
+ return task;
173
+ }
174
+
175
+ /**
176
+ * Get a task by ID.
177
+ */
178
+ get(taskId) {
179
+ return this.tasks.get(taskId) || null;
180
+ }
181
+
182
+ /**
183
+ * List all tasks.
184
+ */
185
+ list(filter = {}) {
186
+ let tasks = [...this.tasks.values()];
187
+ if (filter.status) {
188
+ tasks = tasks.filter((t) => t.status === filter.status);
189
+ }
190
+ return tasks.sort((a, b) => b.createdAt - a.createdAt);
191
+ }
192
+
193
+ /**
194
+ * Stop a running task.
195
+ */
196
+ stop(taskId) {
197
+ const child = this.processes.get(taskId);
198
+ if (child) {
199
+ child.kill("SIGTERM");
200
+ setTimeout(() => {
201
+ if (child.exitCode === null) child.kill("SIGKILL");
202
+ }, 2000);
203
+ }
204
+ this._complete(taskId, TaskStatus.FAILED, null, "Stopped by user");
205
+ }
206
+
207
+ /**
208
+ * Clean up completed/failed tasks older than maxAge.
209
+ * @param {number} [maxAge=3600000] — Max age in ms (default 1 hour)
210
+ */
211
+ cleanup(maxAge = 3600000) {
212
+ const cutoff = Date.now() - maxAge;
213
+ let removed = 0;
214
+ for (const [id, task] of this.tasks) {
215
+ if (
216
+ (task.status === TaskStatus.COMPLETED ||
217
+ task.status === TaskStatus.FAILED ||
218
+ task.status === TaskStatus.TIMEOUT) &&
219
+ task.completedAt &&
220
+ task.completedAt < cutoff
221
+ ) {
222
+ this.tasks.delete(id);
223
+ removed++;
224
+ }
225
+ }
226
+ return removed;
227
+ }
228
+
229
+ /**
230
+ * Destroy the manager (kill all running tasks, clear intervals).
231
+ */
232
+ destroy() {
233
+ if (this._checkInterval) {
234
+ clearInterval(this._checkInterval);
235
+ this._checkInterval = null;
236
+ }
237
+ for (const [id] of this.processes) {
238
+ this.stop(id);
239
+ }
240
+ this.tasks.clear();
241
+ this.processes.clear();
242
+ }
243
+
244
+ // ── Internal ──────────────────────────────────────────────────────
245
+
246
+ _complete(taskId, status, result, error) {
247
+ const task = this.tasks.get(taskId);
248
+ if (!task) return;
249
+
250
+ task.status = status;
251
+ task.completedAt = Date.now();
252
+ task.result = result;
253
+ task.error = error;
254
+
255
+ this._persistTask(task);
256
+ this.emit("task:complete", task);
257
+ }
258
+
259
+ _runningCount() {
260
+ let count = 0;
261
+ for (const task of this.tasks.values()) {
262
+ if (task.status === TaskStatus.RUNNING) count++;
263
+ }
264
+ return count;
265
+ }
266
+
267
+ _persistTask(task) {
268
+ try {
269
+ const line = JSON.stringify(task) + "\n";
270
+ appendFileSync(queuePath(), line, "utf-8");
271
+ } catch (_e) {
272
+ // Non-critical
273
+ }
274
+ }
275
+
276
+ _ensureHeartbeatChecker() {
277
+ if (this._checkInterval) return;
278
+
279
+ this._checkInterval = setInterval(
280
+ () => {
281
+ const now = Date.now();
282
+ for (const [id, task] of this.tasks) {
283
+ if (
284
+ task.status === TaskStatus.RUNNING &&
285
+ task.lastHeartbeat &&
286
+ now - task.lastHeartbeat > this.heartbeatTimeout
287
+ ) {
288
+ this._complete(id, TaskStatus.TIMEOUT, null, "Heartbeat timeout");
289
+ const child = this.processes.get(id);
290
+ if (child) {
291
+ child.kill("SIGKILL");
292
+ this.processes.delete(id);
293
+ }
294
+ }
295
+ }
296
+ },
297
+ Math.min(this.heartbeatTimeout / 2, 10000),
298
+ );
299
+
300
+ // Don't keep process alive for the checker
301
+ if (this._checkInterval.unref) {
302
+ this._checkInterval.unref();
303
+ }
304
+ }
305
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Background Task Worker — child process that executes a command.
3
+ *
4
+ * Args: [command, cwd, type]
5
+ * Sends messages to parent: { type: "heartbeat"|"result"|"error", ... }
6
+ */
7
+
8
+ import { execSync } from "node:child_process";
9
+
10
+ const [command, cwd, type] = process.argv.slice(2);
11
+
12
+ // Heartbeat every 5 seconds
13
+ const heartbeat = setInterval(() => {
14
+ if (process.send) process.send({ type: "heartbeat" });
15
+ }, 5000);
16
+
17
+ try {
18
+ let result;
19
+
20
+ if (type === "shell") {
21
+ result = execSync(command, {
22
+ cwd: cwd || process.cwd(),
23
+ encoding: "utf-8",
24
+ timeout: 300000, // 5 min max
25
+ maxBuffer: 10 * 1024 * 1024,
26
+ });
27
+ } else {
28
+ // Default: treat as shell
29
+ result = execSync(command, {
30
+ cwd: cwd || process.cwd(),
31
+ encoding: "utf-8",
32
+ timeout: 300000,
33
+ maxBuffer: 10 * 1024 * 1024,
34
+ });
35
+ }
36
+
37
+ if (process.send) {
38
+ process.send({ type: "result", data: result || "Done" });
39
+ }
40
+ } catch (err) {
41
+ if (process.send) {
42
+ process.send({
43
+ type: "error",
44
+ error: err.stderr || err.message || String(err),
45
+ });
46
+ }
47
+ process.exitCode = 1;
48
+ } finally {
49
+ clearInterval(heartbeat);
50
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Feature Flag System — runtime feature gating for gradual rollout.
3
+ *
4
+ * Flags are stored in .chainlesschain/config.json under "features" key.
5
+ * Each flag can be:
6
+ * - boolean (true/false)
7
+ * - number 0-100 (percentage rollout, hashed by machine-id)
8
+ * - object { enabled, variant, description }
9
+ *
10
+ * Usage:
11
+ * import { feature, featureVariant, listFeatures } from "./feature-flags.js";
12
+ * if (feature("CONTEXT_SNIP")) { ... }
13
+ */
14
+
15
+ import { loadConfig, getConfigValue, saveConfig } from "./config-manager.js";
16
+ import { createHash } from "node:crypto";
17
+ import { hostname } from "node:os";
18
+
19
+ // ── Flag Registry (source of truth for known flags) ────────────────────
20
+
21
+ const FLAG_REGISTRY = {
22
+ BACKGROUND_TASKS: {
23
+ description: "Enable background task queue with daemon execution",
24
+ default: false,
25
+ },
26
+ WORKTREE_ISOLATION: {
27
+ description: "Enable git worktree isolation for agent tasks",
28
+ default: false,
29
+ },
30
+ CONTEXT_SNIP: {
31
+ description: "Enable snipCompact strategy in context compression",
32
+ default: false,
33
+ },
34
+ CONTEXT_COLLAPSE: {
35
+ description: "Enable contextCollapse strategy in context compression",
36
+ default: false,
37
+ },
38
+ JSONL_SESSION: {
39
+ description: "Use JSONL append-only format for session persistence",
40
+ default: false,
41
+ },
42
+ PROMPT_COMPRESSOR: {
43
+ description: "Enable CLI prompt compressor (auto/snip/collapse)",
44
+ default: true,
45
+ },
46
+ };
47
+
48
+ // ── Core API ────────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Check if a feature flag is enabled.
52
+ * @param {string} name - Flag name (e.g. "CONTEXT_SNIP")
53
+ * @returns {boolean}
54
+ */
55
+ export function feature(name) {
56
+ const value = _resolve(name);
57
+ if (typeof value === "boolean") return value;
58
+ if (typeof value === "number") return _percentageCheck(name, value);
59
+ if (value && typeof value === "object") return Boolean(value.enabled);
60
+ return _getDefault(name);
61
+ }
62
+
63
+ /**
64
+ * Get the variant string for a feature (for A/B style flags).
65
+ * @param {string} name
66
+ * @returns {string|null}
67
+ */
68
+ export function featureVariant(name) {
69
+ const value = _resolve(name);
70
+ if (value && typeof value === "object" && value.variant) {
71
+ return value.variant;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * List all known feature flags with their current values.
78
+ * @returns {Array<{name, enabled, description, source, raw}>}
79
+ */
80
+ export function listFeatures() {
81
+ const config = loadConfig();
82
+ const features = config.features || {};
83
+ const result = [];
84
+
85
+ for (const [name, meta] of Object.entries(FLAG_REGISTRY)) {
86
+ const raw = features[name];
87
+ const enabled = feature(name);
88
+ const source =
89
+ raw !== undefined
90
+ ? "config"
91
+ : process.env[`CC_FLAG_${name}`] !== undefined
92
+ ? "env"
93
+ : "default";
94
+
95
+ result.push({
96
+ name,
97
+ enabled,
98
+ description: meta.description,
99
+ source,
100
+ raw: raw !== undefined ? raw : meta.default,
101
+ });
102
+ }
103
+
104
+ // Include unknown flags from config (user-defined)
105
+ for (const [name, raw] of Object.entries(features)) {
106
+ if (!FLAG_REGISTRY[name]) {
107
+ result.push({
108
+ name,
109
+ enabled: Boolean(raw),
110
+ description: "(user-defined)",
111
+ source: "config",
112
+ raw,
113
+ });
114
+ }
115
+ }
116
+
117
+ return result;
118
+ }
119
+
120
+ /**
121
+ * Set a feature flag value in config.
122
+ * @param {string} name
123
+ * @param {boolean|number|string} value
124
+ */
125
+ export function setFeature(name, value) {
126
+ const config = loadConfig();
127
+ if (!config.features) config.features = {};
128
+ config.features[name] = value;
129
+ saveConfig(config);
130
+ }
131
+
132
+ /**
133
+ * Get the registry entry for a known flag (or null).
134
+ * @param {string} name
135
+ * @returns {{description: string, default: boolean}|null}
136
+ */
137
+ export function getFlagInfo(name) {
138
+ return FLAG_REGISTRY[name] || null;
139
+ }
140
+
141
+ // ── Internal helpers ───────────────────────────────���────────────────────
142
+
143
+ function _resolve(name) {
144
+ // Priority: env var > config > default
145
+ const envKey = `CC_FLAG_${name}`;
146
+ if (process.env[envKey] !== undefined) {
147
+ const envVal = process.env[envKey];
148
+ if (envVal === "true" || envVal === "1") return true;
149
+ if (envVal === "false" || envVal === "0") return false;
150
+ const num = Number(envVal);
151
+ if (!isNaN(num)) return num;
152
+ return envVal;
153
+ }
154
+
155
+ const configVal = getConfigValue(`features.${name}`);
156
+ if (configVal !== undefined) return configVal;
157
+
158
+ return undefined; // let caller fall to default
159
+ }
160
+
161
+ function _getDefault(name) {
162
+ const meta = FLAG_REGISTRY[name];
163
+ return meta ? meta.default : false;
164
+ }
165
+
166
+ function _percentageCheck(name, percentage) {
167
+ if (percentage <= 0) return false;
168
+ if (percentage >= 100) return true;
169
+ const hash = createHash("md5")
170
+ .update(`${name}:${_machineId()}`)
171
+ .digest("hex");
172
+ const bucket = parseInt(hash.slice(0, 8), 16) % 100;
173
+ return bucket < percentage;
174
+ }
175
+
176
+ let _cachedMachineId = null;
177
+ function _machineId() {
178
+ if (!_cachedMachineId) {
179
+ _cachedMachineId = hostname() || "unknown";
180
+ }
181
+ return _cachedMachineId;
182
+ }