jules-orchestrator-kit 0.22.8 → 0.23.0

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/agentctl.mjs CHANGED
@@ -14,7 +14,7 @@ const command = args[0];
14
14
 
15
15
  function printHelp() {
16
16
  console.log(`
17
- šŸš€ agentctl v0.22.8 — Universal Agent Orchestrator & Safety Gatekeeper
17
+ šŸš€ agentctl v0.23.0 — Universal Agent Orchestrator & Safety Gatekeeper
18
18
 
19
19
  Usage: agentctl <command> [options]
20
20
 
@@ -44,7 +44,7 @@ async function main() {
44
44
  }
45
45
 
46
46
  if (command === "version" || command === "--version" || command === "-v") {
47
- console.log("agentctl v0.22.8");
47
+ console.log("agentctl v0.22.9");
48
48
  process.exit(0);
49
49
  }
50
50
 
@@ -226,7 +226,7 @@ async function main() {
226
226
  }
227
227
 
228
228
  case "doctor": {
229
- console.log(`\nšŸ” agentctl System Diagnostics (v0.22.8)`);
229
+ console.log(`\nšŸ” agentctl System Diagnostics (v0.22.9)`);
230
230
  console.log(`--------------------------------------------------`);
231
231
  console.log(` Project Root : ${root}`);
232
232
  console.log(` Config File : ${config._file || "None (Using defaults)"}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.22.8",
3
+ "version": "0.23.0",
4
4
  "description": "Orchestration kit for running Google Jules autonomous agents.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFileSync, existsSync } from "node:fs";
3
+ import { appendTelemetry } from "./telemetry.mjs";
3
4
 
4
5
  /**
5
6
  * Custom error thrown when a circular dependency is detected in the DAG.
@@ -14,23 +15,50 @@ export class DagCycleError extends Error {
14
15
  }
15
16
 
16
17
  /**
17
- * Zero-Dependency Task DAG Executor with cycle detection and interface fingerprinting.
18
+ * Wraps an async runner function with a timeout (default 15 minutes = 900,000 ms).
19
+ * @param {Function} runner
20
+ * @param {number} [timeoutMs=900000]
21
+ * @returns {Promise<any>}
22
+ */
23
+ export async function withTaskTimeout(runner, timeoutMs = 15 * 60 * 1000) {
24
+ let timer;
25
+ const timeoutPromise = new Promise((_, reject) => {
26
+ timer = setTimeout(() => {
27
+ reject(new Error(`Task execution timed out after ${timeoutMs}ms`));
28
+ }, timeoutMs);
29
+ });
30
+
31
+ try {
32
+ return await Promise.race([runner(), timeoutPromise]);
33
+ } finally {
34
+ if (timer) clearTimeout(timer);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Zero-Dependency Task DAG Executor with cycle detection, interface fingerprinting, and execution freezing.
18
40
  */
19
41
  export class DagExecutor {
20
- constructor({ concurrency = 4 } = {}) {
42
+ constructor({ concurrency = 4, taskTimeout = 15 * 60 * 1000 } = {}) {
21
43
  this.concurrency = concurrency;
44
+ this.taskTimeout = taskTimeout;
22
45
  this.tasks = new Map();
46
+ this.isExecuting = false;
23
47
  }
24
48
 
25
49
  /**
26
- * Registers a task into the DAG.
50
+ * Registers a task into the DAG. Throws if called after execution has started.
27
51
  * @param {Object} task
28
52
  * @param {string} task.id
29
53
  * @param {string[]} [task.dependsOn=[]]
30
54
  * @param {string[]} [task.outputs=[]]
31
55
  * @param {Function} [task.runner]
56
+ * @param {number} [task.timeout]
32
57
  */
33
- addTask({ id, dependsOn = [], outputs = [], runner }) {
58
+ addTask({ id, dependsOn = [], outputs = [], runner, timeout }) {
59
+ if (this.isExecuting) {
60
+ throw new Error("Cannot add task after execution has started");
61
+ }
34
62
  if (!id || typeof id !== "string") {
35
63
  throw new Error("Task id must be a non-empty string");
36
64
  }
@@ -42,6 +70,7 @@ export class DagExecutor {
42
70
  dependsOn: Array.isArray(dependsOn) ? [...dependsOn] : [],
43
71
  outputs: Array.isArray(outputs) ? [...outputs] : [],
44
72
  runner: runner || (async () => {}),
73
+ timeout,
45
74
  });
46
75
  }
47
76
 
@@ -171,8 +200,19 @@ export class DagExecutor {
171
200
  * Executes tasks in topological order with parallel concurrency and interface fingerprinting.
172
201
  * @param {Object} [options]
173
202
  * @param {number} [options.concurrency]
203
+ * @param {number} [options.taskTimeout]
204
+ * @param {string} [options.root]
205
+ * @param {Object} [options.progressBus]
206
+ * @param {string} [options.progressToken]
174
207
  */
175
- async execute({ concurrency = this.concurrency } = {}) {
208
+ async execute({
209
+ concurrency = this.concurrency,
210
+ taskTimeout = this.taskTimeout,
211
+ root,
212
+ progressBus,
213
+ progressToken,
214
+ } = {}) {
215
+ this.isExecuting = true;
176
216
  const { dependents } = this.validateDag();
177
217
 
178
218
  const completed = new Set();
@@ -221,7 +261,7 @@ export class DagExecutor {
221
261
  for (const depId of task.dependsOn) {
222
262
  const depTask = this.tasks.get(depId);
223
263
  for (const outputFile of depTask.outputs) {
224
- const expectedHash = fingerprints.get(outputFile);
264
+ const expectedHash = fingerprints.get(`${depId}:${outputFile}`) ?? fingerprints.get(outputFile);
225
265
  const actualHash = this._computeFileHash(outputFile);
226
266
  if (expectedHash !== actualHash) {
227
267
  throw new Error(
@@ -235,26 +275,44 @@ export class DagExecutor {
235
275
  }
236
276
 
237
277
  running.add(taskId);
278
+ if (root) appendTelemetry(root, "dag_task_started", { taskId });
238
279
 
239
280
  (async () => {
240
281
  try {
241
282
  executionHistory.push(taskId);
242
- await task.runner({
243
- id: task.id,
244
- dependsOn: task.dependsOn,
245
- outputs: task.outputs,
246
- fingerprints,
247
- });
248
-
249
- // Post-task Interface Fingerprinting
283
+ const timeoutMs = task.timeout || taskTimeout;
284
+ await withTaskTimeout(
285
+ () =>
286
+ task.runner({
287
+ id: task.id,
288
+ dependsOn: task.dependsOn,
289
+ outputs: task.outputs,
290
+ fingerprints,
291
+ }),
292
+ timeoutMs
293
+ );
294
+
295
+ // Post-task Interface Fingerprinting (keyed by taskId:filePath and filePath alias)
250
296
  for (const outputFile of task.outputs) {
251
297
  const hash = this._computeFileHash(outputFile);
298
+ fingerprints.set(`${taskId}:${outputFile}`, hash);
252
299
  fingerprints.set(outputFile, hash);
253
300
  }
254
301
 
255
302
  running.delete(taskId);
256
303
  completed.add(taskId);
257
304
 
305
+ if (root) appendTelemetry(root, "dag_task_completed", { taskId });
306
+ if (progressBus && progressToken) {
307
+ const pct = Math.round((completed.size / this.tasks.size) * 100);
308
+ progressBus.reportProgress(
309
+ progressToken,
310
+ pct,
311
+ 100,
312
+ `Task ${taskId} completed (${completed.size}/${this.tasks.size})`
313
+ );
314
+ }
315
+
258
316
  for (const depNode of dependents.get(taskId)) {
259
317
  inDegree.set(depNode, inDegree.get(depNode) - 1);
260
318
  }
package/src/engine.mjs CHANGED
@@ -8,8 +8,9 @@ import { recordVerifyRun, readVerifyRuns, flakyVerdict } from "./flaky-ledger.mj
8
8
  import { readdirSync, readFileSync, renameSync, existsSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { createHash } from "node:crypto";
11
+ import { appendTelemetry } from "./telemetry.mjs";
11
12
 
12
- export { recordVerifyRun, readVerifyRuns, flakyVerdict };
13
+ export { recordVerifyRun, readVerifyRuns, flakyVerdict, sanitizeUntrustedData };
13
14
 
14
15
  /**
15
16
  * Validates whether a file in .agent/jules-queue/ is a task file.
@@ -100,8 +101,12 @@ export async function gate(opts = {}) {
100
101
  const root = opts.root || process.cwd();
101
102
  const config = opts.config || loadConfig(root);
102
103
  const base = opts.base || config.baseBranch || "main";
104
+ const progressBus = opts.progressBus;
105
+ const progressToken = opts.progressToken;
103
106
  const phases = [];
104
107
 
108
+ appendTelemetry(root, "gate_started", { base });
109
+
105
110
  let files = [];
106
111
  let bytes = 0;
107
112
  let diffStr = "";
@@ -112,6 +117,7 @@ export async function gate(opts = {}) {
112
117
  diffStr = diffText(root, base);
113
118
  } catch (err) {
114
119
  phases.push({ phase: "git_resolution", ok: false, error: err.message });
120
+ appendTelemetry(root, "gate_finished", { ok: false, code: 1, error: err.message });
115
121
  return { ok: false, code: 1, phases, error: err.message };
116
122
  }
117
123
 
@@ -142,7 +148,13 @@ export async function gate(opts = {}) {
142
148
  });
143
149
 
144
150
  phases.push({ phase: "scope", ok: scopeResult.ok, violations: scopeResult.violations });
151
+ appendTelemetry(root, "gate_phase", { phase: "scope", ok: scopeResult.ok });
152
+ if (progressBus && progressToken) {
153
+ progressBus.reportProgress(progressToken, 25, 100, "Phase 1/4: Scope Guard verification complete");
154
+ }
155
+
145
156
  if (!scopeResult.ok) {
157
+ appendTelemetry(root, "gate_finished", { ok: false, code: 3 });
146
158
  return { ok: false, code: 3, phases };
147
159
  }
148
160
 
@@ -150,14 +162,26 @@ export async function gate(opts = {}) {
150
162
  const limitBytes = (config.limits.diffKb || 75) * 1024;
151
163
  const payloadOk = bytes <= limitBytes;
152
164
  phases.push({ phase: "payload", ok: payloadOk, bytes, limitBytes });
165
+ appendTelemetry(root, "gate_phase", { phase: "payload", ok: payloadOk });
166
+ if (progressBus && progressToken) {
167
+ progressBus.reportProgress(progressToken, 50, 100, "Phase 2/4: Diff Payload Governor check complete");
168
+ }
169
+
153
170
  if (!payloadOk) {
171
+ appendTelemetry(root, "gate_finished", { ok: false, code: 5 });
154
172
  return { ok: false, code: 5, phases };
155
173
  }
156
174
 
157
175
  // Phase 3: Diff Secret Scanner
158
176
  const secretResult = scanDiff(diffStr);
159
177
  phases.push({ phase: "secrets", ok: secretResult.ok, findings: secretResult.findings });
178
+ appendTelemetry(root, "gate_phase", { phase: "secrets", ok: secretResult.ok });
179
+ if (progressBus && progressToken) {
180
+ progressBus.reportProgress(progressToken, 75, 100, "Phase 3/4: Diff Secret Scanner check complete");
181
+ }
182
+
160
183
  if (!secretResult.ok) {
184
+ appendTelemetry(root, "gate_finished", { ok: false, code: 6 });
161
185
  return { ok: false, code: 6, phases };
162
186
  }
163
187
 
@@ -195,6 +219,8 @@ export async function gate(opts = {}) {
195
219
  flakyVerdictResult = flakyVerdict(runs);
196
220
  if (flakyVerdictResult.verdict === "QUARANTINED") {
197
221
  phases.push({ phase: "verify", ok: false, testResult, buildResult, flakyVerdict: flakyVerdictResult });
222
+ appendTelemetry(root, "gate_phase", { phase: "verify", ok: false, quarantined: true });
223
+ appendTelemetry(root, "gate_finished", { ok: false, code: 8 });
198
224
  return { ok: false, code: 8, phases, flakyVerdict: flakyVerdictResult };
199
225
  }
200
226
  }
@@ -208,16 +234,25 @@ export async function gate(opts = {}) {
208
234
  const verifyOk = testResult.ok && buildResult.ok;
209
235
  const failingCmd = !testResult.ok ? testResult : !buildResult.ok ? buildResult : null;
210
236
  phases.push({ phase: "verify", ok: verifyOk, testResult, buildResult });
237
+ appendTelemetry(root, "gate_phase", { phase: "verify", ok: verifyOk });
238
+ if (progressBus && progressToken) {
239
+ progressBus.reportProgress(progressToken, 100, 100, "Phase 4/4: Automated Test & Build Verification complete");
240
+ }
211
241
 
212
242
  if (!verifyOk && opts.fix) {
213
- const repairs = await repair(failingCmd, { config, root, signal: opts.signal });
243
+ const repairs = await repair(failingCmd, { config, root, signal: opts.signal, progressBus, progressToken });
244
+ const finalOk = repairs.ok;
245
+ const finalCode = repairs.ok ? 0 : 4;
246
+ appendTelemetry(root, "gate_finished", { ok: finalOk, code: finalCode, repaired: true });
214
247
  if (repairs.ok) {
215
248
  return { ok: true, code: 0, phases, repairs };
216
249
  }
217
250
  return { ok: false, code: 4, phases, repairs };
218
251
  }
219
252
 
220
- return { ok: verifyOk, code: verifyOk ? 0 : 4, phases };
253
+ const code = verifyOk ? 0 : 4;
254
+ appendTelemetry(root, "gate_finished", { ok: verifyOk, code });
255
+ return { ok: verifyOk, code, phases };
221
256
  }
222
257
 
223
258
  /**
@@ -280,6 +315,8 @@ export async function repair(failure, opts = {}) {
280
315
  const config = opts.config || loadConfig(root);
281
316
  const maxRetries = config.limits.repairAttempts || 3;
282
317
  const provider = opts.provider || createProvider(config.provider, config);
318
+ const progressBus = opts.progressBus;
319
+ const progressToken = opts.progressToken;
283
320
  const attempts = [];
284
321
  const breaker = opts.circuitBreaker || new OODACircuitBreaker({
285
322
  windowSize: Math.max(maxRetries + 1, 6),
@@ -292,6 +329,10 @@ export async function repair(failure, opts = {}) {
292
329
  breaker.observe(initialFingerprint);
293
330
 
294
331
  for (let n = 1; n <= maxRetries; n++) {
332
+ if (progressBus && progressToken) {
333
+ progressBus.reportProgress(progressToken, Math.round((n / maxRetries) * 100), 100, `OODA Repair attempt ${n}/${maxRetries}`);
334
+ }
335
+
295
336
  const repairPrompt = buildRepairPrompt(currentFailure, n, config);
296
337
  let session;
297
338
  try {
@@ -306,13 +347,15 @@ export async function repair(failure, opts = {}) {
306
347
  );
307
348
  } catch (err) {
308
349
  attempts.push({ n, ok: false, error: err.message });
350
+ appendTelemetry(root, "ooda_repair_attempt", { attempt: n, ok: false, error: err.message });
309
351
  break;
310
352
  }
311
353
 
312
354
  attempts.push({ n, session, ok: true });
355
+ appendTelemetry(root, "ooda_repair_attempt", { attempt: n, ok: true });
313
356
 
314
357
  // Re-verify after repair attempt
315
- const gateRes = await gate({ root, config, fix: false });
358
+ const gateRes = await gate({ root, config, fix: false, progressBus, progressToken });
316
359
  if (gateRes.ok) {
317
360
  breaker.reset();
318
361
  return { ok: true, attempts, finalStatus: "PASSED" };
@@ -0,0 +1,158 @@
1
+ import { writeMcpFrame } from "./mcp.mjs";
2
+
3
+ export const MAX_PROGRESS_MESSAGE_LENGTH = 240;
4
+ export const DEFAULT_PROGRESS_COALESCE_MS = 150;
5
+
6
+ /**
7
+ * MCP Progress & Event Streaming Bus with 150ms window coalescing and backpressure safety.
8
+ */
9
+ export class ProgressBus {
10
+ /**
11
+ * @param {import("node:stream").Writable} [output=process.stdout]
12
+ * @param {Object} [opts={}]
13
+ * @param {number} [opts.coalesceMs=150]
14
+ */
15
+ constructor(output = process.stdout, opts = {}) {
16
+ this.output = output || process.stdout;
17
+ this.coalesceMs = opts.coalesceMs ?? DEFAULT_PROGRESS_COALESCE_MS;
18
+ this.pending = new Map(); // token -> { timer, latestPayload }
19
+ this.queue = [];
20
+ this.isWriting = false;
21
+ this.isDraining = false;
22
+ }
23
+
24
+ /**
25
+ * Internal queue flusher with stream backpressure handling.
26
+ * @private
27
+ */
28
+ async _flushQueue() {
29
+ this.isWriting = true;
30
+ while (this.queue.length > 0) {
31
+ if (this.isDraining) {
32
+ await new Promise((resolve) => {
33
+ if (!this.output || typeof this.output.once !== "function") {
34
+ return resolve();
35
+ }
36
+ this.output.once("drain", resolve);
37
+ });
38
+ this.isDraining = false;
39
+ }
40
+
41
+ const frame = this.queue.shift();
42
+ const ok = writeMcpFrame(this.output, frame);
43
+ if (!ok) {
44
+ this.isDraining = true;
45
+ }
46
+ }
47
+ this.isWriting = false;
48
+ }
49
+
50
+ /**
51
+ * Sends a framed JSON-RPC payload to output via authorized writeMcpFrame.
52
+ * @param {Object} payload
53
+ */
54
+ async sendFrame(payload) {
55
+ const frameStr = JSON.stringify(payload) + "\n";
56
+ this.queue.push(frameStr);
57
+ if (!this.isWriting) {
58
+ await this._flushQueue();
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Reports progress for an active progressToken.
64
+ * Coalesces rapid intermediate updates within a 150ms window (latest-wins).
65
+ *
66
+ * @param {string} progressToken
67
+ * @param {number} progress
68
+ * @param {number|null} [total=null]
69
+ * @param {string|null} [message=null]
70
+ */
71
+ reportProgress(progressToken, progress, total = null, message = null) {
72
+ if (!progressToken) return;
73
+
74
+ let cappedMessage = undefined;
75
+ if (message !== null && message !== undefined) {
76
+ const msgStr = String(message);
77
+ cappedMessage =
78
+ msgStr.length > MAX_PROGRESS_MESSAGE_LENGTH
79
+ ? msgStr.slice(0, MAX_PROGRESS_MESSAGE_LENGTH)
80
+ : msgStr;
81
+ }
82
+
83
+ const params = {
84
+ progressToken,
85
+ progress,
86
+ };
87
+ if (total !== null && total !== undefined) params.total = total;
88
+ if (cappedMessage !== undefined) params.message = cappedMessage;
89
+
90
+ const payload = {
91
+ jsonrpc: "2.0",
92
+ method: "notifications/progress",
93
+ params,
94
+ };
95
+
96
+ const isComplete = total !== null && total !== undefined && progress >= total;
97
+ const existing = this.pending.get(progressToken);
98
+
99
+ if (isComplete) {
100
+ if (existing?.timer) clearTimeout(existing.timer);
101
+ this.pending.delete(progressToken);
102
+ this.sendFrame(payload);
103
+ return;
104
+ }
105
+
106
+ if (!existing) {
107
+ // First update in window: send immediately
108
+ this.sendFrame(payload);
109
+ const timer = setTimeout(() => {
110
+ const current = this.pending.get(progressToken);
111
+ if (current?.latestPayload) {
112
+ this.sendFrame(current.latestPayload);
113
+ }
114
+ this.pending.delete(progressToken);
115
+ }, this.coalesceMs);
116
+
117
+ this.pending.set(progressToken, { timer, latestPayload: null });
118
+ } else {
119
+ // Intermediate update: save latest payload
120
+ existing.latestPayload = payload;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Emits an MCP log notification frame (notifications/message).
126
+ * Never calls raw console.log.
127
+ *
128
+ * @param {string} [level="info"] "info" | "warning" | "error" | "debug"
129
+ * @param {string|Object} [data=""]
130
+ * @param {string} [logger="jules-orchestrator"]
131
+ */
132
+ log(level = "info", data = "", logger = "jules-orchestrator") {
133
+ const payload = {
134
+ jsonrpc: "2.0",
135
+ method: "notifications/message",
136
+ params: {
137
+ level,
138
+ logger,
139
+ data: typeof data === "string" ? data : JSON.stringify(data),
140
+ },
141
+ };
142
+ return this.sendFrame(payload);
143
+ }
144
+
145
+ /**
146
+ * Flushes all pending progress timers and ensures output queue is completely drained.
147
+ */
148
+ async flush() {
149
+ for (const [token, item] of this.pending.entries()) {
150
+ if (item.timer) clearTimeout(item.timer);
151
+ if (item.latestPayload) {
152
+ this.sendFrame(item.latestPayload);
153
+ }
154
+ this.pending.delete(token);
155
+ }
156
+ await this._flushQueue();
157
+ }
158
+ }
package/src/mcp.mjs CHANGED
@@ -4,10 +4,12 @@ import { gate, dispatch } from "./engine.mjs";
4
4
  import { classifyRiskTier } from "./risk.mjs";
5
5
  import { checkDailyBudget, lockStatus } from "./state.mjs";
6
6
  import { reapOrphanedIntents, reapStaleMutexDirs } from "./journal.mjs";
7
+ import { readTelemetry } from "./telemetry.mjs";
8
+ import { ProgressBus } from "./mcp-progress.mjs";
7
9
 
8
10
  export const MCP_SERVER_INFO = {
9
11
  name: "jules-orchestrator-kit",
10
- version: "0.22.8",
12
+ version: "0.23.0",
11
13
  };
12
14
 
13
15
  export const MAX_MCP_FRAME_SIZE = 4 * 1024 * 1024; // 4 MB memory safety ceiling
@@ -136,6 +138,16 @@ export const MCP_TOOLS = [
136
138
  properties: {},
137
139
  },
138
140
  },
141
+ {
142
+ name: "telemetry_tail",
143
+ description: "Query the last N telemetry events from the orchestrator telemetry spine.",
144
+ inputSchema: {
145
+ type: "object",
146
+ properties: {
147
+ limit: { type: "number", description: "Number of telemetry events to retrieve (default 50, max 500)" },
148
+ },
149
+ },
150
+ },
139
151
  ];
140
152
 
141
153
  export async function handleMcpRequest(request, opts = {}) {
@@ -260,6 +272,25 @@ export async function handleMcpRequest(request, opts = {}) {
260
272
  };
261
273
  }
262
274
 
275
+ if (toolName === "telemetry_tail") {
276
+ if (args.limit !== undefined && (typeof args.limit !== "number" || args.limit < 1)) {
277
+ return {
278
+ jsonrpc: "2.0",
279
+ id,
280
+ error: { code: -32602, message: "Invalid parameters: 'limit' must be a positive number" },
281
+ };
282
+ }
283
+ const limit = Math.min(args.limit || 50, 500);
284
+ const events = readTelemetry(root, limit);
285
+ return {
286
+ jsonrpc: "2.0",
287
+ id,
288
+ result: {
289
+ content: [{ type: "text", text: JSON.stringify(events, null, 2) }],
290
+ },
291
+ };
292
+ }
293
+
263
294
  return {
264
295
  jsonrpc: "2.0",
265
296
  id,
@@ -361,11 +392,14 @@ export function startMcpServer(input = process.stdin, output = process.stdout, o
361
392
  isolateMcpStdout(output, opts);
362
393
  }
363
394
 
395
+ const progressBus = opts.progressBus || new ProgressBus(output, opts);
364
396
  const decoder = new McpFrameDecoder({ maxFrameSize: opts.maxFrameSize || MAX_MCP_FRAME_SIZE });
365
397
 
366
398
  decoder.on("data", async (request) => {
367
399
  try {
368
- const response = await handleMcpRequest(request, opts);
400
+ const progressToken = request?.params?._meta?.progressToken || request?.params?.progressToken;
401
+ const requestOpts = { ...opts, progressBus, progressToken };
402
+ const response = await handleMcpRequest(request, requestOpts);
369
403
  if (response !== null && response !== undefined) {
370
404
  writeMcpFrame(output, JSON.stringify(response) + "\n");
371
405
  }
@@ -0,0 +1,238 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /**
4
+ * Computes SHA-1 hash of a string.
5
+ * @param {string} content
6
+ * @returns {string} SHA-1 hex string
7
+ */
8
+ function sha1(content) {
9
+ return createHash("sha1").update(content).digest("hex");
10
+ }
11
+
12
+ /**
13
+ * Chunks source code text into declaration blocks bounded by column-0 declaration boundaries.
14
+ * @param {string} text
15
+ * @param {string} [lang="js"]
16
+ * @returns {Array<Object>} Array of block descriptors
17
+ */
18
+ export function chunkBlocks(text = "", lang = "js") {
19
+ if (typeof text !== "string") {
20
+ text = String(text || "");
21
+ }
22
+
23
+ const lines = text.split(/\r?\n/);
24
+ const blocks = [];
25
+ const nameCounts = new Map();
26
+
27
+ const isPython = lang === "py" || lang === "python";
28
+
29
+ // Declaration regex matching column 0
30
+ const declRegex = /^(?:export\s+)?(?:async\s+)?(function\*?|class|const|let|var|def|type|interface|enum)\s+([a-zA-Z0-9_$]+)/;
31
+ const declAnyRegex = /^(?:export\s+)?(?:async\s+)?(function\*?|class|const|let|var|def|type|interface|enum)\b/;
32
+
33
+ let currentLines = [];
34
+ let currentType = "prelude";
35
+ let currentName = null;
36
+ let inBlock = false;
37
+ let braceDepth = 0;
38
+ let blockIndentLevel = 0;
39
+
40
+ function flushBlock() {
41
+ if (currentLines.length === 0) return;
42
+ const content = currentLines.join("\n");
43
+ if (currentType === "prelude" && content.trim().length === 0) {
44
+ currentLines = [];
45
+ return;
46
+ }
47
+ let name = currentName;
48
+ if (!name) {
49
+ name = currentType === "prelude" ? `prelude_${blocks.length}` : `block_${blocks.length}`;
50
+ }
51
+
52
+ // Ensure unique block ID if duplicate names exist
53
+ const count = (nameCounts.get(name) || 0) + 1;
54
+ nameCounts.set(name, count);
55
+ const uniqueId = count > 1 ? `${currentType}:${name}:${count}` : `${currentType}:${name}`;
56
+
57
+ blocks.push({
58
+ id: uniqueId,
59
+ name,
60
+ type: currentType,
61
+ lines: [...currentLines],
62
+ content,
63
+ hash: sha1(content),
64
+ indentation: blockIndentLevel,
65
+ });
66
+
67
+ currentLines = [];
68
+ currentType = "prelude";
69
+ currentName = null;
70
+ inBlock = false;
71
+ braceDepth = 0;
72
+ blockIndentLevel = 0;
73
+ }
74
+
75
+ for (let i = 0; i < lines.length; i++) {
76
+ const line = lines[i];
77
+ const isColumnZero = line.length > 0 && line[0] !== " " && line[0] !== "\t";
78
+
79
+ if (isPython) {
80
+ // Python block logic
81
+ if (isColumnZero && declAnyRegex.test(line)) {
82
+ flushBlock();
83
+ const match = line.match(declRegex);
84
+ currentType = match ? match[1] : "def";
85
+ currentName = match ? match[2] : null;
86
+ inBlock = true;
87
+ blockIndentLevel = 0;
88
+ } else if (inBlock && isColumnZero && line.trim().length > 0 && !line.trim().startsWith("#")) {
89
+ // Dedented back to column 0 in Python
90
+ flushBlock();
91
+ }
92
+ } else {
93
+ // JS/TS block logic
94
+ if (isColumnZero && declAnyRegex.test(line)) {
95
+ flushBlock();
96
+ const match = line.match(declRegex);
97
+ currentType = match ? match[1] : "decl";
98
+ currentName = match ? match[2] : null;
99
+ inBlock = true;
100
+ blockIndentLevel = 0;
101
+ }
102
+ }
103
+
104
+ currentLines.push(line);
105
+
106
+ if (!isPython && inBlock) {
107
+ // Track curly brace balance for JS/TS
108
+ for (const char of line) {
109
+ if (char === "{") braceDepth++;
110
+ if (char === "}") braceDepth--;
111
+ }
112
+
113
+ // Check if block ends on this line
114
+ const isClosingBraceAtColZero = isColumnZero && line.trim() === "}";
115
+ const isSingleLineStatement = braceDepth <= 0 && (line.trim().endsWith(";") || isClosingBraceAtColZero);
116
+
117
+ if (braceDepth <= 0 && isSingleLineStatement) {
118
+ flushBlock();
119
+ }
120
+ }
121
+ }
122
+
123
+ flushBlock();
124
+ return blocks;
125
+ }
126
+
127
+ /**
128
+ * Performs a 3-way block structural merge between base, ours, and theirs.
129
+ * @param {string} baseText
130
+ * @param {string} oursText
131
+ * @param {string} theirsText
132
+ * @param {string} [lang="js"]
133
+ * @returns {Object} { mergedText, classifications, conflicts, toString() }
134
+ */
135
+ export function mergeBlocks3Way(baseText = "", oursText = "", theirsText = "", lang = "js") {
136
+ const baseBlocks = chunkBlocks(baseText, lang);
137
+ const oursBlocks = chunkBlocks(oursText, lang);
138
+ const theirsBlocks = chunkBlocks(theirsText, lang);
139
+
140
+ const baseMap = new Map(baseBlocks.map((b) => [b.id, b]));
141
+ const theirsMap = new Map(theirsBlocks.map((b) => [b.id, b]));
142
+
143
+ const classifications = [];
144
+ const processed = new Set();
145
+ const outputBlocks = [];
146
+ let conflicts = 0;
147
+
148
+ // Process oursBlocks first
149
+ for (const oBlock of oursBlocks) {
150
+ const id = oBlock.id;
151
+ processed.add(id);
152
+
153
+ const bBlock = baseMap.get(id);
154
+ const tBlock = theirsMap.get(id);
155
+
156
+ if (tBlock && bBlock) {
157
+ if (oBlock.hash === tBlock.hash) {
158
+ // Both match exactly
159
+ classifications.push({ id, type: "IDENTICAL" });
160
+ outputBlocks.push(oBlock.content);
161
+ } else if (oBlock.hash === bBlock.hash) {
162
+ // Ours didn't change, theirs changed
163
+ classifications.push({ id, type: "ONLY_THEIRS" });
164
+ outputBlocks.push(tBlock.content);
165
+ } else if (tBlock.hash === bBlock.hash) {
166
+ // Theirs didn't change, ours changed
167
+ classifications.push({ id, type: "ONLY_OURS" });
168
+ outputBlocks.push(oBlock.content);
169
+ } else {
170
+ // Both changed differently
171
+ conflicts++;
172
+ classifications.push({ id, type: "CONFLICT_EDIT_EDIT" });
173
+ outputBlocks.push(`<<<<<<< OURS\n${oBlock.content}\n=======\n${tBlock.content}\n>>>>>>> THEIRS`);
174
+ }
175
+ } else if (tBlock && !bBlock) {
176
+ // Added in both ours and theirs
177
+ if (oBlock.hash === tBlock.hash) {
178
+ classifications.push({ id, type: "IDENTICAL" });
179
+ outputBlocks.push(oBlock.content);
180
+ } else {
181
+ conflicts++;
182
+ classifications.push({ id, type: "CONFLICT_EDIT_EDIT" });
183
+ outputBlocks.push(`<<<<<<< OURS\n${oBlock.content}\n=======\n${tBlock.content}\n>>>>>>> THEIRS`);
184
+ }
185
+ } else if (!tBlock && bBlock) {
186
+ // Present in base and ours, missing in theirs
187
+ if (oBlock.hash === bBlock.hash) {
188
+ // Deleted by theirs, unchanged by ours
189
+ classifications.push({ id, type: "DELETED" });
190
+ } else {
191
+ // Modified by ours, deleted by theirs -> CONFLICT
192
+ conflicts++;
193
+ classifications.push({ id, type: "CONFLICT_EDIT_EDIT" });
194
+ outputBlocks.push(`<<<<<<< OURS\n${oBlock.content}\n=======\n>>>>>>> THEIRS`);
195
+ }
196
+ } else {
197
+ // !tBlock && !bBlock -> ADDED_OURS
198
+ classifications.push({ id, type: "ADDED_OURS" });
199
+ outputBlocks.push(oBlock.content);
200
+ }
201
+ }
202
+
203
+ // Handle blocks in theirs that were not in ours
204
+ for (const tBlock of theirsBlocks) {
205
+ const id = tBlock.id;
206
+ if (processed.has(id)) continue;
207
+ processed.add(id);
208
+
209
+ const bBlock = baseMap.get(id);
210
+ if (!bBlock) {
211
+ // Added in theirs
212
+ classifications.push({ id, type: "ADDED_THEIRS" });
213
+ outputBlocks.push(tBlock.content);
214
+ } else {
215
+ // Present in base and theirs, deleted by ours
216
+ if (tBlock.hash === bBlock.hash) {
217
+ // Deleted by ours, unchanged by theirs
218
+ classifications.push({ id, type: "DELETED" });
219
+ } else {
220
+ // Modified by theirs, deleted by ours -> CONFLICT
221
+ conflicts++;
222
+ classifications.push({ id, type: "CONFLICT_EDIT_EDIT" });
223
+ outputBlocks.push(`<<<<<<< OURS\n=======\n${tBlock.content}\n>>>>>>> THEIRS`);
224
+ }
225
+ }
226
+ }
227
+
228
+ const mergedText = outputBlocks.join("\n\n");
229
+
230
+ return {
231
+ mergedText,
232
+ classifications,
233
+ conflicts,
234
+ toString() {
235
+ return this.mergedText;
236
+ },
237
+ };
238
+ }
@@ -0,0 +1,63 @@
1
+ import { writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
2
+ import { join, basename, extname } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { spawnSync } from "node:child_process";
5
+
6
+ /**
7
+ * Syntax Verification Chain validating merged output via target runtime tools.
8
+ * @param {string} mergedText
9
+ * @param {string} fileName
10
+ * @param {string} [root=process.cwd()]
11
+ * @returns {{ ok: boolean, tool: string, error?: string }}
12
+ */
13
+ export function mergeVerifyChain(mergedText, fileName, root = process.cwd()) {
14
+ const ext = extname(fileName).toLowerCase();
15
+ const base = basename(fileName) || `temp_file${ext}`;
16
+ const tempDir = join(tmpdir(), `merge-verify-${Date.now()}-${Math.random().toString(36).slice(2)}`);
17
+
18
+ mkdirSync(tempDir, { recursive: true });
19
+ const tempFilePath = join(tempDir, base);
20
+
21
+ try {
22
+ writeFileSync(tempFilePath, mergedText, "utf8");
23
+
24
+ let cmd = "";
25
+ let args = [];
26
+ let tool = "";
27
+
28
+ if (ext === ".py") {
29
+ cmd = "python3";
30
+ args = ["-m", "py_compile", tempFilePath];
31
+ tool = "python3 -m py_compile";
32
+ } else if (ext === ".ts" || ext === ".tsx" || ext === ".mts" || ext === ".cts") {
33
+ const hasTsConfig = existsSync(join(root, "tsconfig.json"));
34
+ if (hasTsConfig) {
35
+ cmd = "npx";
36
+ args = ["tsc", "--noEmit", tempFilePath];
37
+ tool = "tsc --noEmit";
38
+ } else {
39
+ cmd = "node";
40
+ args = ["--check", tempFilePath];
41
+ tool = "node --check";
42
+ }
43
+ } else {
44
+ // JS, MJS, CJS or default
45
+ cmd = "node";
46
+ args = ["--check", tempFilePath];
47
+ tool = "node --check";
48
+ }
49
+
50
+ const res = spawnSync(cmd, args, { encoding: "utf8" });
51
+ const ok = res.status === 0;
52
+
53
+ return {
54
+ ok,
55
+ tool,
56
+ ...(ok ? {} : { error: res.stderr || res.stdout || `Process exited with code ${res.status}` }),
57
+ };
58
+ } finally {
59
+ if (existsSync(tempDir)) {
60
+ rmSync(tempDir, { recursive: true, force: true });
61
+ }
62
+ }
63
+ }
package/src/security.mjs CHANGED
@@ -64,7 +64,9 @@ export function safeAtomicWrite(filePath, content, options = {}) {
64
64
  const fd = openSync(tempFile, "wx", mode);
65
65
  try {
66
66
  writeSync(fd, content, null, encoding);
67
- fsyncSync(fd);
67
+ if (options.sync !== false) {
68
+ fsyncSync(fd);
69
+ }
68
70
  } finally {
69
71
  closeSync(fd);
70
72
  }
@@ -0,0 +1,275 @@
1
+ import {
2
+ openSync,
3
+ writeSync,
4
+ closeSync,
5
+ readFileSync,
6
+ existsSync,
7
+ statSync,
8
+ readdirSync,
9
+ } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { createHash } from "node:crypto";
12
+ import { resolveRoot } from "./config.mjs";
13
+ import { getStateDir, withVfsMutex } from "./state.mjs";
14
+ import { safeAtomicWrite } from "./security.mjs";
15
+
16
+ export const MAX_TELEMETRY_SEGMENT_BYTES = 8 * 1024 * 1024; // 8 MB log segment ceiling
17
+ export const TELEMETRY_GENESIS_HASH = "0".repeat(64);
18
+
19
+ /**
20
+ * Returns telemetry file paths for a given date string sorted by segment index.
21
+ */
22
+ function getTelemetrySegmentFiles(stateDir, dateStr) {
23
+ if (!existsSync(stateDir)) return [];
24
+ const prefix = `telemetry-${dateStr}`;
25
+ const files = readdirSync(stateDir).filter(
26
+ (f) => f.startsWith(prefix) && f.endsWith(".jsonl")
27
+ );
28
+
29
+ return files
30
+ .map((name) => {
31
+ let index = 0;
32
+ if (name !== `${prefix}.jsonl`) {
33
+ const match = name.match(/telemetry-[0-9]{4}-[0-9]{2}-[0-9]{2}-([0-9]+)\.jsonl$/);
34
+ if (match) index = parseInt(match[1], 10);
35
+ }
36
+ return { name, path: join(stateDir, name), index };
37
+ })
38
+ .sort((a, b) => a.index - b.index);
39
+ }
40
+
41
+ /**
42
+ * Performs a cold scan of telemetry jsonl files to recover the last valid hash and active segment index.
43
+ */
44
+ function coldScanTelemetry(stateDir, dateStr) {
45
+ const segments = getTelemetrySegmentFiles(stateDir, dateStr);
46
+ if (segments.length === 0) {
47
+ return { hash: TELEMETRY_GENESIS_HASH, segment: 0 };
48
+ }
49
+
50
+ for (let i = segments.length - 1; i >= 0; i--) {
51
+ const seg = segments[i];
52
+ if (existsSync(seg.path)) {
53
+ try {
54
+ const content = readFileSync(seg.path, "utf-8");
55
+ const lines = content.split("\n").filter(Boolean);
56
+ for (let j = lines.length - 1; j >= 0; j--) {
57
+ try {
58
+ const entry = JSON.parse(lines[j]);
59
+ if (entry && typeof entry.hash === "string") {
60
+ return { hash: entry.hash, segment: seg.index };
61
+ }
62
+ } catch (_) {}
63
+ }
64
+ } catch (_) {}
65
+ }
66
+ }
67
+
68
+ return { hash: TELEMETRY_GENESIS_HASH, segment: 0 };
69
+ }
70
+
71
+ /**
72
+ * Appends a structured telemetry event to .agent/state/telemetry-<date>.jsonl with SHA-256 hash chaining.
73
+ * Uses .head atomic cache for O(1) constant-time appends.
74
+ *
75
+ * @param {string} [rootOrOpts]
76
+ * @param {string} kind
77
+ * @param {Object} [fields={}]
78
+ * @returns {Object} Appended telemetry record
79
+ */
80
+ export function appendTelemetry(rootOrOpts = resolveRoot(), kind = "event", fields = {}) {
81
+ const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
82
+ const stateDir = getStateDir(root);
83
+ const mutexDir = join(stateDir, ".telemetry.mutex");
84
+
85
+ return withVfsMutex(mutexDir, () => {
86
+ const dateStr = new Date().toISOString().split("T")[0];
87
+ const headPath = join(stateDir, `telemetry-${dateStr}.head`);
88
+
89
+ let prevHash = TELEMETRY_GENESIS_HASH;
90
+ let activeSegmentIndex = 0;
91
+ let headValid = false;
92
+
93
+ if (existsSync(headPath)) {
94
+ try {
95
+ const headContent = readFileSync(headPath, "utf-8");
96
+ const headObj = JSON.parse(headContent);
97
+ if (headObj && typeof headObj.hash === "string") {
98
+ prevHash = headObj.hash;
99
+ activeSegmentIndex = typeof headObj.segment === "number" ? headObj.segment : 0;
100
+ headValid = true;
101
+ }
102
+ } catch (_) {}
103
+ }
104
+
105
+ if (!headValid) {
106
+ const recovered = coldScanTelemetry(stateDir, dateStr);
107
+ prevHash = recovered.hash;
108
+ activeSegmentIndex = recovered.segment;
109
+ }
110
+
111
+ let activeFileName =
112
+ activeSegmentIndex === 0
113
+ ? `telemetry-${dateStr}.jsonl`
114
+ : `telemetry-${dateStr}-${activeSegmentIndex}.jsonl`;
115
+ let activeFilePath = join(stateDir, activeFileName);
116
+
117
+ if (existsSync(activeFilePath)) {
118
+ try {
119
+ const stat = statSync(activeFilePath);
120
+ if (stat.size >= MAX_TELEMETRY_SEGMENT_BYTES) {
121
+ activeSegmentIndex += 1;
122
+ activeFileName = `telemetry-${dateStr}-${activeSegmentIndex}.jsonl`;
123
+ activeFilePath = join(stateDir, activeFileName);
124
+ }
125
+ } catch (_) {}
126
+ }
127
+
128
+ const ts = new Date().toISOString();
129
+ const payloadWithoutHash = { v: 1, ts, kind, ...fields, prevHash };
130
+ const hash = createHash("sha256").update(JSON.stringify(payloadWithoutHash)).digest("hex");
131
+ const entry = { ...payloadWithoutHash, hash };
132
+
133
+ const fd = openSync(activeFilePath, "a");
134
+ try {
135
+ writeSync(fd, JSON.stringify(entry) + "\n", "utf-8");
136
+ } finally {
137
+ closeSync(fd);
138
+ }
139
+
140
+ safeAtomicWrite(
141
+ headPath,
142
+ JSON.stringify({ v: 1, hash, segment: activeSegmentIndex, ts }),
143
+ { sync: false }
144
+ );
145
+
146
+ return entry;
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Reads telemetry records across segments for a target date (or current date if omitted).
152
+ * Returns array of telemetry objects in chronological order.
153
+ *
154
+ * @param {string} [rootOrOpts]
155
+ * @param {number} [limit=50]
156
+ * @param {string} [targetDateStr]
157
+ * @returns {Array<Object>}
158
+ */
159
+ export function readTelemetry(rootOrOpts = resolveRoot(), limit = 50, targetDateStr = null) {
160
+ const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
161
+ const stateDir = getStateDir(root);
162
+ const dateStr = targetDateStr || new Date().toISOString().split("T")[0];
163
+
164
+ const segments = getTelemetrySegmentFiles(stateDir, dateStr);
165
+ if (segments.length === 0) return [];
166
+
167
+ const allEntries = [];
168
+ for (const seg of segments) {
169
+ if (existsSync(seg.path)) {
170
+ try {
171
+ const raw = readFileSync(seg.path, "utf-8");
172
+ const lines = raw.split("\n").filter(Boolean);
173
+ for (const line of lines) {
174
+ try {
175
+ const entry = JSON.parse(line);
176
+ allEntries.push(Object.freeze(entry));
177
+ } catch (_) {}
178
+ }
179
+ } catch (_) {}
180
+ }
181
+ }
182
+
183
+ if (allEntries.length <= limit) {
184
+ return allEntries;
185
+ }
186
+ return allEntries.slice(allEntries.length - limit);
187
+ }
188
+
189
+ /**
190
+ * Verifies the cryptographic SHA-256 hash-chain integrity of telemetry records for a date.
191
+ *
192
+ * @param {string} [rootOrOpts]
193
+ * @param {string} [targetDateStr]
194
+ * @returns {Object}
195
+ */
196
+ export function verifyTelemetryIntegrity(rootOrOpts = resolveRoot(), targetDateStr = null) {
197
+ const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
198
+ const stateDir = getStateDir(root);
199
+ const dateStr = targetDateStr || new Date().toISOString().split("T")[0];
200
+
201
+ const segments = getTelemetrySegmentFiles(stateDir, dateStr);
202
+ if (segments.length === 0) {
203
+ return { ok: true, count: 0, lastHash: TELEMETRY_GENESIS_HASH };
204
+ }
205
+
206
+ let expectedPrevHash = TELEMETRY_GENESIS_HASH;
207
+ let totalCount = 0;
208
+
209
+ for (const seg of segments) {
210
+ if (!existsSync(seg.path)) continue;
211
+ try {
212
+ const raw = readFileSync(seg.path, "utf-8");
213
+ const lines = raw.split("\n").filter(Boolean);
214
+ for (let i = 0; i < lines.length; i++) {
215
+ totalCount++;
216
+ let obj;
217
+ try {
218
+ obj = JSON.parse(lines[i]);
219
+ } catch (err) {
220
+ return { ok: false, segment: seg.name, line: i + 1, error: "TORN_WRITE_CORRUPTION", detail: err.message };
221
+ }
222
+
223
+ if (!obj.hash || !obj.prevHash) {
224
+ return { ok: false, segment: seg.name, line: i + 1, error: "MISSING_HASH_FIELDS" };
225
+ }
226
+
227
+ if (obj.prevHash !== expectedPrevHash) {
228
+ return {
229
+ ok: false,
230
+ segment: seg.name,
231
+ line: i + 1,
232
+ error: "BROKEN_PREV_HASH",
233
+ expected: expectedPrevHash,
234
+ actual: obj.prevHash,
235
+ };
236
+ }
237
+
238
+ const { hash, ...rest } = obj;
239
+ const recomputed = createHash("sha256").update(JSON.stringify(rest)).digest("hex");
240
+ if (recomputed !== hash) {
241
+ return {
242
+ ok: false,
243
+ segment: seg.name,
244
+ line: i + 1,
245
+ error: "CORRUPTED_ENTRY_HASH",
246
+ expected: recomputed,
247
+ actual: hash,
248
+ };
249
+ }
250
+ expectedPrevHash = hash;
251
+ }
252
+ } catch (err) {
253
+ return { ok: false, count: totalCount, error: err.message };
254
+ }
255
+ }
256
+
257
+ const headPath = join(stateDir, `telemetry-${dateStr}.head`);
258
+ if (existsSync(headPath)) {
259
+ try {
260
+ const headObj = JSON.parse(readFileSync(headPath, "utf-8"));
261
+ if (headObj.hash !== expectedPrevHash) {
262
+ return {
263
+ ok: false,
264
+ error: "HEAD_HASH_MISMATCH",
265
+ expected: expectedPrevHash,
266
+ actual: headObj.hash,
267
+ };
268
+ }
269
+ } catch (_) {
270
+ return { ok: false, error: "CORRUPTED_HEAD_FILE" };
271
+ }
272
+ }
273
+
274
+ return { ok: true, count: totalCount, lastHash: expectedPrevHash };
275
+ }