@hilbras/omninode 1.0.0 → 2.0.0-alpha.10

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/dist/cli/index.js CHANGED
@@ -6,7 +6,7 @@ import { realpathSync } from "fs";
6
6
  import { Command } from "commander";
7
7
 
8
8
  // src/version.ts
9
- var OMNINODE_VERSION = "1.0.0";
9
+ var OMNINODE_VERSION = "2.0.0-alpha.10";
10
10
 
11
11
  // src/errors/index.ts
12
12
  var OmniNodeError = class extends Error {
@@ -44,11 +44,6 @@ var PipelineError = class extends OmniNodeError {
44
44
  super(code, message, options);
45
45
  }
46
46
  };
47
- var ProtocolError = class extends OmniNodeError {
48
- constructor(message, options = {}) {
49
- super("PROTOCOL_VIOLATION", message, options);
50
- }
51
- };
52
47
  var PlannerError = class extends OmniNodeError {
53
48
  constructor(code, message, options = {}) {
54
49
  super(code, message, options);
@@ -73,6 +68,7 @@ var providerConfigSchema = z.object({
73
68
  type: z.enum(["openai-compatible", "omnihilbras", "openrouter", "local", "custom"]),
74
69
  base_url: z.string().url(),
75
70
  api_key_env_var: z.string().min(1).optional(),
71
+ timeout_ms: z.number().int().positive().optional(),
76
72
  headers: z.record(z.string()).optional(),
77
73
  enabled: z.boolean().optional(),
78
74
  metadata: z.record(z.unknown()).optional()
@@ -88,6 +84,11 @@ var agentConfigSchema = z.object({
88
84
  input_mode: z.enum(["stdin", "arg", "protocol"]).default("stdin"),
89
85
  timeout_ms: z.number().int().positive().optional(),
90
86
  inherit_env: z.boolean().optional(),
87
+ env_policy: z.enum(["inherit", "allowlist", "denylist", "explicit"]).optional(),
88
+ env_allowlist: z.array(z.string()).optional(),
89
+ env_denylist: z.array(z.string()).optional(),
90
+ max_output_bytes: z.number().int().positive().optional(),
91
+ allow_external_cwd: z.boolean().optional(),
91
92
  metadata: z.record(z.unknown()).optional()
92
93
  }).strict();
93
94
  var roleConfigSchema = z.object({
@@ -132,7 +133,9 @@ var projectConfigSchema = z.object({
132
133
  memory: z.object({
133
134
  provider: z.string().min(1).default("local"),
134
135
  base_url: z.string().url().optional(),
135
- api_key_env_var: z.string().min(1).optional()
136
+ api_key_env_var: z.string().min(1).optional(),
137
+ /** When true, memory failures fail tasks instead of being tolerated (§12). */
138
+ required: z.boolean().optional()
136
139
  }).strict().optional()
137
140
  }).strict();
138
141
  var appConfigSchema = z.object({
@@ -214,7 +217,8 @@ function toAppConfig(yaml) {
214
217
  memory: {
215
218
  provider: yaml.project.memory.provider,
216
219
  baseUrl: yaml.project.memory.base_url,
217
- apiKeyEnvVar: yaml.project.memory.api_key_env_var
220
+ apiKeyEnvVar: yaml.project.memory.api_key_env_var,
221
+ required: yaml.project.memory.required
218
222
  }
219
223
  } : {}
220
224
  },
@@ -245,6 +249,7 @@ function toProviderConfig(raw) {
245
249
  type: raw.type,
246
250
  baseUrl: raw.base_url,
247
251
  apiKeyEnvVar: raw.api_key_env_var,
252
+ timeoutMs: raw.timeout_ms,
248
253
  headers: raw.headers,
249
254
  enabled: raw.enabled,
250
255
  metadata: raw.metadata
@@ -262,6 +267,11 @@ function toAgentConfig(raw) {
262
267
  inputMode: raw.input_mode,
263
268
  timeoutMs: raw.timeout_ms,
264
269
  inheritEnv: raw.inherit_env,
270
+ envPolicy: raw.env_policy,
271
+ envAllowlist: raw.env_allowlist,
272
+ envDenylist: raw.env_denylist,
273
+ maxOutputBytes: raw.max_output_bytes,
274
+ allowExternalCwd: raw.allow_external_cwd,
265
275
  metadata: raw.metadata
266
276
  };
267
277
  }
@@ -358,35 +368,32 @@ function authHeaders(config, env = process.env) {
358
368
  }
359
369
 
360
370
  // src/providers/http.ts
371
+ var HttpResponseError = class extends Error {
372
+ constructor(message, status, cause) {
373
+ super(message, { cause });
374
+ this.status = status;
375
+ this.name = "HttpResponseError";
376
+ }
377
+ status;
378
+ };
361
379
  var DEFAULT_TIMEOUT_MS = 3e4;
362
380
  async function requestJson(url, options = {}) {
363
381
  const { method = "GET", headers = {}, body, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
364
- let response;
365
- try {
366
- response = await fetch(url, {
367
- method,
368
- headers: {
369
- accept: "application/json",
370
- ...body !== void 0 ? { "content-type": "application/json" } : {},
371
- ...headers
372
- },
373
- ...body !== void 0 ? { body: JSON.stringify(body) } : {},
374
- signal: AbortSignal.timeout(timeoutMs)
375
- });
376
- } catch (error) {
377
- const reason = error instanceof Error && error.name === "TimeoutError" ? `timed out after ${timeoutMs}ms` : error instanceof Error ? error.message : String(error);
378
- throw new ProviderError("PROVIDER_UNAVAILABLE", `Request to ${url} failed: ${reason}.`, {
379
- cause: error
380
- });
381
- }
382
+ const response = await fetch(url, {
383
+ method,
384
+ headers: {
385
+ accept: "application/json",
386
+ ...body !== void 0 ? { "content-type": "application/json" } : {},
387
+ ...headers
388
+ },
389
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {},
390
+ signal: AbortSignal.timeout(timeoutMs)
391
+ });
382
392
  let parsed;
383
393
  try {
384
394
  parsed = await response.json();
385
395
  } catch (error) {
386
- throw new ProviderError("PROVIDER_UNAVAILABLE", `Response from ${url} was not valid JSON.`, {
387
- cause: error,
388
- details: { status: response.status }
389
- });
396
+ throw new HttpResponseError(`Response from ${url} was not valid JSON.`, response.status, error);
390
397
  }
391
398
  return { status: response.status, body: parsed };
392
399
  }
@@ -447,24 +454,167 @@ var Logger = class _Logger {
447
454
  };
448
455
  var logger = new Logger();
449
456
 
457
+ // src/providers/errors.ts
458
+ function kindToCode(kind) {
459
+ switch (kind) {
460
+ case "AUTHENTICATION_ERROR":
461
+ return "PROVIDER_AUTH_FAILED";
462
+ case "MODEL_NOT_FOUND":
463
+ return "MODEL_NOT_FOUND";
464
+ default:
465
+ return "PROVIDER_UNAVAILABLE";
466
+ }
467
+ }
468
+ function isRetryable(kind) {
469
+ return kind === "RATE_LIMIT_ERROR" || kind === "SERVER_ERROR" || kind === "NETWORK_ERROR";
470
+ }
471
+ function kindFromStatus(status) {
472
+ if (status === 401 || status === 403) return "AUTHENTICATION_ERROR";
473
+ if (status === 429) return "RATE_LIMIT_ERROR";
474
+ if (status === 404) return "MODEL_NOT_FOUND";
475
+ if (status === 408 || status === 504) return "TIMEOUT";
476
+ if (status >= 500) return "SERVER_ERROR";
477
+ if (status >= 400) return "INVALID_REQUEST";
478
+ return "UNKNOWN_ERROR";
479
+ }
480
+ function extractMessage(body) {
481
+ if (body === null || typeof body !== "object") return void 0;
482
+ const root = body;
483
+ const error = root.error ?? root;
484
+ const message = error.message ?? error.detail ?? root.message;
485
+ return typeof message === "string" ? message : void 0;
486
+ }
487
+ function parseRetryAfter(value, now = Date.now()) {
488
+ if (!value) return void 0;
489
+ const seconds = Number(value);
490
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
491
+ const date = Date.parse(value);
492
+ return Number.isNaN(date) ? void 0 : Math.max(0, date - now);
493
+ }
494
+ function isTimeoutError(error) {
495
+ return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError") || typeof error?.code === "string" && error.code === "ABORT_ERR";
496
+ }
497
+ function fallbackMessage(kind, context, status) {
498
+ const { provider, operation } = context;
499
+ switch (kind) {
500
+ case "AUTHENTICATION_ERROR":
501
+ return `Authentication with provider "${provider}" failed (HTTP ${status}). Check that the referenced API key is set and valid.`;
502
+ case "RATE_LIMIT_ERROR":
503
+ return `Provider "${provider}" rate-limited ${operation} (HTTP ${status}). Retry later.`;
504
+ case "MODEL_NOT_FOUND":
505
+ return `Provider "${provider}" does not expose the requested model (HTTP ${status}).`;
506
+ case "TIMEOUT":
507
+ return `Provider "${provider}" timed out during ${operation} (HTTP ${status}).`;
508
+ case "SERVER_ERROR":
509
+ return `Provider "${provider}" returned a server error during ${operation} (HTTP ${status}).`;
510
+ case "INVALID_REQUEST":
511
+ return `Provider "${provider}" rejected ${operation} as invalid (HTTP ${status}).`;
512
+ default:
513
+ return `Provider "${provider}" failed during ${operation} (HTTP ${status}).`;
514
+ }
515
+ }
516
+ function providerHttpError(context) {
517
+ const status = context.status ?? 0;
518
+ const kind = kindFromStatus(status);
519
+ const detail = extractMessage(context.body);
520
+ const retryAfterMs = context.retryAfterMs ?? parseRetryAfter(void 0);
521
+ return new ProviderError(
522
+ context.code ?? kindToCode(kind),
523
+ detail ?? fallbackMessage(kind, context, status),
524
+ {
525
+ cause: context.cause,
526
+ details: {
527
+ kind,
528
+ provider: context.provider,
529
+ operation: context.operation,
530
+ status,
531
+ retryable: isRetryable(kind),
532
+ ...retryAfterMs !== void 0 ? { retryAfterMs } : {},
533
+ ...detail ? { providerMessage: detail } : {}
534
+ }
535
+ }
536
+ );
537
+ }
538
+ function providerTransportError(context) {
539
+ const cause = context.cause;
540
+ const kind = isTimeoutError(cause) ? "TIMEOUT" : cause instanceof SyntaxError || cause?.name === "HttpResponseError" ? "UNKNOWN_ERROR" : "NETWORK_ERROR";
541
+ const message = context.cause instanceof Error ? context.cause.message : String(context.cause);
542
+ return new ProviderError("PROVIDER_UNAVAILABLE", `Provider request failed: ${message}`, {
543
+ cause: context.cause,
544
+ details: {
545
+ kind,
546
+ provider: context.provider,
547
+ operation: context.operation,
548
+ retryable: isRetryable(kind)
549
+ }
550
+ });
551
+ }
552
+
450
553
  // src/providers/openai-compatible/index.ts
554
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
451
555
  var OpenAICompatibleProvider = class {
452
556
  config;
557
+ providerId;
558
+ authentication;
453
559
  log;
454
- constructor(config, log = logger) {
560
+ /** Discovered models, so getModel() answers without another round-trip. */
561
+ discovered;
562
+ env;
563
+ constructor(config, log = logger, env = process.env) {
455
564
  this.config = { ...config, baseUrl: normalizeBaseUrl(config.baseUrl) };
565
+ this.env = env;
566
+ this.providerId = config.providerId ?? config.name;
567
+ this.authentication = {
568
+ method: config.apiKeyEnvVar ? "bearer" : "none",
569
+ ...config.apiKeyEnvVar ? { envVar: config.apiKeyEnvVar } : {},
570
+ configured: config.apiKeyEnvVar ? Boolean(env[config.apiKeyEnvVar]) : true
571
+ };
456
572
  this.log = log.child({ provider: config.name });
457
573
  }
458
- async listModels() {
459
- const { status, body } = await requestJson(`${this.config.baseUrl}/models`, {
460
- headers: authHeaders(this.config)
461
- });
462
- if (status === 401 || status === 403) {
463
- throw authFailed(this.config, status);
574
+ /** Provider-level request timeout (roadmap §6.6/§11) with the shared default. */
575
+ get requestTimeoutMs() {
576
+ return this.config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
577
+ }
578
+ /** Authenticate + discover (roadmap §10, Model Discovery). */
579
+ async connect() {
580
+ authHeaders(this.config, this.env);
581
+ const models = await this.listModels();
582
+ this.discovered = models;
583
+ return models;
584
+ }
585
+ async getModel(modelId) {
586
+ const models = this.discovered ?? await this.connect();
587
+ return models.find((m) => m.id === modelId);
588
+ }
589
+ /** Raw /models body (protected so adapters can capture gateway-level metadata). */
590
+ async rawModelsResponse() {
591
+ const response = await this.modelsRequest();
592
+ return response.body;
593
+ }
594
+ async modelsRequest() {
595
+ let response;
596
+ try {
597
+ response = await requestJson(`${this.config.baseUrl}/models`, {
598
+ headers: authHeaders(this.config, this.env),
599
+ timeoutMs: this.requestTimeoutMs
600
+ });
601
+ } catch (error) {
602
+ if (error instanceof ProviderError) throw error;
603
+ throw providerTransportError({ provider: this.config.name, operation: "listModels", cause: error });
464
604
  }
605
+ const { status, body } = response;
465
606
  if (status !== 200) {
466
- throw unavailable(this.config, `Model discovery returned HTTP ${status}.`, status);
607
+ throw providerHttpError({
608
+ provider: this.config.name,
609
+ operation: "listModels",
610
+ status,
611
+ body
612
+ });
467
613
  }
614
+ return response;
615
+ }
616
+ async listModels() {
617
+ const body = await this.rawModelsResponse();
468
618
  const entries = extractModelEntries(body, this.config);
469
619
  const models = [];
470
620
  let skipped = 0;
@@ -473,22 +623,44 @@ var OpenAICompatibleProvider = class {
473
623
  skipped += 1;
474
624
  continue;
475
625
  }
476
- models.push({
477
- provider: this.config.name,
478
- id: entry2.id,
479
- displayName: entry2.id,
480
- status: "available",
481
- metadata: {
482
- ...typeof entry2.owned_by === "string" ? { ownedBy: entry2.owned_by } : {},
483
- ...typeof entry2.created === "number" ? { createdAt: entry2.created } : {}
484
- }
485
- });
626
+ models.push(this.toModelInfo(entry2));
486
627
  }
487
628
  if (skipped > 0) {
488
629
  this.log.warn(`Skipped ${skipped} model entr(y/ies) without a valid id.`);
489
630
  }
490
631
  return models;
491
632
  }
633
+ /** Maps a raw model entry onto the normalized metadata shape (§10). */
634
+ toModelInfo(entry2) {
635
+ const parameters = Array.isArray(entry2.supported_parameters) ? entry2.supported_parameters.filter((p) => typeof p === "string") : void 0;
636
+ const contextWindow = typeof entry2.context_length === "number" ? entry2.context_length : typeof entry2.context_window === "number" ? entry2.context_window : void 0;
637
+ const inputTypes = normalizeModalities(entry2.input_modalities);
638
+ const outputTypes = normalizeModalities(entry2.output_modalities);
639
+ return {
640
+ provider: this.config.name,
641
+ id: entry2.id,
642
+ displayName: entry2.id,
643
+ ...typeof entry2.name === "string" ? { name: entry2.name } : {},
644
+ status: "available",
645
+ ...contextWindow !== void 0 ? { contextWindow } : {},
646
+ ...inputTypes !== void 0 ? { inputTypes } : {},
647
+ ...outputTypes !== void 0 ? { outputTypes } : {},
648
+ ...parameters !== void 0 ? {
649
+ supportsTools: parameters.includes("tools") || parameters.includes("tool_choice"),
650
+ supportsStructuredOutput: parameters.includes("response_format"),
651
+ supportsStreaming: parameters.includes("stream")
652
+ } : {},
653
+ metadata: {
654
+ ...typeof entry2.owned_by === "string" ? { ownedBy: entry2.owned_by } : {},
655
+ ...typeof entry2.created === "number" ? { createdAt: entry2.created } : {},
656
+ ...parameters !== void 0 ? { supportedParameters: parameters } : {}
657
+ }
658
+ };
659
+ }
660
+ /** Streaming where the gateway supports it (§11). */
661
+ stream(request, onChunk) {
662
+ return streamChatCompletion(this.config, request, onChunk, this.env);
663
+ }
492
664
  async healthCheck() {
493
665
  const lastCheckedAt = (/* @__PURE__ */ new Date()).toISOString();
494
666
  try {
@@ -518,27 +690,35 @@ var OpenAICompatibleProvider = class {
518
690
  }
519
691
  }
520
692
  async chat(request) {
521
- const { status, body } = await requestJson(`${this.config.baseUrl}/chat/completions`, {
522
- method: "POST",
523
- headers: authHeaders(this.config),
524
- body: {
525
- model: request.model,
526
- messages: request.messages,
527
- ...request.temperature !== void 0 ? { temperature: request.temperature } : {},
528
- ...request.maxTokens !== void 0 ? { max_tokens: request.maxTokens } : {},
529
- ...request.stop !== void 0 ? { stop: request.stop } : {}
530
- }
531
- });
532
- if (status === 401 || status === 403) throw authFailed(this.config, status);
533
- if (status === 404) {
534
- throw new ProviderError(
535
- "MODEL_NOT_FOUND",
536
- `Model "${request.model}" or the chat endpoint was not found on provider "${this.config.name}" (HTTP 404).`,
537
- { details: { status } }
538
- );
693
+ let response;
694
+ try {
695
+ response = await requestJson(`${this.config.baseUrl}/chat/completions`, {
696
+ method: "POST",
697
+ headers: authHeaders(this.config, this.env),
698
+ timeoutMs: this.requestTimeoutMs,
699
+ body: {
700
+ model: request.model,
701
+ messages: request.messages,
702
+ ...request.temperature !== void 0 ? { temperature: request.temperature } : {},
703
+ ...request.maxTokens !== void 0 ? { max_tokens: request.maxTokens } : {},
704
+ ...request.stop !== void 0 ? { stop: request.stop } : {}
705
+ }
706
+ });
707
+ } catch (error) {
708
+ if (error instanceof ProviderError) throw error;
709
+ throw providerTransportError({ provider: this.config.name, operation: "chat", cause: error });
539
710
  }
711
+ const { status, body } = response;
540
712
  if (status !== 200) {
541
- throw unavailable(this.config, `Chat completion returned HTTP ${status}.`, status);
713
+ throw providerHttpError({
714
+ provider: this.config.name,
715
+ operation: "chat",
716
+ status,
717
+ body,
718
+ ...status === 404 ? {
719
+ code: "MODEL_NOT_FOUND"
720
+ } : {}
721
+ });
542
722
  }
543
723
  const payload = body;
544
724
  const choices = Array.isArray(payload.choices) ? payload.choices : [];
@@ -560,6 +740,81 @@ var OpenAICompatibleProvider = class {
560
740
  };
561
741
  }
562
742
  };
743
+ async function streamChatCompletion(config, request, onChunk, env = process.env) {
744
+ const baseUrl = normalizeBaseUrl(config.baseUrl);
745
+ const timeoutMs = config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
746
+ let response;
747
+ try {
748
+ response = await fetch(`${baseUrl}/chat/completions`, {
749
+ method: "POST",
750
+ headers: {
751
+ accept: "text/event-stream",
752
+ "content-type": "application/json",
753
+ ...authHeaders(config, env)
754
+ },
755
+ body: JSON.stringify({
756
+ model: request.model,
757
+ messages: request.messages,
758
+ stream: true,
759
+ ...request.temperature !== void 0 ? { temperature: request.temperature } : {},
760
+ ...request.maxTokens !== void 0 ? { max_tokens: request.maxTokens } : {}
761
+ }),
762
+ signal: AbortSignal.timeout(timeoutMs)
763
+ });
764
+ } catch (error) {
765
+ throw providerTransportError({ provider: config.name, operation: "stream", cause: error });
766
+ }
767
+ if (!response.ok) {
768
+ const body = await response.json().catch(() => void 0);
769
+ throw providerHttpError({ provider: config.name, operation: "stream", status: response.status, body });
770
+ }
771
+ if (!response.body) {
772
+ throw providerHttpError({
773
+ provider: config.name,
774
+ operation: "stream",
775
+ status: 500,
776
+ body: { error: { message: "streaming is not supported by this endpoint" } }
777
+ });
778
+ }
779
+ let content = "";
780
+ let model = request.model;
781
+ let finishReason;
782
+ const decoder = new TextDecoder();
783
+ let buffer = "";
784
+ for await (const piece of response.body) {
785
+ buffer += decoder.decode(piece, { stream: true });
786
+ let newline = buffer.indexOf("\n");
787
+ while (newline >= 0) {
788
+ const line = buffer.slice(0, newline).trim();
789
+ buffer = buffer.slice(newline + 1);
790
+ newline = buffer.indexOf("\n");
791
+ if (line.length === 0 || line.startsWith(":")) continue;
792
+ const data = line.startsWith("data:") ? line.slice(5).trim() : line;
793
+ if (data === "[DONE]") continue;
794
+ let event;
795
+ try {
796
+ event = JSON.parse(data);
797
+ } catch {
798
+ continue;
799
+ }
800
+ if (typeof event.model === "string") model = event.model;
801
+ const choices = Array.isArray(event.choices) ? event.choices : [];
802
+ const first = choices[0];
803
+ const delta = typeof first?.delta?.content === "string" ? first.delta.content : "";
804
+ if (delta.length > 0) content += delta;
805
+ if (typeof first?.finish_reason === "string") finishReason = first.finish_reason;
806
+ onChunk({ delta, ...finishReason !== void 0 ? { finishReason } : {}, raw: event });
807
+ }
808
+ }
809
+ return { model, content, ...finishReason !== void 0 ? { finishReason } : {} };
810
+ }
811
+ function normalizeModalities(value) {
812
+ const list = Array.isArray(value) ? value : typeof value === "string" ? [value] : void 0;
813
+ if (!list) return void 0;
814
+ const allowed = /* @__PURE__ */ new Set(["text", "image", "audio", "video", "embedding"]);
815
+ const modalities = list.filter((m) => typeof m === "string" && allowed.has(m));
816
+ return modalities.length > 0 ? modalities : void 0;
817
+ }
563
818
  function normalizeBaseUrl(baseUrl) {
564
819
  return baseUrl.replace(/\/+$/, "");
565
820
  }
@@ -574,14 +829,6 @@ function extractModelEntries(body, config) {
574
829
  { details: { endpoint: "models" } }
575
830
  );
576
831
  }
577
- function authFailed(config, status) {
578
- const envHint = config.apiKeyEnvVar ? ` Check that $${config.apiKeyEnvVar} is set and valid.` : " The provider requires authentication; configure api_key_env_var for it.";
579
- return new ProviderError(
580
- "PROVIDER_AUTH_FAILED",
581
- `Authentication with provider "${config.name}" failed (HTTP ${status}).${envHint}`,
582
- { details: { status } }
583
- );
584
- }
585
832
  function unavailable(config, message, status) {
586
833
  return new ProviderError("PROVIDER_UNAVAILABLE", `Provider "${config.name}": ${message}`, {
587
834
  details: { status }
@@ -594,11 +841,33 @@ function inferHealth(error) {
594
841
 
595
842
  // src/providers/omnihilbras/index.ts
596
843
  var OmniHilbrasProvider = class extends OpenAICompatibleProvider {
844
+ gateway;
845
+ async listModels() {
846
+ const raw = await this.rawModelsResponse();
847
+ await this.extractGatewayMetadata(raw);
848
+ const models = await super.listModels();
849
+ return this.gateway ? models.map((model) => ({ ...model, metadata: { ...model.metadata, gateway: this.gateway } })) : models;
850
+ }
597
851
  /**
598
852
  * Run the full connect flow. Throws on authentication or transport failure
599
853
  * (use healthCheck() for a non-throwing probe).
600
854
  */
601
- async connect(registry) {
855
+ /**
856
+ * Gateway metadata reported alongside the model list (gateway version, tier,
857
+ * region…), kept separate from per-model metadata (§11 — provider metadata).
858
+ */
859
+ gatewayMetadata() {
860
+ return this.gateway;
861
+ }
862
+ async extractGatewayMetadata(body) {
863
+ if (body !== null && typeof body === "object") {
864
+ const gateway = body.gateway;
865
+ if (gateway !== null && typeof gateway === "object") {
866
+ this.gateway = gateway;
867
+ }
868
+ }
869
+ }
870
+ async connectAndRegister(registry) {
602
871
  const apiKey = resolveApiKey(this.config);
603
872
  const discovered = await this.listModels();
604
873
  const seen = /* @__PURE__ */ new Set();
@@ -822,7 +1091,7 @@ async function testProvider(name, options = {}) {
822
1091
  if (options.connect && provider instanceof OmniHilbrasProvider) {
823
1092
  const registry = new ModelRegistry();
824
1093
  try {
825
- const result = await provider.connect(registry);
1094
+ const result = await provider.connectAndRegister(registry);
826
1095
  printProviderStatus(result.status);
827
1096
  console.log(` registered: ${result.registered}`);
828
1097
  } catch (error) {
@@ -897,19 +1166,416 @@ function registerModelsCommand(program) {
897
1166
 
898
1167
  // src/agents/process/index.ts
899
1168
  import { spawn } from "child_process";
900
- var DEFAULT_TIMEOUT_MS2 = 6e5;
901
- var KILL_GRACE_MS = 5e3;
1169
+
1170
+ // src/agent-protocol/version.ts
1171
+ var PROTOCOL_V2 = "omninode-agent-protocol/2";
1172
+ var SUPPORTED_PROTOCOL_VERSIONS = [
1173
+ "omninode-agent-protocol/1",
1174
+ PROTOCOL_V2
1175
+ ];
1176
+ var MAX_MESSAGE_BYTES = 1048576;
1177
+ function isSupportedProtocol(protocol) {
1178
+ return SUPPORTED_PROTOCOL_VERSIONS.includes(protocol);
1179
+ }
1180
+
1181
+ // src/agent-protocol/types.ts
1182
+ function newMessageId(prefix = "msg") {
1183
+ const random = Math.random().toString(36).slice(2, 10);
1184
+ return `${prefix}-${Date.now().toString(36)}-${random}`;
1185
+ }
1186
+ function envelope(type, payload, ids = {}) {
1187
+ return {
1188
+ protocol: PROTOCOL_V2,
1189
+ messageId: newMessageId(),
1190
+ ...ids.requestId !== void 0 ? { requestId: ids.requestId } : {},
1191
+ ...ids.taskId !== void 0 ? { taskId: ids.taskId } : {},
1192
+ ...ids.agentId !== void 0 ? { agentId: ids.agentId } : {},
1193
+ ...ids.pipelineId !== void 0 ? { pipelineId: ids.pipelineId } : {},
1194
+ ...ids.executionId !== void 0 ? { executionId: ids.executionId } : {},
1195
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1196
+ type,
1197
+ payload,
1198
+ ...ids.metadata !== void 0 ? { metadata: ids.metadata } : {}
1199
+ };
1200
+ }
1201
+
1202
+ // src/agent-protocol/codec.ts
1203
+ var KNOWN_TYPES = /* @__PURE__ */ new Set([
1204
+ // node → agent
1205
+ "TASK",
1206
+ "HELLO_ACK",
1207
+ "CONTEXT",
1208
+ "INSTRUCTION",
1209
+ "RESPONSE",
1210
+ "CANCEL",
1211
+ // agent → node
1212
+ "HELLO",
1213
+ "TASK_REQUEST",
1214
+ "TASK_ACCEPTED",
1215
+ "TASK_STARTED",
1216
+ "STATUS",
1217
+ "QUESTION",
1218
+ "REPORT",
1219
+ "ARTIFACT",
1220
+ "COMPLETION",
1221
+ "ERROR",
1222
+ "CANCELLED"
1223
+ ]);
1224
+ var PAYLOAD_REQUIRED_TYPES = /* @__PURE__ */ new Set(["TASK", "REPORT", "COMPLETION", "ERROR", "QUESTION", "ARTIFACT", "HELLO"]);
1225
+ function validateEnvelope(value, line) {
1226
+ const sample = line?.slice(0, 200);
1227
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
1228
+ return { ok: false, error: { code: "NOT_AN_OBJECT", message: "message must be a JSON object", ...sample ? { sample } : {} } };
1229
+ }
1230
+ const raw = value;
1231
+ const protocol = raw.protocol;
1232
+ if (protocol === void 0) {
1233
+ const type = raw.type;
1234
+ if (typeof type !== "string" || !KNOWN_TYPES.has(type)) {
1235
+ return { ok: false, error: { code: "UNKNOWN_TYPE", message: `unknown message type ${String(type)}`, ...sample ? { sample } : {} } };
1236
+ }
1237
+ return {
1238
+ ok: true,
1239
+ legacy: true,
1240
+ envelope: {
1241
+ protocol: "omninode-agent-protocol/1",
1242
+ messageId: typeof raw.messageId === "string" ? raw.messageId : "legacy",
1243
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1244
+ type,
1245
+ payload: raw.payload
1246
+ }
1247
+ };
1248
+ }
1249
+ if (typeof protocol !== "string" || !isSupportedProtocol(protocol)) {
1250
+ return {
1251
+ ok: false,
1252
+ error: { code: "UNSUPPORTED_VERSION", message: `unsupported protocol "${String(protocol)}"`, ...sample ? { sample } : {} }
1253
+ };
1254
+ }
1255
+ if (raw.type === void 0) {
1256
+ return { ok: false, error: { code: "MISSING_FIELD", message: "message is missing 'type'", ...sample ? { sample } : {} } };
1257
+ }
1258
+ if (typeof raw.type !== "string" || !KNOWN_TYPES.has(raw.type)) {
1259
+ return { ok: false, error: { code: "UNKNOWN_TYPE", message: `unknown message type ${String(raw.type)}`, ...sample ? { sample } : {} } };
1260
+ }
1261
+ if (raw.timestamp !== void 0 && typeof raw.timestamp !== "string") {
1262
+ return { ok: false, error: { code: "MISSING_FIELD", message: "'timestamp' must be a string", ...sample ? { sample } : {} } };
1263
+ }
1264
+ if (PAYLOAD_REQUIRED_TYPES.has(raw.type) && (raw.payload === void 0 || raw.payload === null)) {
1265
+ return { ok: false, error: { code: "INVALID_PAYLOAD", message: `${raw.type} requires a payload`, ...sample ? { sample } : {} } };
1266
+ }
1267
+ return {
1268
+ ok: true,
1269
+ legacy: protocol !== PROTOCOL_V2,
1270
+ envelope: {
1271
+ protocol,
1272
+ messageId: typeof raw.messageId === "string" ? raw.messageId : "unknown",
1273
+ ...typeof raw.requestId === "string" ? { requestId: raw.requestId } : {},
1274
+ ...typeof raw.taskId === "string" ? { taskId: raw.taskId } : {},
1275
+ ...typeof raw.agentId === "string" ? { agentId: raw.agentId } : {},
1276
+ ...typeof raw.pipelineId === "string" ? { pipelineId: raw.pipelineId } : {},
1277
+ ...typeof raw.executionId === "string" ? { executionId: raw.executionId } : {},
1278
+ timestamp: typeof raw.timestamp === "string" ? raw.timestamp : (/* @__PURE__ */ new Date()).toISOString(),
1279
+ type: raw.type,
1280
+ payload: raw.payload,
1281
+ ...raw.metadata !== null && typeof raw.metadata === "object" ? { metadata: raw.metadata } : {}
1282
+ }
1283
+ };
1284
+ }
1285
+ function decodeMessage(line, maxBytes = MAX_MESSAGE_BYTES) {
1286
+ const trimmed = line.trim();
1287
+ if (trimmed.length === 0) {
1288
+ return { ok: false, error: { code: "INVALID_JSON", message: "empty line" } };
1289
+ }
1290
+ if (trimmed.length > maxBytes) {
1291
+ return {
1292
+ ok: false,
1293
+ error: { code: "OVERSIZED", message: `message of ${trimmed.length} bytes exceeds the ${maxBytes} byte limit`, sample: trimmed.slice(0, 200) }
1294
+ };
1295
+ }
1296
+ let value;
1297
+ try {
1298
+ value = JSON.parse(trimmed);
1299
+ } catch (error) {
1300
+ return {
1301
+ ok: false,
1302
+ error: { code: "INVALID_JSON", message: error instanceof Error ? error.message : "invalid JSON", sample: trimmed.slice(0, 200) }
1303
+ };
1304
+ }
1305
+ return validateEnvelope(value, trimmed);
1306
+ }
1307
+ function encodeMessage(envelope2, maxBytes = MAX_MESSAGE_BYTES) {
1308
+ const line = JSON.stringify(envelope2);
1309
+ if (line.length > maxBytes) {
1310
+ throw new Error(`outbound protocol message of ${line.length} bytes exceeds the ${maxBytes} byte limit`);
1311
+ }
1312
+ return line;
1313
+ }
1314
+ var ProtocolDecoder = class {
1315
+ buffer = "";
1316
+ maxBytes;
1317
+ constructor(maxBytes = MAX_MESSAGE_BYTES) {
1318
+ this.maxBytes = maxBytes;
1319
+ }
1320
+ push(chunk) {
1321
+ this.buffer += chunk;
1322
+ const results = [];
1323
+ let index = this.buffer.indexOf("\n");
1324
+ while (index >= 0) {
1325
+ const line = this.buffer.slice(0, index);
1326
+ this.buffer = this.buffer.slice(index + 1);
1327
+ if (line.trim().length > 0) results.push(decodeMessage(line, this.maxBytes));
1328
+ index = this.buffer.indexOf("\n");
1329
+ }
1330
+ if (this.buffer.length > this.maxBytes) {
1331
+ results.push({
1332
+ ok: false,
1333
+ error: { code: "OVERSIZED", message: `line exceeded ${this.maxBytes} bytes without a newline`, sample: this.buffer.slice(0, 200) }
1334
+ });
1335
+ this.buffer = "";
1336
+ }
1337
+ return results;
1338
+ }
1339
+ /** Flush any trailing line when the process exits. */
1340
+ flush() {
1341
+ if (this.buffer.trim().length === 0) {
1342
+ this.buffer = "";
1343
+ return [];
1344
+ }
1345
+ const result = decodeMessage(this.buffer, this.maxBytes);
1346
+ this.buffer = "";
1347
+ return [result];
1348
+ }
1349
+ };
1350
+
1351
+ // src/agent-protocol/handshake.ts
1352
+ function parseHello(envelopeValue) {
1353
+ if (envelopeValue.type !== "HELLO") {
1354
+ return { ok: false, reason: `expected HELLO, received ${envelopeValue.type}` };
1355
+ }
1356
+ const payload = envelopeValue.payload;
1357
+ if (payload === null || typeof payload !== "object") {
1358
+ return { ok: false, reason: "HELLO payload must be an object" };
1359
+ }
1360
+ const p = payload;
1361
+ if (typeof p.name !== "string" || p.name.length === 0) {
1362
+ return { ok: false, reason: "HELLO payload is missing 'name'" };
1363
+ }
1364
+ return {
1365
+ ok: true,
1366
+ descriptor: {
1367
+ protocol: envelopeValue.protocol,
1368
+ name: p.name,
1369
+ ...typeof p.version === "string" ? { version: p.version } : {},
1370
+ capabilities: Array.isArray(p.capabilities) ? p.capabilities.filter((c) => typeof c === "string") : [],
1371
+ supports: Array.isArray(p.supports) ? p.supports.filter((s) => typeof s === "string") : []
1372
+ }
1373
+ };
1374
+ }
1375
+ function buildHelloAck(descriptor) {
1376
+ return envelope("HELLO_ACK", {
1377
+ ok: true,
1378
+ protocol: PROTOCOL_V2,
1379
+ agent: descriptor.name,
1380
+ ...descriptor.version !== void 0 ? { agentVersion: descriptor.version } : {},
1381
+ supportedProtocols: [PROTOCOL_V2]
1382
+ }, { agentId: descriptor.name });
1383
+ }
1384
+
1385
+ // src/agent-protocol/session.ts
1386
+ var ProtocolSession = class {
1387
+ decoder;
1388
+ ids;
1389
+ responder;
1390
+ violations = [];
1391
+ questions = [];
1392
+ artifacts = [];
1393
+ reports = [];
1394
+ descriptor;
1395
+ completionSummary;
1396
+ errorMessage;
1397
+ constructor(ids, options = {}) {
1398
+ this.ids = ids;
1399
+ this.responder = options.responder;
1400
+ this.decoder = new ProtocolDecoder(options.maxBytes);
1401
+ }
1402
+ /** The v2 messages OmniNode sends to open a task (written to the agent's stdin). */
1403
+ taskMessages(objective, role, context, instruction) {
1404
+ const ids = this.ids;
1405
+ const messages = [envelope("TASK", { taskId: ids.taskId, objective }, ids)];
1406
+ if (role !== void 0) messages.push(envelope("CONTEXT", { role }, ids));
1407
+ if (context !== void 0 && context.length > 0) messages.push(envelope("CONTEXT", { body: context }, ids));
1408
+ if (instruction !== void 0 && instruction.length > 0) messages.push(envelope("INSTRUCTION", { text: instruction }, ids));
1409
+ return messages.map((m) => encodeMessage(m));
1410
+ }
1411
+ helloAck(descriptor) {
1412
+ return encodeMessage(buildHelloAck(descriptor));
1413
+ }
1414
+ /** Consumes decoded stdout, answering questions as they arrive. */
1415
+ async handle(decoded) {
1416
+ if (!decoded.ok) {
1417
+ this.violations.push(decoded.error);
1418
+ return;
1419
+ }
1420
+ const message = decoded.envelope;
1421
+ if (message.protocol === PROTOCOL_V2) this.sawV2 = true;
1422
+ if (message.type === "HELLO") {
1423
+ const hello = parseHello(message);
1424
+ if (hello.ok) this.descriptor = hello.descriptor;
1425
+ else this.violations.push({ code: "INVALID_PAYLOAD", message: `bad HELLO: ${hello.reason}` });
1426
+ return;
1427
+ }
1428
+ if (message.type === "QUESTION") {
1429
+ await this.answerQuestion(message);
1430
+ return;
1431
+ }
1432
+ if (message.type === "REPORT") {
1433
+ this.reports.push(message.payload);
1434
+ return;
1435
+ }
1436
+ if (message.type === "ARTIFACT") {
1437
+ this.artifacts.push(message.payload);
1438
+ return;
1439
+ }
1440
+ if (message.type === "COMPLETION") {
1441
+ const payload = message.payload;
1442
+ this.completionSummary = typeof payload?.summary === "string" ? payload.summary : "";
1443
+ return;
1444
+ }
1445
+ if (message.type === "ERROR") {
1446
+ const payload = message.payload;
1447
+ const detail = typeof payload?.message === "string" ? payload.message : "agent reported an error";
1448
+ const code = typeof payload?.code === "string" ? ` [${payload.code}]` : "";
1449
+ this.errorMessage = `Agent error${code}: ${detail}`;
1450
+ return;
1451
+ }
1452
+ if (message.type === "TASK_ACCEPTED" || message.type === "TASK_STARTED" || message.type === "STATUS") {
1453
+ return;
1454
+ }
1455
+ this.violations.push({
1456
+ code: "UNKNOWN_TYPE",
1457
+ message: `unexpected message type ${message.type} from agent`
1458
+ });
1459
+ }
1460
+ async answerQuestion(message) {
1461
+ const payload = message.payload ?? {};
1462
+ const questionId = typeof payload.questionId === "string" ? payload.questionId : message.messageId;
1463
+ const question = typeof payload.question === "string" ? payload.question : "";
1464
+ const expected = typeof payload.expected === "string" ? payload.expected : "text";
1465
+ let answer;
1466
+ if (this.responder) {
1467
+ try {
1468
+ answer = await this.responder({ questionId, question, expected });
1469
+ } catch (error) {
1470
+ this.violations.push({
1471
+ code: "INVALID_PAYLOAD",
1472
+ message: `question responder failed: ${error instanceof Error ? error.message : String(error)}`
1473
+ });
1474
+ }
1475
+ }
1476
+ this.questions.push({ questionId, question, ...answer !== void 0 ? { answer } : {} });
1477
+ this.pendingAnswers.push(
1478
+ encodeMessage(
1479
+ envelope("RESPONSE", { questionId, ...answer !== void 0 ? { body: answer } : {} }, {
1480
+ ...this.ids,
1481
+ requestId: message.requestId ?? this.ids.requestId
1482
+ })
1483
+ )
1484
+ );
1485
+ }
1486
+ pendingAnswers = [];
1487
+ /** True once any inbound message carried the v2 protocol field. */
1488
+ sawV2 = false;
1489
+ };
1490
+
1491
+ // src/agents/cwd.ts
1492
+ import { statSync } from "fs";
1493
+ import { isAbsolute, relative, resolve } from "path";
1494
+ function validateWorkingDirectory(cwd, options = {}) {
1495
+ if (!cwd) return void 0;
1496
+ const normalized = isAbsolute(cwd) ? resolve(cwd) : resolve(process.cwd(), cwd);
1497
+ let stats;
1498
+ try {
1499
+ stats = statSync(normalized);
1500
+ } catch (error) {
1501
+ throw new AgentError(
1502
+ "AGENT_NOT_FOUND",
1503
+ `Working directory "${normalized}" does not exist or is not accessible.`,
1504
+ { cause: error, details: { cwd: normalized } }
1505
+ );
1506
+ }
1507
+ if (!stats.isDirectory()) {
1508
+ throw new AgentError("AGENT_NOT_FOUND", `Working directory "${normalized}" is not a directory.`, {
1509
+ details: { cwd: normalized }
1510
+ });
1511
+ }
1512
+ try {
1513
+ statSync(resolve(normalized, "."));
1514
+ } catch (error) {
1515
+ throw new AgentError(
1516
+ "AGENT_NOT_FOUND",
1517
+ `Working directory "${normalized}" is not accessible.`,
1518
+ { cause: error }
1519
+ );
1520
+ }
1521
+ const projectRoot = resolve(options.projectRoot ?? process.cwd());
1522
+ const rel = relative(projectRoot, normalized);
1523
+ const outsideProject = rel.startsWith("..") || isAbsolute(rel);
1524
+ if (outsideProject && options.allowExternal !== true) {
1525
+ throw new AgentError(
1526
+ "AGENT_NOT_FOUND",
1527
+ `Working directory "${normalized}" is outside the project root "${projectRoot}". Set allow_external_cwd: true on the agent to permit this.`,
1528
+ { details: { cwd: normalized, projectRoot } }
1529
+ );
1530
+ }
1531
+ return { cwd: normalized, outsideProject };
1532
+ }
1533
+
1534
+ // src/agents/env.ts
1535
+ var BASELINE_KEYS = ["PATH", "HOME"];
1536
+ function resolveEnvPolicy(config) {
1537
+ if (config.envPolicy !== void 0) return config.envPolicy;
1538
+ if (config.inheritEnv === false) return "explicit";
1539
+ return "inherit";
1540
+ }
902
1541
  function buildChildEnv(config) {
903
1542
  const configured = config.env ?? {};
904
- if (config.inheritEnv !== false) {
1543
+ const policy = resolveEnvPolicy(config);
1544
+ if (policy === "inherit") {
905
1545
  return { ...process.env, ...configured };
906
1546
  }
907
- return {
908
- PATH: process.env.PATH ?? "",
909
- HOME: process.env.HOME ?? "",
910
- ...configured
911
- };
1547
+ const base = {};
1548
+ for (const key of BASELINE_KEYS) {
1549
+ const value = process.env[key];
1550
+ if (value !== void 0) base[key] = value;
1551
+ }
1552
+ switch (policy) {
1553
+ case "explicit":
1554
+ return { ...base, ...configured };
1555
+ case "allowlist": {
1556
+ const allowed = { ...base };
1557
+ for (const key of config.envAllowlist ?? []) {
1558
+ const value = process.env[key];
1559
+ if (value !== void 0) allowed[key] = value;
1560
+ }
1561
+ return { ...allowed, ...configured };
1562
+ }
1563
+ case "denylist": {
1564
+ const denied = new Set((config.envDenylist ?? []).map((k) => k.toUpperCase()));
1565
+ const filtered = {};
1566
+ for (const [key, value] of Object.entries(process.env)) {
1567
+ if (denied.has(key.toUpperCase())) continue;
1568
+ filtered[key] = value;
1569
+ }
1570
+ return { ...filtered, ...configured };
1571
+ }
1572
+ }
912
1573
  }
1574
+
1575
+ // src/agents/process/index.ts
1576
+ var DEFAULT_TIMEOUT_MS2 = 6e5;
1577
+ var KILL_GRACE_MS = 5e3;
1578
+ var DEFAULT_MAX_OUTPUT_BYTES = 5 * 1024 * 1024;
913
1579
  function composeTaskText(input) {
914
1580
  const sections = [`# Objective
915
1581
  ${input.objective}`];
@@ -930,18 +1596,19 @@ ${input.instruction}`);
930
1596
  return sections.join("\n\n");
931
1597
  }
932
1598
  function buildProtocolMessages(input, agentName) {
933
- const meta = {
1599
+ const session = new ProtocolSession({
1600
+ requestId: `req-${input.taskId}`,
934
1601
  taskId: input.taskId,
935
- agent: agentName,
936
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
937
- };
938
- const messages = [
939
- { type: "TASK", payload: { taskId: input.taskId, objective: input.objective } }
940
- ];
941
- if (input.role) messages.push({ type: "ROLE", payload: input.role });
942
- if (input.context) messages.push({ type: "CONTEXT", payload: { body: input.context } });
943
- if (input.instruction) messages.push({ type: "INSTRUCTION", payload: { text: input.instruction } });
944
- return messages.map((message) => JSON.stringify({ meta, message }));
1602
+ agentId: agentName,
1603
+ ...input.pipelineId !== void 0 ? { pipelineId: input.pipelineId } : {},
1604
+ ...input.executionId !== void 0 ? { executionId: input.executionId } : {}
1605
+ });
1606
+ return session.taskMessages(
1607
+ input.objective,
1608
+ input.role,
1609
+ input.context,
1610
+ input.instruction
1611
+ );
945
1612
  }
946
1613
  var ProcessAgent = class {
947
1614
  config;
@@ -970,40 +1637,73 @@ var ProcessAgent = class {
970
1637
  }
971
1638
  const mode = this.config.inputMode ?? "stdin";
972
1639
  const timeoutMs = this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
973
- return new Promise((resolve, reject) => {
1640
+ return new Promise((resolve2, reject) => {
1641
+ let cwd;
1642
+ try {
1643
+ cwd = validateWorkingDirectory(this.config.cwd, {
1644
+ allowExternal: this.config.allowExternalCwd
1645
+ })?.cwd;
1646
+ } catch (error) {
1647
+ reject(error);
1648
+ return;
1649
+ }
974
1650
  const child = spawn(
975
1651
  command,
976
1652
  [...this.config.args ?? [], ...mode === "arg" ? [composeTaskText(input)] : []],
977
1653
  {
978
- cwd: this.config.cwd,
1654
+ ...cwd !== void 0 ? { cwd } : {},
979
1655
  env: buildChildEnv(this.config),
980
- stdio: ["pipe", "pipe", "pipe"]
1656
+ stdio: ["pipe", "pipe", "pipe"],
1657
+ // Own process group so the whole tree can be terminated together
1658
+ // (roadmap §8: no orphaned children).
1659
+ ...process.platform === "win32" ? {} : { detached: true }
981
1660
  }
982
1661
  );
983
1662
  this.running.set(input.taskId, child);
1663
+ GLOBAL_RUNNING.add(child);
1664
+ installSignalForwarding();
984
1665
  let stdout = "";
985
1666
  let stderr = "";
986
1667
  let timedOut = false;
1668
+ let outputLimitExceeded = false;
987
1669
  let settled = false;
1670
+ const maxOutputBytes = this.config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
988
1671
  const timer = setTimeout(() => {
989
1672
  timedOut = true;
990
- this.log.warn(`Task ${input.taskId} exceeded ${timeoutMs}ms \u2014 killing process.`);
991
- child.kill("SIGTERM");
992
- setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS).unref();
1673
+ this.log.warn(`Task ${input.taskId} exceeded ${timeoutMs}ms \u2014 killing process tree.`);
1674
+ killTree(child, "SIGTERM");
1675
+ setTimeout(() => killTree(child, "SIGKILL"), KILL_GRACE_MS).unref();
993
1676
  }, timeoutMs);
994
1677
  const settle = (finish) => {
995
1678
  if (settled) return;
996
1679
  settled = true;
997
1680
  clearTimeout(timer);
998
1681
  this.running.delete(input.taskId);
1682
+ GLOBAL_RUNNING.delete(child);
999
1683
  finish();
1000
1684
  };
1001
- child.stdout?.on("data", (chunk) => {
1002
- stdout += chunk.toString();
1003
- });
1004
- child.stderr?.on("data", (chunk) => {
1005
- stderr += chunk.toString();
1006
- });
1685
+ const collect = (into, chunk) => {
1686
+ if (outputLimitExceeded) return;
1687
+ const text = sanitizeChunk(chunk);
1688
+ if (into === "stdout") {
1689
+ if (stdout.length + text.length > maxOutputBytes) {
1690
+ stdout = (stdout + text).slice(0, maxOutputBytes);
1691
+ outputLimitExceeded = true;
1692
+ this.log.warn(`Agent "${this.config.name}" exceeded the ${maxOutputBytes} byte output limit \u2014 killing it.`);
1693
+ killTree(child, "SIGKILL");
1694
+ return;
1695
+ }
1696
+ stdout += text;
1697
+ } else {
1698
+ if (stderr.length + text.length > maxOutputBytes) {
1699
+ stderr = (stderr + text).slice(0, maxOutputBytes);
1700
+ return;
1701
+ }
1702
+ stderr += text;
1703
+ }
1704
+ };
1705
+ child.stdout?.on("data", (chunk) => collect("stdout", chunk));
1706
+ child.stderr?.on("data", (chunk) => collect("stderr", chunk));
1007
1707
  child.stdin?.on("error", () => {
1008
1708
  });
1009
1709
  if (mode !== "arg") {
@@ -1037,7 +1737,19 @@ var ProcessAgent = class {
1037
1737
  new AgentError(
1038
1738
  "AGENT_TIMEOUT",
1039
1739
  `Agent "${this.config.name}" timed out after ${timeoutMs}ms and was killed.`,
1040
- { details: { taskId: input.taskId, stdout: stdout.slice(0, 4e3), stderr: stderr.slice(0, 2e3) } }
1740
+ {
1741
+ details: {
1742
+ operation: "agent.run",
1743
+ agent: this.config.name,
1744
+ durationMs: timeoutMs,
1745
+ // The process was started and may have performed side
1746
+ // effects before being killed — the outcome is unknown,
1747
+ // not failed (roadmap §6.3).
1748
+ dispatched: true,
1749
+ stdout: stdout.slice(0, 4e3),
1750
+ stderr: stderr.slice(0, 2e3)
1751
+ }
1752
+ }
1041
1753
  )
1042
1754
  );
1043
1755
  return;
@@ -1045,7 +1757,7 @@ var ProcessAgent = class {
1045
1757
  const logs = stderr.trim().length > 0 ? stderr.split("\n") : void 0;
1046
1758
  if (mode === "protocol") {
1047
1759
  try {
1048
- resolve(
1760
+ resolve2(
1049
1761
  interpretProtocolRun({
1050
1762
  input,
1051
1763
  agentName: this.config.name,
@@ -1060,14 +1772,14 @@ var ProcessAgent = class {
1060
1772
  return;
1061
1773
  }
1062
1774
  const trimmed = stdout.trim();
1063
- resolve({
1775
+ resolve2({
1064
1776
  taskId: input.taskId,
1065
- status: code === 0 ? "completed" : "failed",
1777
+ status: code === 0 && !outputLimitExceeded ? "completed" : "failed",
1066
1778
  summary: trimmed.length > 0 ? firstLine(trimmed) : void 0,
1067
1779
  rawOutput: trimmed.length > 0 ? trimmed : void 0,
1068
1780
  exitCode: code ?? void 0,
1069
1781
  logs,
1070
- error: code === 0 ? void 0 : `Process exited with code ${code ?? "unknown"}${signal ? ` (signal: ${signal})` : ""}`
1782
+ ...outputLimitExceeded ? { error: `Agent exceeded the ${maxOutputBytes} byte output limit and was terminated.` } : code === 0 ? {} : { error: describeExit(code, signal) }
1071
1783
  });
1072
1784
  });
1073
1785
  });
@@ -1076,67 +1788,117 @@ var ProcessAgent = class {
1076
1788
  /** Ask the agent to stop current work (SIGTERM). */
1077
1789
  async cancel(taskId) {
1078
1790
  const child = this.running.get(taskId);
1079
- if (child) child.kill("SIGTERM");
1791
+ if (child) killTree(child, "SIGTERM");
1080
1792
  }
1081
1793
  runningTaskIds() {
1082
1794
  return [...this.running.keys()];
1083
1795
  }
1084
1796
  };
1085
- function interpretProtocolRun({ input, agentName, stdout, exitCode, logs }) {
1086
- const reports = [];
1087
- let summary;
1088
- let error;
1089
- let sawProtocolMessage = false;
1090
- for (const line of stdout.split("\n")) {
1091
- const trimmed2 = line.trim();
1092
- if (trimmed2.length === 0) continue;
1093
- const message = parseAgentMessage(trimmed2);
1094
- if (!message) continue;
1095
- sawProtocolMessage = true;
1096
- if (message.type === "REPORT") {
1097
- const report = normalizeReport(message.payload, input.taskId, agentName, reports.length);
1098
- if (report) reports.push(report);
1099
- } else if (message.type === "COMPLETION") {
1100
- const payload = message.payload;
1101
- if (typeof payload?.summary === "string") summary = payload.summary;
1102
- } else if (message.type === "ERROR") {
1103
- const payload = message.payload;
1104
- const detail = typeof payload?.message === "string" ? payload.message : "unknown error";
1105
- const code = typeof payload?.code === "string" ? ` [${payload.code}]` : "";
1106
- error = `Agent error${code}: ${detail}`;
1107
- }
1797
+ function interpretProtocolRun({
1798
+ input,
1799
+ agentName,
1800
+ stdout,
1801
+ exitCode,
1802
+ logs,
1803
+ responder
1804
+ }) {
1805
+ const session = new ProtocolSession(
1806
+ {
1807
+ requestId: `req-${input.taskId}`,
1808
+ taskId: input.taskId,
1809
+ agentId: agentName,
1810
+ ...input.pipelineId !== void 0 ? { pipelineId: input.pipelineId } : {},
1811
+ ...input.executionId !== void 0 ? { executionId: input.executionId } : {}
1812
+ },
1813
+ { ...responder !== void 0 ? { responder } : {} }
1814
+ );
1815
+ for (const result of session.decoder.push(stdout)) {
1816
+ session.handle(result);
1108
1817
  }
1109
- if (!sawProtocolMessage && exitCode === 0) {
1110
- throw new ProtocolError(
1111
- `Agent "${agentName}" produced no protocol messages on stdout (input mode "protocol").`,
1112
- { details: { taskId: input.taskId, stdout: stdout.slice(0, 2e3) } }
1113
- );
1818
+ for (const result of session.decoder.flush()) {
1819
+ session.handle(result);
1114
1820
  }
1821
+ const reports = session.reports.flatMap((payload) => Array.isArray(payload) ? payload : [payload]).map((payload, index) => normalizeReport(payload, input.taskId, agentName, index)).filter((report) => report !== void 0);
1115
1822
  const trimmed = stdout.trim();
1823
+ const protocolBlock = {
1824
+ protocol: session.descriptor?.protocol ?? PROTOCOL_V2,
1825
+ legacy: !session.sawV2 && session.descriptor === void 0,
1826
+ ...session.descriptor !== void 0 ? { descriptor: session.descriptor } : {},
1827
+ questions: session.questions,
1828
+ violations: session.violations.map((v) => `${v.code}: ${v.message}`)
1829
+ };
1830
+ if (session.errorMessage !== void 0) {
1831
+ return {
1832
+ taskId: input.taskId,
1833
+ status: "failed",
1834
+ error: session.errorMessage,
1835
+ rawOutput: trimmed.length > 0 ? trimmed : void 0,
1836
+ exitCode: exitCode ?? void 0,
1837
+ logs,
1838
+ protocol: protocolBlock,
1839
+ ...reports.length > 0 ? { reports } : {}
1840
+ };
1841
+ }
1842
+ if (session.descriptor === void 0 && session.violations.length > 0 && session.reports.length === 0 && session.completionSummary === void 0) {
1843
+ return {
1844
+ taskId: input.taskId,
1845
+ status: "failed",
1846
+ error: `agent produced no valid protocol messages: ${protocolBlock.violations.join("; ")}`,
1847
+ rawOutput: trimmed.length > 0 ? trimmed : void 0,
1848
+ exitCode: exitCode ?? void 0,
1849
+ logs,
1850
+ protocol: protocolBlock
1851
+ };
1852
+ }
1853
+ const completed = exitCode === 0 && session.completionSummary !== void 0;
1116
1854
  return {
1117
1855
  taskId: input.taskId,
1118
- status: error !== void 0 || exitCode !== 0 ? "failed" : "completed",
1119
- summary: summary ?? reports[0]?.summary,
1856
+ status: completed || exitCode === 0 ? completed ? "completed" : "completed" : "failed",
1857
+ summary: session.completionSummary ?? reports[0]?.summary,
1120
1858
  rawOutput: trimmed.length > 0 ? trimmed : void 0,
1121
1859
  exitCode: exitCode ?? void 0,
1122
1860
  logs,
1123
- reports: reports.length > 0 ? reports : void 0,
1124
- error: error ?? (exitCode !== 0 ? `Process exited with code ${exitCode ?? "unknown"}` : void 0)
1861
+ protocol: protocolBlock,
1862
+ ...reports.length > 0 ? { reports } : {},
1863
+ ...exitCode !== 0 ? { error: `Process exited with code ${exitCode ?? "unknown"}` } : {}
1125
1864
  };
1126
1865
  }
1127
- function parseAgentMessage(line) {
1128
- try {
1129
- const parsed = JSON.parse(line);
1130
- if (typeof parsed.type === "string") {
1131
- return { type: parsed.type, payload: parsed.payload };
1132
- }
1133
- const inner = parsed.message;
1134
- if (inner && typeof inner.type === "string") {
1135
- return { type: inner.type, payload: inner.payload };
1866
+ var GLOBAL_RUNNING = /* @__PURE__ */ new Set();
1867
+ var signalForwardingInstalled = false;
1868
+ function installSignalForwarding() {
1869
+ if (signalForwardingInstalled) return;
1870
+ signalForwardingInstalled = true;
1871
+ for (const signal of ["SIGINT", "SIGTERM"]) {
1872
+ process.on(signal, () => {
1873
+ for (const child of GLOBAL_RUNNING) {
1874
+ killTree(child, "SIGTERM");
1875
+ }
1876
+ });
1877
+ }
1878
+ }
1879
+ function killTree(child, signal) {
1880
+ if (child.pid === void 0) return;
1881
+ if (process.platform !== "win32") {
1882
+ try {
1883
+ process.kill(-child.pid, signal);
1884
+ return;
1885
+ } catch {
1136
1886
  }
1887
+ }
1888
+ try {
1889
+ child.kill(signal);
1137
1890
  } catch {
1138
1891
  }
1139
- return void 0;
1892
+ }
1893
+ function describeExit(code, signal) {
1894
+ if (signal !== null) {
1895
+ return signal === "SIGKILL" ? "Process was killed (SIGKILL) \u2014 usually an external kill or OOM." : `Process terminated by signal ${signal}.`;
1896
+ }
1897
+ if (code === null) return "Process terminated without an exit code.";
1898
+ return `Process exited with code ${code}.`;
1899
+ }
1900
+ function sanitizeChunk(chunk) {
1901
+ return [...chunk.toString("utf8")].filter((char) => char.charCodeAt(0) !== 0).join("");
1140
1902
  }
1141
1903
  function normalizeReport(payload, taskId, agentName, index) {
1142
1904
  if (payload === null || typeof payload !== "object") return void 0;
@@ -1157,6 +1919,40 @@ function firstLine(text, maxLength = 200) {
1157
1919
  return line.length > maxLength ? `${line.slice(0, maxLength)}\u2026` : line;
1158
1920
  }
1159
1921
 
1922
+ // src/agents/adapters.ts
1923
+ var AgentAdapterRegistry = class {
1924
+ adapters = /* @__PURE__ */ new Map();
1925
+ register(adapter) {
1926
+ this.adapters.set(adapter.integration, adapter);
1927
+ }
1928
+ get(integration) {
1929
+ return this.adapters.get(integration);
1930
+ }
1931
+ list() {
1932
+ return [...this.adapters.values()];
1933
+ }
1934
+ create(config) {
1935
+ const adapter = this.get(config.integration);
1936
+ if (!adapter) {
1937
+ throw new AgentError(
1938
+ "AGENT_NOT_FOUND",
1939
+ `No agent adapter registered for integration "${config.integration}". Registered: ${[...this.adapters.keys()].join(", ") || "(none)"}.`
1940
+ );
1941
+ }
1942
+ return adapter.create(config);
1943
+ }
1944
+ };
1945
+ var ProcessAgentAdapter = class {
1946
+ constructor(factory) {
1947
+ this.factory = factory;
1948
+ }
1949
+ factory;
1950
+ integration = "process";
1951
+ create(config) {
1952
+ return this.factory(config);
1953
+ }
1954
+ };
1955
+
1160
1956
  // src/agents/registry.ts
1161
1957
  var AgentRegistry = class {
1162
1958
  agents = /* @__PURE__ */ new Map();
@@ -1172,14 +1968,15 @@ var AgentRegistry = class {
1172
1968
  };
1173
1969
 
1174
1970
  // src/agents/factory.ts
1175
- function createAgent(config) {
1176
- if (config.integration === "process") {
1177
- return new ProcessAgent(config);
1178
- }
1971
+ var defaultAgentAdapters = new AgentAdapterRegistry();
1972
+ defaultAgentAdapters.register(new ProcessAgentAdapter((config) => new ProcessAgent(config)));
1973
+ function createAgent(config, adapters = defaultAgentAdapters) {
1974
+ const registered = adapters.get(config.integration);
1975
+ if (registered) return registered.create(config);
1179
1976
  if (config.integration === "native") {
1180
1977
  throw new OmniNodeError(
1181
1978
  "NOT_IMPLEMENTED",
1182
- `Native agent adapters are planned for a later phase. Use input over stdin/stdout (type "cli") for "${config.name}" for now.`
1979
+ `No native adapter is registered for "${config.name}". Register an IAgentAdapter (createAgent(config, adapters)) or use input over stdin/stdout (type "cli").`
1183
1980
  );
1184
1981
  }
1185
1982
  throw new OmniNodeError("NOT_IMPLEMENTED", `Agent integration "${config.integration}" has no adapter yet.`);
@@ -1222,7 +2019,7 @@ function registerAgentCommands(program) {
1222
2019
  "--input-mode <mode>",
1223
2020
  "How the task is delivered: stdin (prompt on stdin), arg (prompt as last argument) or protocol (\xA721 JSON-lines task protocol).",
1224
2021
  "stdin"
1225
- ).option(
2022
+ ).option("--env-policy <policy>", "Environment policy: inherit, allowlist, denylist or explicit.").option("--allow-env <name>", "Variable to forward under the allowlist policy (repeatable).", collectArgs).option("--deny-env <name>", "Variable to remove under the denylist policy (repeatable).", collectArgs).option("--max-output-bytes <n>", "Per-stream output budget in bytes (default 5 MiB).", (value) => Number(value)).option("--allow-external-cwd", "Allow a working directory outside the project root.").option(
1226
2023
  "--timeout-ms <ms>",
1227
2024
  "Per-task timeout in milliseconds.",
1228
2025
  (value) => Number(value)
@@ -1237,7 +2034,12 @@ async function addAgent(options) {
1237
2034
  ...options.arg !== void 0 && options.arg.length > 0 ? { args: options.arg } : {},
1238
2035
  ...options.cwd !== void 0 ? { cwd: options.cwd } : {},
1239
2036
  input_mode: options.inputMode,
1240
- ...options.timeoutMs !== void 0 ? { timeout_ms: options.timeoutMs } : {}
2037
+ ...options.timeoutMs !== void 0 ? { timeout_ms: options.timeoutMs } : {},
2038
+ ...options.envPolicy !== void 0 ? { env_policy: options.envPolicy } : {},
2039
+ ...options.allowEnv !== void 0 && options.allowEnv.length > 0 ? { env_allowlist: options.allowEnv } : {},
2040
+ ...options.denyEnv !== void 0 && options.denyEnv.length > 0 ? { env_denylist: options.denyEnv } : {},
2041
+ ...options.maxOutputBytes !== void 0 ? { max_output_bytes: options.maxOutputBytes } : {},
2042
+ ...options.allowExternalCwd !== void 0 ? { allow_external_cwd: options.allowExternalCwd } : {}
1241
2043
  });
1242
2044
  if (!candidate.success) {
1243
2045
  const issues = candidate.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
@@ -1302,40 +2104,54 @@ function registerRoleCommands(program) {
1302
2104
  });
1303
2105
  }
1304
2106
 
1305
- // src/tasks/store.ts
2107
+ // src/persistence/json-file-store.ts
1306
2108
  import { mkdir, readFile, rename, writeFile } from "fs/promises";
1307
2109
  import { dirname } from "path";
1308
- var FileTaskStore = class {
2110
+ var JsonFileStore = class {
1309
2111
  filePath;
1310
- /**
1311
- * Read-modify-write operations are serialized: parallel pipeline tasks
1312
- * would otherwise race on the JSON file (last writer wins).
1313
- */
2112
+ schemaVersion;
2113
+ onCorrupt;
2114
+ log;
1314
2115
  queue = Promise.resolve();
1315
- constructor(directory = `${process.cwd()}/.omninode`) {
1316
- this.filePath = `${directory}/tasks.json`;
2116
+ constructor(options) {
2117
+ this.filePath = `${options.directory ?? `${process.cwd()}/.omninode`}/${options.fileName}`;
2118
+ this.schemaVersion = options.schemaVersion ?? 1;
2119
+ this.onCorrupt = options.onCorrupt;
2120
+ this.log = logger.child({ component: "persistence", file: options.fileName });
1317
2121
  }
1318
- async save(task) {
1319
- return this.synchronized(async () => {
1320
- const tasks = await this.load();
1321
- const index = tasks.findIndex((t) => t.id === task.id);
1322
- if (index >= 0) tasks[index] = task;
1323
- else tasks.push(task);
1324
- await this.write(tasks);
1325
- });
2122
+ get path() {
2123
+ return this.filePath;
1326
2124
  }
1327
- async get(id) {
1328
- return this.synchronized(async () => {
1329
- const tasks = await this.load();
1330
- return tasks.find((t) => t.id === id);
1331
- });
2125
+ /** All items, or [] when missing/corrupt. */
2126
+ async readAll() {
2127
+ let raw;
2128
+ try {
2129
+ raw = await readFile(this.filePath, "utf8");
2130
+ } catch {
2131
+ return [];
2132
+ }
2133
+ try {
2134
+ const parsed = JSON.parse(raw);
2135
+ if (Array.isArray(parsed)) return parsed;
2136
+ if (parsed !== null && typeof parsed === "object" && Array.isArray(parsed.items)) {
2137
+ return parsed.items;
2138
+ }
2139
+ throw new Error("persisted file has an unrecognized shape");
2140
+ } catch (error) {
2141
+ this.log.warn(`Treating ${this.filePath} as empty \u2014 it could not be parsed.`);
2142
+ this.onCorrupt?.(this.filePath, error);
2143
+ return [];
2144
+ }
1332
2145
  }
1333
- async list(filter = {}) {
1334
- return this.synchronized(async () => {
1335
- const tasks = await this.load();
1336
- return tasks.filter((task) => filter.status ? task.status === filter.status : true).filter((task) => filter.project ? task.project === filter.project : true).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1337
- });
2146
+ async writeAll(items) {
2147
+ const document = { schemaVersion: this.schemaVersion, items };
2148
+ const tmp = `${this.filePath}.tmp`;
2149
+ await mkdir(dirname(this.filePath), { recursive: true });
2150
+ await writeFile(tmp, `${JSON.stringify(document, null, 2)}
2151
+ `, "utf8");
2152
+ await rename(tmp, this.filePath);
1338
2153
  }
2154
+ /** Serializes an async operation against this file. */
1339
2155
  synchronized(operation) {
1340
2156
  const result = this.queue.then(operation, operation);
1341
2157
  this.queue = result.then(
@@ -1344,21 +2160,37 @@ var FileTaskStore = class {
1344
2160
  );
1345
2161
  return result;
1346
2162
  }
1347
- async load() {
1348
- try {
1349
- const raw = await readFile(this.filePath, "utf8");
1350
- const parsed = JSON.parse(raw);
1351
- return Array.isArray(parsed) ? parsed : [];
1352
- } catch {
1353
- return [];
1354
- }
2163
+ /** Read-modify-write helper: applies `mutate` to the current items. */
2164
+ async mutate(mutate) {
2165
+ return this.synchronized(async () => {
2166
+ const items = await this.readAll();
2167
+ const [next, result] = await mutate(items);
2168
+ await this.writeAll(next);
2169
+ return result;
2170
+ });
1355
2171
  }
1356
- async write(tasks) {
1357
- const tmp = `${this.filePath}.tmp`;
1358
- await mkdir(dirname(this.filePath), { recursive: true });
1359
- await writeFile(tmp, `${JSON.stringify(tasks, null, 2)}
1360
- `, "utf8");
1361
- await rename(tmp, this.filePath);
2172
+ };
2173
+
2174
+ // src/tasks/store.ts
2175
+ var FileTaskStore = class extends JsonFileStore {
2176
+ constructor(directory) {
2177
+ super({ ...directory !== void 0 ? { directory } : {}, fileName: "tasks.json" });
2178
+ }
2179
+ save(task) {
2180
+ return this.mutate((tasks) => {
2181
+ const index = tasks.findIndex((t) => t.id === task.id);
2182
+ if (index >= 0) tasks[index] = task;
2183
+ else tasks.push(task);
2184
+ return [tasks, void 0];
2185
+ });
2186
+ }
2187
+ async get(id) {
2188
+ const tasks = await this.readAll();
2189
+ return tasks.find((t) => t.id === id);
2190
+ }
2191
+ async list(filter = {}) {
2192
+ const tasks = await this.readAll();
2193
+ return tasks.filter((task) => filter.status ? task.status === filter.status : true).filter((task) => filter.project ? task.project === filter.project : true).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1362
2194
  }
1363
2195
  };
1364
2196
 
@@ -1367,12 +2199,18 @@ import { randomBytes } from "crypto";
1367
2199
  var ALLOWED_TRANSITIONS = {
1368
2200
  created: ["queued", "running", "cancelled"],
1369
2201
  queued: ["running", "cancelled"],
1370
- running: ["completed", "failed", "cancelled"],
2202
+ running: ["completed", "failed", "timed_out", "unknown", "cancelled", "partially_completed"],
1371
2203
  waiting: ["running", "cancelled"],
1372
2204
  completed: [],
1373
- failed: [],
1374
- cancelled: []
2205
+ // failed → running is the only re-entry: bounded automatic retry (§6.4).
2206
+ failed: ["running"],
2207
+ timed_out: [],
2208
+ cancelled: [],
2209
+ unknown: [],
2210
+ partially_completed: []
1375
2211
  };
2212
+ var RETRYABLE_ERROR_CODES = ["AGENT_FAILED"];
2213
+ var EXECUTION_HISTORY_LIMIT = 10;
1376
2214
  var TaskEngine = class {
1377
2215
  constructor(options) {
1378
2216
  this.options = options;
@@ -1404,6 +2242,7 @@ var TaskEngine = class {
1404
2242
  id: generateTaskId(),
1405
2243
  objective: input.objective.trim(),
1406
2244
  status: "created",
2245
+ attempt: 0,
1407
2246
  createdAt: now,
1408
2247
  updatedAt: now,
1409
2248
  ...input.project !== void 0 ? { project: input.project } : {},
@@ -1420,8 +2259,10 @@ var TaskEngine = class {
1420
2259
  const task = await this.mustGet(id);
1421
2260
  return this.transition(task, "queued");
1422
2261
  }
1423
- /** Runs the task through its assigned agent and records the result. */
1424
- async run(id) {
2262
+ /** Runs the task through its assigned agent and records the result.
2263
+ * `maxAttempts` enables bounded automatic retries of *retryable* errors
2264
+ * with linear backoff; unknown/timed_out outcomes are never auto-retried. */
2265
+ async run(id, options = {}) {
1425
2266
  const task = await this.mustGet(id);
1426
2267
  if (task.status !== "created" && task.status !== "queued") {
1427
2268
  throw new TaskError("TASK_INVALID", `Task ${id} is "${task.status}" and cannot be run.`);
@@ -1434,49 +2275,89 @@ var TaskEngine = class {
1434
2275
  throw new TaskError("TASK_INVALID", `Agent "${task.agent}" is not registered anymore.`);
1435
2276
  }
1436
2277
  const role = task.role ? this.options.roles.get(task.role) : void 0;
1437
- const running = await this.transition(task, "running");
1438
2278
  this.log.info(`Running task ${task.id} on agent "${task.agent}".`);
1439
- let background = running.context?.background;
2279
+ let background = task.context?.background;
1440
2280
  if (this.options.memory) {
1441
2281
  const memoryContext = await this.options.memory.gatherContext({
1442
- objective: running.objective,
1443
- ...running.role ? { role: running.role } : {},
1444
- ...running.project ? { project: running.project } : {}
2282
+ objective: task.objective,
2283
+ ...task.role ? { role: task.role } : {},
2284
+ ...task.project ? { project: task.project } : {}
1445
2285
  });
1446
2286
  if (memoryContext.length > 0) {
1447
2287
  background = [memoryContext, background].filter((part) => part && part.length > 0).join("\n\n");
1448
2288
  }
1449
- }
1450
- let output;
1451
- try {
1452
- output = await agent.run({
1453
- taskId: running.id,
1454
- objective: running.objective,
1455
- ...role ? { role } : {},
1456
- ...background !== void 0 ? { context: background } : {}
1457
- });
1458
- } catch (error) {
1459
- const message = error instanceof Error ? error.message : String(error);
1460
- this.log.error(`Task ${task.id} failed: ${message}`);
1461
- return this.transition(running, "failed", {
1462
- result: { error: message, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }
2289
+ }
2290
+ const maxAttempts = Math.max(1, options.maxAttempts ?? 1);
2291
+ const backoffMs = options.retryBackoffMs ?? 0;
2292
+ let current = task;
2293
+ for (let i = 0; i < maxAttempts; i += 1) {
2294
+ const attempt = current.attempt + 1;
2295
+ const executionId = `exec-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
2296
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2297
+ current = await this.transition(current, "running", { attempt });
2298
+ let output;
2299
+ let thrown;
2300
+ try {
2301
+ output = await agent.run({
2302
+ taskId: current.id,
2303
+ objective: current.objective,
2304
+ ...role ? { role } : {},
2305
+ ...background !== void 0 ? { context: background } : {}
2306
+ });
2307
+ } catch (error) {
2308
+ thrown = error;
2309
+ }
2310
+ const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
2311
+ if (thrown !== void 0 || output === void 0) {
2312
+ const outcome = classifyThrown(thrown);
2313
+ const message = thrown instanceof Error ? thrown.message : String(thrown);
2314
+ const retryable = outcome === "failed" && RETRYABLE_ERROR_CODES.includes(errorCode(thrown)) && i < maxAttempts - 1;
2315
+ current = await this.transition(current, outcome, {
2316
+ attempt,
2317
+ lastError: message,
2318
+ execution: {
2319
+ executionId,
2320
+ attempt,
2321
+ startedAt,
2322
+ finishedAt,
2323
+ outcome,
2324
+ error: message
2325
+ }
2326
+ });
2327
+ this.log.warn(`Task ${current.id} attempt ${attempt} \u2192 ${outcome}: ${message}`);
2328
+ if (retryable) {
2329
+ if (backoffMs > 0) await sleep(backoffMs * attempt);
2330
+ continue;
2331
+ }
2332
+ if (this.options.memory) await this.options.memory.recordTaskOutcome(current);
2333
+ return current;
2334
+ }
2335
+ const currentState = await this.options.store.get(current.id);
2336
+ if (currentState?.status === "cancelled") {
2337
+ current = currentState;
2338
+ break;
2339
+ }
2340
+ const result = {
2341
+ ...output.summary !== void 0 ? { summary: output.summary } : {},
2342
+ ...output.reports !== void 0 && output.reports.length > 0 ? { reports: output.reports } : {},
2343
+ // Keep the verbatim output (bounded) for report extraction and audit.
2344
+ ...output.rawOutput !== void 0 && output.rawOutput.length > 0 ? { rawOutput: output.rawOutput.slice(0, 1e4) } : {},
2345
+ ...output.error !== void 0 ? { error: output.error } : {},
2346
+ finishedAt
2347
+ };
2348
+ const finalStatus = output.status === "completed" ? "completed" : "failed";
2349
+ current = await this.transition(current, finalStatus, {
2350
+ attempt,
2351
+ result,
2352
+ execution: { executionId, attempt, startedAt, finishedAt, outcome: finalStatus }
1463
2353
  });
2354
+ this.log.info(`Task ${current.id} finished: ${finalStatus}.`);
2355
+ break;
1464
2356
  }
1465
- const result = {
1466
- ...output.summary !== void 0 ? { summary: output.summary } : {},
1467
- ...output.reports !== void 0 && output.reports.length > 0 ? { reports: output.reports } : {},
1468
- // Keep the verbatim output (bounded) for report extraction and audit.
1469
- ...output.rawOutput !== void 0 && output.rawOutput.length > 0 ? { rawOutput: output.rawOutput.slice(0, 1e4) } : {},
1470
- ...output.error !== void 0 ? { error: output.error } : {},
1471
- finishedAt: (/* @__PURE__ */ new Date()).toISOString()
1472
- };
1473
- const finalStatus = output.status === "completed" ? "completed" : "failed";
1474
- const finished = await this.transition(running, finalStatus, { result });
1475
- this.log.info(`Task ${task.id} finished: ${finalStatus}.`);
1476
2357
  if (this.options.memory) {
1477
- await this.options.memory.recordTaskOutcome(finished);
2358
+ await this.options.memory.recordTaskOutcome(current);
1478
2359
  }
1479
- return finished;
2360
+ return current;
1480
2361
  }
1481
2362
  async cancel(id) {
1482
2363
  const task = await this.mustGet(id);
@@ -1489,13 +2370,14 @@ var TaskEngine = class {
1489
2370
  }
1490
2371
  return this.transition(task, "cancelled");
1491
2372
  }
1492
- /** Error recovery (§27 Phase 9): reset a failed/cancelled task so it can run again. */
2373
+ /** Error recovery: reset a failed/cancelled/unknown/timed_out task so it can run again. */
1493
2374
  async retry(id) {
1494
2375
  const task = await this.mustGet(id);
1495
- if (task.status !== "failed" && task.status !== "cancelled") {
2376
+ const retryableStates = ["failed", "cancelled", "unknown", "timed_out"];
2377
+ if (!retryableStates.includes(task.status)) {
1496
2378
  throw new TaskError(
1497
2379
  "TASK_INVALID",
1498
- `Only failed or cancelled tasks can be retried \u2014 task ${id} is "${task.status}".`
2380
+ `Only failed, timed out, unknown or cancelled tasks can be retried \u2014 task ${id} is "${task.status}".`
1499
2381
  );
1500
2382
  }
1501
2383
  const { result: _discarded, ...rest } = task;
@@ -1527,10 +2409,14 @@ var TaskEngine = class {
1527
2409
  if (!allowed.includes(to)) {
1528
2410
  throw new TaskError("TASK_INVALID", `Task ${task.id} cannot move from "${task.status}" to "${to}".`);
1529
2411
  }
2412
+ const executions = extra.execution ? [...task.executions ?? [], extra.execution].slice(-EXECUTION_HISTORY_LIMIT) : task.executions;
1530
2413
  const updated = {
1531
2414
  ...task,
1532
2415
  status: to,
1533
2416
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2417
+ ...extra.attempt !== void 0 ? { attempt: extra.attempt } : {},
2418
+ ...extra.lastError !== void 0 ? { lastError: extra.lastError } : {},
2419
+ ...executions !== void 0 ? { executions } : {},
1534
2420
  ...extra.result !== void 0 ? { result: { ...task.result, ...extra.result } } : {}
1535
2421
  };
1536
2422
  await this.options.store.save(updated);
@@ -1551,6 +2437,19 @@ var TaskEngine = class {
1551
2437
  function generateTaskId() {
1552
2438
  return `task-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
1553
2439
  }
2440
+ function errorCode(error) {
2441
+ return error?.code ?? "";
2442
+ }
2443
+ function classifyThrown(error) {
2444
+ if (error instanceof AgentError && error.code === "AGENT_TIMEOUT") {
2445
+ const dispatched = error.details?.dispatched;
2446
+ return dispatched === false ? "timed_out" : "unknown";
2447
+ }
2448
+ return "failed";
2449
+ }
2450
+ function sleep(ms) {
2451
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
2452
+ }
1554
2453
 
1555
2454
  // src/audit/index.ts
1556
2455
  import { appendFile, mkdir as mkdir2 } from "fs/promises";
@@ -1575,10 +2474,10 @@ var FileAuditLog = class {
1575
2474
  }
1576
2475
  /** Recent events, oldest first; used by the CLI. */
1577
2476
  async recent(limit = 20) {
1578
- const { readFile: readFile6 } = await import("fs/promises");
2477
+ const { readFile: readFile2 } = await import("fs/promises");
1579
2478
  let raw;
1580
2479
  try {
1581
- raw = await readFile6(this.filePath, "utf8");
2480
+ raw = await readFile2(this.filePath, "utf8");
1582
2481
  } catch {
1583
2482
  return [];
1584
2483
  }
@@ -1593,9 +2492,24 @@ var FileAuditLog = class {
1593
2492
  }
1594
2493
  };
1595
2494
 
2495
+ // src/memory/base.ts
2496
+ function defaultSearch(provider, text, options = {}) {
2497
+ return provider.retrieve({
2498
+ text,
2499
+ ...options.scope !== void 0 ? { scope: options.scope } : {},
2500
+ ...options.limit !== void 0 ? { limit: options.limit } : {}
2501
+ });
2502
+ }
2503
+ function defaultMetadata(name, options = {}) {
2504
+ return {
2505
+ name,
2506
+ capabilities: options.capabilities ?? ["retrieve", "store", "search"],
2507
+ relevanceRanking: options.relevanceRanking ?? false,
2508
+ ...options.backend !== void 0 ? { backend: options.backend } : {}
2509
+ };
2510
+ }
2511
+
1596
2512
  // src/memory/local.ts
1597
- import { mkdir as mkdir3, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "fs/promises";
1598
- import { dirname as dirname3 } from "path";
1599
2513
  var STOPWORDS = /* @__PURE__ */ new Set([
1600
2514
  "the",
1601
2515
  "and",
@@ -1652,29 +2566,42 @@ function relevanceScore(queryKeywords, entry2) {
1652
2566
  }
1653
2567
  return overlap * 2 + tagMatches * 1.5;
1654
2568
  }
1655
- var LocalMemoryProvider = class {
2569
+ var LocalMemoryProvider = class extends JsonFileStore {
1656
2570
  name = "local";
1657
- filePath;
1658
- queue = Promise.resolve();
1659
- constructor(directory = `${process.cwd()}/.omninode`) {
1660
- this.filePath = `${directory}/memory.json`;
1661
- }
1662
- async query(query) {
1663
- return this.synchronized(async () => {
1664
- const entries = await this.load();
1665
- const filtered = entries.filter((entry2) => query.scope ? entry2.scope === query.scope : true).filter(
1666
- (entry2) => query.tags && query.tags.length > 0 ? query.tags.every((tag) => (entry2.tags ?? []).includes(tag)) : true
1667
- );
1668
- if (!query.text || query.text.trim().length === 0) {
1669
- return filtered.sort((a, b) => (b.updatedAt ?? b.createdAt ?? "").localeCompare(a.updatedAt ?? a.createdAt ?? "")).slice(0, query.limit ?? 10);
1670
- }
1671
- const queryKeywords = keywords(query.text);
1672
- return filtered.map((entry2) => ({ entry: entry2, score: relevanceScore(queryKeywords, entry2) })).filter(({ score }) => score > 0).sort((a, b) => b.score - a.score).slice(0, query.limit ?? 10).map(({ entry: entry2 }) => entry2);
2571
+ constructor(directory) {
2572
+ super({ ...directory !== void 0 ? { directory } : {}, fileName: "memory.json" });
2573
+ }
2574
+ metadata() {
2575
+ return defaultMetadata(this.name, {
2576
+ capabilities: ["retrieve", "store", "search", "categories", "tags"],
2577
+ relevanceRanking: true,
2578
+ backend: { kind: "json-file", deterministicScoring: true }
1673
2579
  });
1674
2580
  }
1675
- async write(entry2) {
1676
- return this.synchronized(async () => {
1677
- const entries = await this.load();
2581
+ search(text, options = {}) {
2582
+ return defaultSearch(this, text, options);
2583
+ }
2584
+ /** @deprecated v1 alias of {@link retrieve}. */
2585
+ query(query) {
2586
+ return this.retrieve(query);
2587
+ }
2588
+ /** @deprecated v1 alias of {@link store}. */
2589
+ write(entry2) {
2590
+ return this.store(entry2);
2591
+ }
2592
+ async retrieve(query) {
2593
+ const entries = await this.readAll();
2594
+ const filtered = entries.filter((entry2) => query.scope ? entry2.scope === query.scope : true).filter((entry2) => query.category ? entry2.category === query.category : true).filter(
2595
+ (entry2) => query.tags && query.tags.length > 0 ? query.tags.every((tag) => (entry2.tags ?? []).includes(tag)) : true
2596
+ );
2597
+ if (!query.text || query.text.trim().length === 0) {
2598
+ return filtered.sort((a, b) => (b.updatedAt ?? b.createdAt ?? "").localeCompare(a.updatedAt ?? a.createdAt ?? "")).slice(0, query.limit ?? 10);
2599
+ }
2600
+ const queryKeywords = keywords(query.text);
2601
+ return filtered.map((entry2) => ({ entry: entry2, score: relevanceScore(queryKeywords, entry2) })).filter(({ score }) => score > 0).sort((a, b) => b.score - a.score).slice(0, query.limit ?? 10).map(({ entry: entry2 }) => entry2);
2602
+ }
2603
+ store(entry2) {
2604
+ return this.mutate((entries) => {
1678
2605
  const index = entries.findIndex((e) => e.key === entry2.key);
1679
2606
  const stamped = {
1680
2607
  ...entry2,
@@ -1683,35 +2610,11 @@ var LocalMemoryProvider = class {
1683
2610
  };
1684
2611
  if (index >= 0) entries[index] = stamped;
1685
2612
  else entries.push(stamped);
1686
- await this.saveAll(entries);
2613
+ return [entries, void 0];
1687
2614
  });
1688
2615
  }
1689
2616
  async count() {
1690
- return (await this.load()).length;
1691
- }
1692
- synchronized(operation) {
1693
- const result = this.queue.then(operation, operation);
1694
- this.queue = result.then(
1695
- () => void 0,
1696
- () => void 0
1697
- );
1698
- return result;
1699
- }
1700
- async load() {
1701
- try {
1702
- const raw = await readFile2(this.filePath, "utf8");
1703
- const parsed = JSON.parse(raw);
1704
- return Array.isArray(parsed) ? parsed : [];
1705
- } catch {
1706
- return [];
1707
- }
1708
- }
1709
- async saveAll(entries) {
1710
- const tmp = `${this.filePath}.tmp`;
1711
- await mkdir3(dirname3(this.filePath), { recursive: true });
1712
- await writeFile2(tmp, `${JSON.stringify(entries, null, 2)}
1713
- `, "utf8");
1714
- await rename2(tmp, this.filePath);
2617
+ return (await this.readAll()).length;
1715
2618
  }
1716
2619
  };
1717
2620
 
@@ -1728,11 +2631,40 @@ var RememberaMemoryProvider = class {
1728
2631
  this.env = config.env ?? process.env;
1729
2632
  this.log = log.child({ component: "remembera" });
1730
2633
  }
2634
+ get authenticationConfigured() {
2635
+ return this.apiKeyEnvVar === void 0 || Boolean(this.env[this.apiKeyEnvVar]);
2636
+ }
1731
2637
  headers() {
1732
2638
  const key = this.apiKeyEnvVar ? resolveApiKey({ name: "remembera", apiKeyEnvVar: this.apiKeyEnvVar }, this.env) : void 0;
1733
2639
  return key ? { authorization: `Bearer ${key}` } : {};
1734
2640
  }
1735
- async query(query) {
2641
+ /** Search delegates to the gateway's relevance query. */
2642
+ search(text, options = {}) {
2643
+ return defaultSearch(
2644
+ this,
2645
+ text,
2646
+ {
2647
+ ...options.scope !== void 0 ? { scope: options.scope } : {},
2648
+ ...options.limit !== void 0 ? { limit: options.limit } : {}
2649
+ }
2650
+ );
2651
+ }
2652
+ metadata() {
2653
+ return defaultMetadata(this.name, {
2654
+ capabilities: ["retrieve", "store", "search", "categories", "tags"],
2655
+ relevanceRanking: true,
2656
+ backend: { kind: "http", baseUrl: this.baseUrl, authenticated: this.authenticationConfigured }
2657
+ });
2658
+ }
2659
+ /** @deprecated v1 alias of {@link retrieve}. */
2660
+ query(query) {
2661
+ return this.retrieve(query);
2662
+ }
2663
+ /** @deprecated v1 alias of {@link store}. */
2664
+ write(entry2) {
2665
+ return this.store(entry2);
2666
+ }
2667
+ async retrieve(query) {
1736
2668
  const { status, body } = await this.request(`${this.baseUrl}/api/memory/query`, query);
1737
2669
  if (status !== 200) {
1738
2670
  throw new MemoryError(`Remembera query returned HTTP ${status}.`);
@@ -1743,7 +2675,7 @@ var RememberaMemoryProvider = class {
1743
2675
  }
1744
2676
  return entries.map((entry2) => this.toEntry(entry2)).filter((entry2) => entry2 !== void 0);
1745
2677
  }
1746
- async write(entry2) {
2678
+ async store(entry2) {
1747
2679
  const { status } = await this.request(`${this.baseUrl}/api/memory`, entry2);
1748
2680
  if (status !== 200 && status !== 201) {
1749
2681
  throw new MemoryError(`Remembera write returned HTTP ${status}.`);
@@ -1778,6 +2710,7 @@ var RememberaMemoryProvider = class {
1778
2710
  content: entry2.content,
1779
2711
  scope: scope === "project" || scope === "role" || scope === "task" || scope === "knowledge" ? scope : "knowledge",
1780
2712
  ...Array.isArray(entry2.tags) ? { tags: entry2.tags.filter((t) => typeof t === "string") } : {},
2713
+ ...typeof entry2.category === "string" ? { category: entry2.category } : {},
1781
2714
  ...typeof entry2.createdAt === "string" ? { createdAt: entry2.createdAt } : {},
1782
2715
  ...typeof entry2.updatedAt === "string" ? { updatedAt: entry2.updatedAt } : {}
1783
2716
  };
@@ -1808,57 +2741,54 @@ function createMemoryProvider(config) {
1808
2741
  }
1809
2742
 
1810
2743
  // src/memory/service.ts
2744
+ var DEFAULT_ENTRY_LIMIT = 8;
1811
2745
  var MAX_CONTEXT_CHARS = 4e3;
1812
2746
  var MemoryService = class {
1813
- constructor(provider, log = logger) {
2747
+ constructor(provider, log = logger, options = {}) {
1814
2748
  this.provider = provider;
1815
2749
  this.log = log.child({ component: "memory", provider: provider.name });
2750
+ this.required = options.required ?? false;
2751
+ this.maxContextChars = options.maxContextChars ?? MAX_CONTEXT_CHARS;
1816
2752
  }
1817
2753
  provider;
1818
2754
  log;
2755
+ required;
2756
+ maxContextChars;
1819
2757
  get providerRef() {
1820
2758
  return this.provider;
1821
2759
  }
1822
2760
  get providerName() {
1823
2761
  return this.provider.name;
1824
2762
  }
1825
- /** Relevant context for a task objective; empty string when nothing matches. */
1826
- async gatherContext(query) {
2763
+ get isRequired() {
2764
+ return this.required;
2765
+ }
2766
+ /** Structured, budgeted context for a task (§12 — avoid loading all memory). */
2767
+ async contextFor(query) {
2768
+ let entries;
1827
2769
  try {
1828
- const entries = await this.provider.query({
2770
+ entries = await this.provider.retrieve({
1829
2771
  text: query.objective,
1830
2772
  ...query.role ? { tags: [query.role] } : {},
1831
- limit: 8
2773
+ limit: query.limit ?? DEFAULT_ENTRY_LIMIT
1832
2774
  });
1833
- if (entries.length === 0) return "";
1834
- const byScope = /* @__PURE__ */ new Map();
1835
- for (const entry2 of entries) {
1836
- byScope.set(entry2.scope, [...byScope.get(entry2.scope) ?? [], entry2]);
1837
- }
1838
- const parts = [];
1839
- for (const scope of ["project", "task", "role", "knowledge"]) {
1840
- const scopeEntries = byScope.get(scope) ?? [];
1841
- if (scopeEntries.length === 0) continue;
1842
- const lines = scopeEntries.map((entry2) => `- ${entry2.content}`).join("\n");
1843
- parts.push(`### Relevant memory (${scope})
1844
- ${lines}`);
1845
- }
1846
- const context = parts.join("\n\n");
1847
- return context.length > MAX_CONTEXT_CHARS ? `${context.slice(0, MAX_CONTEXT_CHARS)}\u2026` : context;
1848
2775
  } catch (error) {
1849
- this.log.warn(
1850
- `Memory query failed (continuing without memory): ${error instanceof Error ? error.message : String(error)}`
1851
- );
1852
- return "";
2776
+ return this.handleFailure("retrieve", error, { text: "", entries: [], truncated: false });
1853
2777
  }
2778
+ return this.format(entries);
2779
+ }
2780
+ /** Prompt-ready context text (v1 convenience wrapper). */
2781
+ async gatherContext(query) {
2782
+ return (await this.contextFor(query)).text;
1854
2783
  }
1855
- /** Records a finished task's outcome — best-effort. */
2784
+ /** Records a finished task's outcome — task history plus "known problems" (§12). */
1856
2785
  async recordTaskOutcome(task) {
1857
2786
  const outcome = task.result?.summary ?? task.result?.error ?? "(no output)";
1858
2787
  try {
1859
- await this.provider.write({
2788
+ await this.provider.store({
1860
2789
  key: `task:${task.id}`,
1861
2790
  scope: "task",
2791
+ category: "tasks",
1862
2792
  content: `Objective: ${task.objective}
1863
2793
  Status: ${task.status}
1864
2794
  Outcome: ${outcome}`,
@@ -1869,18 +2799,53 @@ Outcome: ${outcome}`,
1869
2799
  (report) => report.findings.filter((finding) => finding.severity === "high" || finding.severity === "critical").map((finding) => ({ report, finding }))
1870
2800
  );
1871
2801
  for (const { report, finding } of significant) {
1872
- await this.provider.write({
2802
+ await this.provider.store({
1873
2803
  key: `finding:${report.id}:${finding.id}`,
1874
2804
  scope: "project",
2805
+ category: "problems",
1875
2806
  content: `[${finding.severity}] ${finding.title} \u2014 ${finding.detail}`,
1876
2807
  tags: ["known-problem", report.agent]
1877
2808
  });
1878
2809
  }
1879
2810
  } catch (error) {
1880
- this.log.warn(
1881
- `Memory write failed (continuing): ${error instanceof Error ? error.message : String(error)}`
2811
+ this.handleFailure("store", error, void 0);
2812
+ }
2813
+ }
2814
+ format(entries) {
2815
+ if (entries.length === 0) return { text: "", entries, truncated: false };
2816
+ const byScope = /* @__PURE__ */ new Map();
2817
+ for (const entry2 of entries) {
2818
+ byScope.set(entry2.scope, [...byScope.get(entry2.scope) ?? [], entry2]);
2819
+ }
2820
+ const parts = [];
2821
+ for (const scope of ["project", "task", "role", "knowledge"]) {
2822
+ const scopeEntries = byScope.get(scope) ?? [];
2823
+ if (scopeEntries.length === 0) continue;
2824
+ const lines = scopeEntries.map((entry2) => {
2825
+ const category = entry2.category ? `[${entry2.category}] ` : "";
2826
+ return `- ${category}${entry2.content}`;
2827
+ }).join("\n");
2828
+ parts.push(`### Relevant memory (${scope})
2829
+ ${lines}`);
2830
+ }
2831
+ let text = parts.join("\n\n");
2832
+ const truncated = text.length > this.maxContextChars;
2833
+ if (truncated) text = `${text.slice(0, this.maxContextChars - 1)}\u2026`;
2834
+ return { text, entries, truncated };
2835
+ }
2836
+ /**
2837
+ * Failure policy (§12): continue unless memory is explicitly required.
2838
+ */
2839
+ handleFailure(operation, error, fallback) {
2840
+ const message = error instanceof Error ? error.message : String(error);
2841
+ if (this.required) {
2842
+ throw new MemoryError(
2843
+ `Memory is marked required, but ${operation} failed: ${message}`,
2844
+ { cause: error }
1882
2845
  );
1883
2846
  }
2847
+ this.log.warn(`Memory ${operation} failed (continuing without memory): ${message}`);
2848
+ return fallback ?? { text: "", entries: [], truncated: false };
1884
2849
  }
1885
2850
  };
1886
2851
 
@@ -1914,7 +2879,11 @@ function createTaskEngine(config, log = logger, directory) {
1914
2879
  agents,
1915
2880
  roles,
1916
2881
  store: new FileTaskStore(directory),
1917
- ...config.project.memory !== void 0 ? { memory: new MemoryService(createMemoryProvider(config.project.memory), log) } : {},
2882
+ ...config.project.memory !== void 0 ? {
2883
+ memory: new MemoryService(createMemoryProvider(config.project.memory), log, {
2884
+ required: config.project.memory.required
2885
+ })
2886
+ } : {},
1918
2887
  audit: new FileAuditLog(),
1919
2888
  log
1920
2889
  });
@@ -1981,7 +2950,7 @@ function registerTaskCommands(program) {
1981
2950
  const cancelled = await engine.cancel(taskId);
1982
2951
  console.log(`Task ${cancelled.id} is now "${cancelled.status}".`);
1983
2952
  });
1984
- task.command("retry <task-id>").description("Reset a failed/cancelled task to created; combine with --run to execute it again.").option("--run", "Run the task again immediately after resetting it.").action(async (taskId, options) => {
2953
+ task.command("retry <task-id>").description("Reset a failed, timed out, unknown or cancelled task to created; combine with --run to execute it again.").option("--run", "Run the task again immediately after resetting it.").action(async (taskId, options) => {
1985
2954
  const config = loadConfig();
1986
2955
  const engine = createTaskEngine(config);
1987
2956
  const reset = await engine.retry(taskId);
@@ -2004,6 +2973,15 @@ function printOutcome(task, options = {}) {
2004
2973
  if (task.context?.files) console.log(` files: ${task.context.files.join(", ")}`);
2005
2974
  console.log(` objective: ${task.objective}`);
2006
2975
  }
2976
+ if (options.verbose && task.attempt > 0) {
2977
+ console.log(` attempt: ${task.attempt}`);
2978
+ }
2979
+ if (task.lastError) console.log(` error: ${task.lastError}`);
2980
+ if (options.verbose && task.executions && task.executions.length > 0) {
2981
+ console.log(
2982
+ ` executions: ${task.executions.map((e) => `#${e.attempt}:${e.outcome}`).join(", ")}`
2983
+ );
2984
+ }
2007
2985
  const result = task.result;
2008
2986
  if (!result) return;
2009
2987
  if (result.summary) console.log(` summary: ${result.summary}`);
@@ -2019,6 +2997,87 @@ function truncate2(text, max) {
2019
2997
 
2020
2998
  // src/reports/aggregate.ts
2021
2999
  import { randomBytes as randomBytes2 } from "crypto";
3000
+
3001
+ // src/reports/schema.ts
3002
+ import { z as z2 } from "zod";
3003
+ var evidenceSchema = z2.union([
3004
+ z2.string().transform((excerpt) => ({ kind: "observation", excerpt })),
3005
+ z2.object({
3006
+ kind: z2.enum(["file", "line", "url", "command", "observation", "artifact"]),
3007
+ ref: z2.string().optional(),
3008
+ line: z2.number().int().optional(),
3009
+ excerpt: z2.string().optional(),
3010
+ agent: z2.string().optional()
3011
+ })
3012
+ ]);
3013
+ var findingSchema = z2.object({
3014
+ id: z2.string().min(1),
3015
+ title: z2.string().min(1),
3016
+ detail: z2.string().default(""),
3017
+ description: z2.string().optional(),
3018
+ severity: z2.enum(["info", "low", "medium", "high", "critical"]).optional(),
3019
+ confidence: z2.enum(["low", "medium", "high"]).optional(),
3020
+ severityBySource: z2.record(z2.enum(["info", "low", "medium", "high", "critical"])).optional(),
3021
+ confidenceBySource: z2.record(z2.enum(["low", "medium", "high"])).optional(),
3022
+ recommendations: z2.array(z2.string()).optional(),
3023
+ evidence: z2.array(evidenceSchema).optional(),
3024
+ location: z2.string().optional(),
3025
+ tags: z2.array(z2.string()).optional(),
3026
+ source: z2.object({ agent: z2.string().optional(), reportId: z2.string().optional(), pipelineRunId: z2.string().optional() }).optional(),
3027
+ recommendation: z2.string().optional()
3028
+ });
3029
+ var artifactSchema = z2.object({
3030
+ kind: z2.enum(["file", "patch", "log", "screenshot", "document", "data"]),
3031
+ ref: z2.string().min(1),
3032
+ description: z2.string().optional(),
3033
+ mediaType: z2.string().optional(),
3034
+ bytes: z2.number().int().optional()
3035
+ });
3036
+ var reportSchema = z2.object({
3037
+ id: z2.string().min(1),
3038
+ taskId: z2.string().min(1),
3039
+ agent: z2.string().min(1),
3040
+ agentId: z2.string().optional(),
3041
+ pipelineRunId: z2.string().optional(),
3042
+ model: z2.string().optional(),
3043
+ summary: z2.string(),
3044
+ findings: z2.array(findingSchema),
3045
+ recommendations: z2.array(z2.string()),
3046
+ confidence: z2.enum(["low", "medium", "high"]).optional(),
3047
+ evidence: z2.array(evidenceSchema).optional(),
3048
+ artifacts: z2.array(artifactSchema).optional(),
3049
+ createdAt: z2.string().min(1),
3050
+ metadata: z2.record(z2.unknown()).optional()
3051
+ });
3052
+ function validateReport(value) {
3053
+ const parsed = reportSchema.safeParse(value);
3054
+ if (!parsed.success) {
3055
+ const issues = parsed.error.issues.map((issue) => ({
3056
+ path: issue.path.join(".") || "(root)",
3057
+ message: issue.message
3058
+ }));
3059
+ return {
3060
+ valid: false,
3061
+ issues,
3062
+ reason: `report failed schema validation (${issues.length} issue(s))`
3063
+ };
3064
+ }
3065
+ const report = parsed.data;
3066
+ report.findings = report.findings.map((finding) => ({
3067
+ ...finding,
3068
+ description: finding.description ?? finding.detail,
3069
+ ...finding.source === void 0 ? { source: { agent: report.agent, reportId: report.id, ...report.pipelineRunId !== void 0 ? { pipelineRunId: report.pipelineRunId } : {} } } : {}
3070
+ }));
3071
+ return { valid: true, report };
3072
+ }
3073
+ function normalizeEvidence(input) {
3074
+ if (input === void 0) return void 0;
3075
+ return input.map(
3076
+ (item) => typeof item === "string" ? { kind: "observation", excerpt: item } : item
3077
+ );
3078
+ }
3079
+
3080
+ // src/reports/aggregate.ts
2022
3081
  var SEVERITY_RANK = {
2023
3082
  info: 0,
2024
3083
  low: 1,
@@ -2040,7 +3099,12 @@ function aggregateReports(reports, options = {}) {
2040
3099
  }
2041
3100
  const sources = [...new Set(reports.map((r) => r.agent))].sort();
2042
3101
  const totalRawFindings = reports.reduce((sum, r) => sum + r.findings.length, 0);
2043
- const summary = `${reports.length} report(s) from ${sources.length > 0 ? sources.join(", ") : "no agents"}: ${findingGroups.length} unique finding group(s) (${totalRawFindings} raw), ${recommendationGroups.length} recommendation group(s).`;
3102
+ const conflicts = detectConflicts(findingGroups);
3103
+ const agreements = findingGroups.filter(
3104
+ (group) => group.sources.length > 1 && group.severity !== void 0 && new Set(Object.values(group.severityBySource ?? {})).size === 1
3105
+ );
3106
+ const disputed = conflicts.length;
3107
+ const summary = `${reports.length} report(s) from ${sources.length > 0 ? sources.join(", ") : "no agents"}: ${findingGroups.length} unique finding group(s) (${totalRawFindings} raw), ${agreements.length} agreement(s), ${disputed} conflict(s), ${recommendationGroups.length} recommendation group(s).`;
2044
3108
  return {
2045
3109
  id: `combined-${Date.now().toString(36)}-${randomBytes2(3).toString("hex")}`,
2046
3110
  title: options.objective !== void 0 ? truncate3(options.objective, 120) : "Combined report",
@@ -2049,10 +3113,57 @@ function aggregateReports(reports, options = {}) {
2049
3113
  taskIds: options.taskIds ?? [],
2050
3114
  summary,
2051
3115
  findings: findingGroups.sort((a, b) => rank(b) - rank(a)),
3116
+ agreements,
3117
+ conflicts,
2052
3118
  recommendations: recommendationGroups,
2053
3119
  sources,
2054
3120
  reportIds: reports.map((r) => r.id),
2055
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
3121
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3122
+ metadata: {
3123
+ agents: sources.length,
3124
+ reports: reports.length,
3125
+ rawFindings: totalRawFindings,
3126
+ findingGroups: findingGroups.length,
3127
+ agreements: agreements.length,
3128
+ conflicts: conflicts.length
3129
+ }
3130
+ };
3131
+ }
3132
+ function rankConfidence(confidence) {
3133
+ return confidence ? { low: 1, medium: 2, high: 3 }[confidence] : 0;
3134
+ }
3135
+ function detectConflicts(groups) {
3136
+ const conflicts = [];
3137
+ for (const group of groups) {
3138
+ if (group.sources.length < 2) continue;
3139
+ const severities = new Set(Object.values(group.severityBySource ?? {}));
3140
+ if (severities.size > 1) {
3141
+ conflicts.push({
3142
+ type: "severity",
3143
+ findingTitle: group.title,
3144
+ positions: group.sources.map((source) => position(source, group)),
3145
+ note: `Agents disagree on severity (${[...severities].sort((a, b) => SEVERITY_RANK[a] - SEVERITY_RANK[b]).join(", ")}); the highest severity is shown, every position is preserved.`
3146
+ });
3147
+ }
3148
+ const recommendations = (group.recommendations ?? []).filter((r) => r.length > 0);
3149
+ if (recommendations.length > 1) {
3150
+ conflicts.push({
3151
+ type: "recommendation",
3152
+ findingTitle: group.title,
3153
+ positions: group.sources.map((source) => position(source, group)),
3154
+ note: `Agents proposed ${recommendations.length} different fixes; none was discarded.`
3155
+ });
3156
+ }
3157
+ }
3158
+ return conflicts;
3159
+ }
3160
+ function position(source, group) {
3161
+ return {
3162
+ agent: source.agent,
3163
+ reportId: source.reportId,
3164
+ ...group.severityBySource?.[source.agent] !== void 0 ? { severity: group.severityBySource[source.agent] } : {},
3165
+ ...group.confidenceBySource?.[source.agent] !== void 0 ? { confidence: group.confidenceBySource[source.agent] } : {},
3166
+ ...group.recommendations?.[0] !== void 0 ? { recommendation: group.recommendations[0] } : {}
2056
3167
  };
2057
3168
  }
2058
3169
  function rank(finding) {
@@ -2065,10 +3176,22 @@ function mergeFinding(groups, finding, report, threshold) {
2065
3176
  group.occurrences += 1;
2066
3177
  group.details.push(finding.detail);
2067
3178
  group.sources.push(source);
2068
- group.evidence.push(...finding.evidence ?? []);
3179
+ group.evidence.push(...normalizeEvidence(finding.evidence) ?? []);
2069
3180
  if (finding.severity && (!group.severity || SEVERITY_RANK[finding.severity] > SEVERITY_RANK[group.severity])) {
2070
3181
  group.severity = finding.severity;
2071
3182
  }
3183
+ if (finding.confidence && rankConfidence(finding.confidence) > rankConfidence(group.confidence)) {
3184
+ group.confidence = finding.confidence;
3185
+ }
3186
+ if (finding.severity) {
3187
+ group.severityBySource = { ...group.severityBySource, [report.agent]: finding.severity };
3188
+ }
3189
+ if (finding.confidence) {
3190
+ group.confidenceBySource = { ...group.confidenceBySource, [report.agent]: finding.confidence };
3191
+ }
3192
+ if (finding.recommendation && !group.recommendations?.includes(finding.recommendation)) {
3193
+ group.recommendations = [...group.recommendations ?? [], finding.recommendation];
3194
+ }
2072
3195
  return;
2073
3196
  }
2074
3197
  groups.push({
@@ -2077,7 +3200,11 @@ function mergeFinding(groups, finding, report, threshold) {
2077
3200
  sources: [source],
2078
3201
  occurrences: 1,
2079
3202
  ...finding.severity ? { severity: finding.severity } : {},
2080
- evidence: [...finding.evidence ?? []]
3203
+ ...finding.confidence ? { confidence: finding.confidence } : {},
3204
+ ...finding.severity ? { severityBySource: { [report.agent]: finding.severity } } : {},
3205
+ ...finding.confidence ? { confidenceBySource: { [report.agent]: finding.confidence } } : {},
3206
+ ...finding.recommendation ? { recommendations: [finding.recommendation] } : {},
3207
+ evidence: [...normalizeEvidence(finding.evidence) ?? []]
2081
3208
  });
2082
3209
  }
2083
3210
  function mergeRecommendation(groups, text, report, threshold) {
@@ -2159,6 +3286,7 @@ function extractReportFromText(raw, meta) {
2159
3286
  id: `${meta.taskId}-f-${index + 1}`,
2160
3287
  title: truncate4(text, 120),
2161
3288
  detail: text,
3289
+ description: text,
2162
3290
  severity: guessSeverity(text)
2163
3291
  }));
2164
3292
  return {
@@ -2169,7 +3297,8 @@ function extractReportFromText(raw, meta) {
2169
3297
  summary,
2170
3298
  findings,
2171
3299
  recommendations: recommendationTexts.map((t) => truncate4(t, 200)),
2172
- metadata: evidenceTexts.length > 0 ? { evidence: evidenceTexts } : void 0,
3300
+ ...evidenceTexts.length > 0 ? { evidence: evidenceTexts.map((excerpt) => ({ kind: "observation", excerpt })) } : {},
3301
+ metadata: evidenceTexts.length > 0 ? { evidenceCount: evidenceTexts.length } : void 0,
2173
3302
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
2174
3303
  };
2175
3304
  }
@@ -2185,27 +3314,46 @@ var ReportService = class {
2185
3314
  }
2186
3315
  store;
2187
3316
  log;
3317
+ /** Rejected reports from the last collection (§13 — malformed never enters aggregation). */
3318
+ lastDiagnostics = { collected: 0, accepted: 0, rejected: [] };
2188
3319
  get storeRef() {
2189
3320
  return this.store;
2190
3321
  }
2191
- /** Normalizes finished tasks into reports (§17 — normalizer). */
3322
+ /** Normalizes finished tasks into reports (§17 — normalizer), validating each
3323
+ * one so malformed reports never reach aggregation (§13). */
2192
3324
  async collectFromTasks(tasks) {
2193
- const reports = [];
3325
+ const candidates = [];
2194
3326
  for (const task of tasks) {
2195
3327
  const structured = task.result?.reports ?? [];
2196
3328
  if (structured.length > 0) {
2197
- reports.push(...structured);
3329
+ candidates.push(...structured);
2198
3330
  continue;
2199
3331
  }
2200
3332
  const text = task.result?.rawOutput ?? task.result?.summary ?? "";
2201
3333
  if (text.trim().length === 0) continue;
2202
- reports.push(
3334
+ candidates.push(
2203
3335
  extractReportFromText(text, {
2204
3336
  taskId: task.id,
2205
3337
  agent: task.agent ?? "unknown"
2206
3338
  })
2207
3339
  );
2208
3340
  }
3341
+ const reports = [];
3342
+ const rejected = [];
3343
+ for (const candidate of candidates) {
3344
+ const result = validateReport(candidate);
3345
+ if (result.valid) {
3346
+ reports.push(result.report);
3347
+ } else {
3348
+ rejected.push({
3349
+ key: candidate?.id ?? "(unknown)",
3350
+ reason: result.reason,
3351
+ issues: result.issues.map((issue) => `${issue.path}: ${issue.message}`)
3352
+ });
3353
+ this.log.warn(`Rejected malformed report: ${result.reason}`);
3354
+ }
3355
+ }
3356
+ this.lastDiagnostics = { collected: candidates.length, accepted: reports.length, rejected };
2209
3357
  return reports;
2210
3358
  }
2211
3359
  async saveReports(reports) {
@@ -2228,84 +3376,39 @@ var ReportService = class {
2228
3376
  };
2229
3377
 
2230
3378
  // src/reports/store.ts
2231
- import { mkdir as mkdir4, readFile as readFile3, rename as rename3, writeFile as writeFile3 } from "fs/promises";
2232
- import { dirname as dirname4 } from "path";
2233
- var FileReportStore = class {
2234
- filePath;
2235
- queue = Promise.resolve();
2236
- constructor(directory = `${process.cwd()}/.omninode`) {
2237
- this.filePath = `${directory}/reports.json`;
3379
+ var FileReportStore = class extends JsonFileStore {
3380
+ constructor(directory) {
3381
+ super({ ...directory !== void 0 ? { directory } : {}, fileName: "reports.json" });
2238
3382
  }
2239
3383
  async saveReports(reports) {
2240
- return this.synchronized(async () => {
2241
- const entries = await this.load();
3384
+ await this.mutate((entries) => {
2242
3385
  for (const report of reports) {
2243
- const index = entries.findIndex(
2244
- (e) => e.kind === "report" && e.report.id === report.id
2245
- );
3386
+ const index = entries.findIndex((e) => e.kind === "report" && e.report.id === report.id);
2246
3387
  const entry2 = { kind: "report", report };
2247
3388
  if (index >= 0) entries[index] = entry2;
2248
3389
  else entries.push(entry2);
2249
3390
  }
2250
- await this.write(entries);
3391
+ return [entries, void 0];
2251
3392
  });
2252
3393
  }
2253
3394
  async listReports(filter = {}) {
2254
- return this.synchronized(async () => {
2255
- const entries = await this.load();
2256
- return entries.filter((e) => e.kind === "report").map((e) => e.report).filter((report) => filter.taskId ? report.taskId === filter.taskId : true).filter((report) => filter.agent ? report.agent === filter.agent : true).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
2257
- });
3395
+ return (await this.readAll()).filter((e) => e.kind === "report").map((e) => e.report).filter((report) => filter.taskId ? report.taskId === filter.taskId : true).filter((report) => filter.agent ? report.agent === filter.agent : true).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
2258
3396
  }
2259
3397
  async saveCombined(combined) {
2260
- return this.synchronized(async () => {
2261
- const entries = await this.load();
2262
- const index = entries.findIndex(
2263
- (e) => e.kind === "combined" && e.combined.id === combined.id
2264
- );
3398
+ await this.mutate((entries) => {
3399
+ const index = entries.findIndex((e) => e.kind === "combined" && e.combined.id === combined.id);
2265
3400
  const entry2 = { kind: "combined", combined };
2266
3401
  if (index >= 0) entries[index] = entry2;
2267
3402
  else entries.push(entry2);
2268
- await this.write(entries);
3403
+ return [entries, void 0];
2269
3404
  });
2270
3405
  }
2271
3406
  async getCombined(id) {
2272
- return this.synchronized(async () => {
2273
- const entries = await this.load();
2274
- const found = entries.find((e) => e.kind === "combined" && e.combined.id === id);
2275
- return found?.kind === "combined" ? found.combined : void 0;
2276
- });
3407
+ const found = (await this.readAll()).find((e) => e.kind === "combined" && e.combined.id === id);
3408
+ return found?.kind === "combined" ? found.combined : void 0;
2277
3409
  }
2278
3410
  async listCombined(filter = {}) {
2279
- return this.synchronized(async () => {
2280
- const entries = await this.load();
2281
- return entries.filter((e) => e.kind === "combined").map((e) => e.combined).filter(
2282
- (combined) => filter.pipelineRunId ? combined.pipelineRunId === filter.pipelineRunId : true
2283
- ).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
2284
- });
2285
- }
2286
- synchronized(operation) {
2287
- const result = this.queue.then(operation, operation);
2288
- this.queue = result.then(
2289
- () => void 0,
2290
- () => void 0
2291
- );
2292
- return result;
2293
- }
2294
- async load() {
2295
- try {
2296
- const raw = await readFile3(this.filePath, "utf8");
2297
- const parsed = JSON.parse(raw);
2298
- return Array.isArray(parsed) ? parsed : [];
2299
- } catch {
2300
- return [];
2301
- }
2302
- }
2303
- async write(entries) {
2304
- const tmp = `${this.filePath}.tmp`;
2305
- await mkdir4(dirname4(this.filePath), { recursive: true });
2306
- await writeFile3(tmp, `${JSON.stringify(entries, null, 2)}
2307
- `, "utf8");
2308
- await rename3(tmp, this.filePath);
3411
+ return (await this.readAll()).filter((e) => e.kind === "combined").map((e) => e.combined).filter((combined) => filter.pipelineRunId ? combined.pipelineRunId === filter.pipelineRunId : true).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
2309
3412
  }
2310
3413
  };
2311
3414
 
@@ -2339,8 +3442,9 @@ function registerReportCommands(program) {
2339
3442
  return;
2340
3443
  }
2341
3444
  for (const c of combined) {
3445
+ const conflicts = c.conflicts.length > 0 ? ` ${c.conflicts.length} CONFLICT(S)` : "";
2342
3446
  console.log(
2343
- `${c.id} ${c.findings.length} finding(s) from ${c.sources.length} source(s)${c.pipelineRunId ? ` run=${c.pipelineRunId}` : ""}`
3447
+ `${c.id} ${c.findings.length} finding(s) from ${c.sources.length} source(s)${conflicts}${c.pipelineRunId ? ` run=${c.pipelineRunId}` : ""}`
2344
3448
  );
2345
3449
  }
2346
3450
  });
@@ -2377,6 +3481,21 @@ function registerReportCommands(program) {
2377
3481
  console.log(` reports: ${combined.reportIds.length}`);
2378
3482
  if (combined.pipelineRunId) console.log(` run: ${combined.pipelineRunId}`);
2379
3483
  console.log(` summary: ${combined.summary}`);
3484
+ if (combined.conflicts.length > 0) {
3485
+ console.log(` CONFLICTS (${combined.conflicts.length}) \u2014 both positions preserved:`);
3486
+ for (const conflict of combined.conflicts) {
3487
+ console.log(` ! ${conflict.findingTitle} [${conflict.type}]`);
3488
+ for (const position2 of conflict.positions) {
3489
+ console.log(
3490
+ ` ${position2.agent}: severity=${position2.severity ?? "unstated"}${position2.recommendation ? ` fix="${position2.recommendation}"` : ""}`
3491
+ );
3492
+ }
3493
+ console.log(` ${conflict.note}`);
3494
+ }
3495
+ }
3496
+ if (combined.agreements.length > 0) {
3497
+ console.log(` agreements: ${combined.agreements.length} (multi-agent consensus)`);
3498
+ }
2380
3499
  if (combined.findings.length > 0) {
2381
3500
  console.log(" findings (grouped, duplicates merged):");
2382
3501
  for (const finding of combined.findings) {
@@ -2470,7 +3589,7 @@ function truncate5(text, max) {
2470
3589
 
2471
3590
  // src/planner/model.ts
2472
3591
  import { randomBytes as randomBytes4 } from "crypto";
2473
- import { z as z2 } from "zod";
3592
+ import { z as z3 } from "zod";
2474
3593
 
2475
3594
  // src/planner/context.ts
2476
3595
  function buildPlannerContext(request) {
@@ -2523,21 +3642,21 @@ ${request.context}`);
2523
3642
  }
2524
3643
 
2525
3644
  // src/planner/model.ts
2526
- var planStepJsonSchema = z2.object({
2527
- id: z2.string().optional(),
2528
- title: z2.string().min(1),
2529
- description: z2.string().optional(),
2530
- order: z2.number().int().optional(),
2531
- targets: z2.array(z2.string()).optional(),
2532
- acceptance_criteria: z2.array(z2.string()).optional(),
2533
- depends_on: z2.array(z2.string()).optional(),
2534
- source_findings: z2.array(z2.string()).optional()
3645
+ var planStepJsonSchema = z3.object({
3646
+ id: z3.string().optional(),
3647
+ title: z3.string().min(1),
3648
+ description: z3.string().optional(),
3649
+ order: z3.number().int().optional(),
3650
+ targets: z3.array(z3.string()).optional(),
3651
+ acceptance_criteria: z3.array(z3.string()).optional(),
3652
+ depends_on: z3.array(z3.string()).optional(),
3653
+ source_findings: z3.array(z3.string()).optional()
2535
3654
  }).strict();
2536
- var planJsonSchema = z2.object({
2537
- summary: z2.string().min(1),
2538
- steps: z2.array(planStepJsonSchema).min(1),
2539
- risks: z2.array(z2.string()).optional(),
2540
- notes: z2.array(z2.string()).optional()
3655
+ var planJsonSchema = z3.object({
3656
+ summary: z3.string().min(1),
3657
+ steps: z3.array(planStepJsonSchema).min(1),
3658
+ risks: z3.array(z3.string()).optional(),
3659
+ notes: z3.array(z3.string()).optional()
2541
3660
  }).strict();
2542
3661
  var ModelPlanner = class {
2543
3662
  constructor(options) {
@@ -2630,9 +3749,23 @@ var PipelineEngine = class {
2630
3749
  }
2631
3750
  options;
2632
3751
  log;
3752
+ /** Runs executing in THIS process — cancellation can terminate them directly. */
3753
+ activeRuns = /* @__PURE__ */ new Map();
2633
3754
  get store() {
2634
3755
  return this.options.store;
2635
3756
  }
3757
+ /**
3758
+ * Saves the run while preserving fields that may be set by another process
3759
+ * (e.g. a cancellation request) — an in-memory snapshot must never clobber
3760
+ * externally requested cancellation (§9.2).
3761
+ */
3762
+ async persist(run2) {
3763
+ const current = await this.options.store.get(run2.id);
3764
+ await this.options.store.save({
3765
+ ...run2,
3766
+ ...current?.cancellationRequested === true ? { cancellationRequested: true, cancellationRequestedAt: current.cancellationRequestedAt } : {}
3767
+ });
3768
+ }
2636
3769
  /** Structural + semantic validation (§27 Phase 5 — pipeline parser). */
2637
3770
  validate(def) {
2638
3771
  const ids = /* @__PURE__ */ new Set();
@@ -2732,18 +3865,44 @@ var PipelineEngine = class {
2732
3865
  );
2733
3866
  }
2734
3867
  }
3868
+ if (step.model) {
3869
+ const separator = step.model.indexOf(":");
3870
+ if (separator <= 0 || separator === step.model.length - 1) {
3871
+ throw new PipelineError(
3872
+ "PIPELINE_INVALID",
3873
+ `Step "${step.id}" has an invalid model reference "${step.model}" \u2014 use "provider:model-id".`
3874
+ );
3875
+ }
3876
+ const providerName = step.model.slice(0, separator);
3877
+ if (!this.options.providers.some((p) => p.name === providerName)) {
3878
+ throw new PipelineError(
3879
+ "PIPELINE_INVALID",
3880
+ `Step "${step.id}" uses model "${step.model}" but provider "${providerName}" is not configured.`
3881
+ );
3882
+ }
3883
+ }
2735
3884
  }
2736
3885
  const now = (/* @__PURE__ */ new Date()).toISOString();
2737
3886
  const run2 = {
2738
3887
  id: `run-${Date.now().toString(36)}-${randomBytes5(3).toString("hex")}`,
2739
3888
  pipelineId: def.id,
3889
+ attempt: 1,
2740
3890
  objective,
2741
3891
  status: "running",
2742
3892
  startedAt: now,
2743
3893
  stepRuns: []
2744
3894
  };
2745
3895
  await this.options.store.save(run2);
2746
- this.log.info(`Running pipeline ${def.id} as ${run2.id}.`);
3896
+ const liveTaskIds = /* @__PURE__ */ new Set();
3897
+ this.activeRuns.set(run2.id, {
3898
+ taskIds: liveTaskIds,
3899
+ cancelTasks: async () => {
3900
+ for (const taskId of liveTaskIds) {
3901
+ await this.options.tasks.cancel(taskId).catch(() => void 0);
3902
+ }
3903
+ }
3904
+ });
3905
+ this.log.info(`Running pipeline ${def.id} as ${run2.id} (attempt ${run2.attempt}).`);
2747
3906
  await this.options.audit?.record({
2748
3907
  at: (/* @__PURE__ */ new Date()).toISOString(),
2749
3908
  action: "pipeline.run.started",
@@ -2755,7 +3914,22 @@ var PipelineEngine = class {
2755
3914
  const pending = new Set(def.steps.map((s) => s.id));
2756
3915
  const contextParts = [];
2757
3916
  const allReports = [];
3917
+ let cancelled = false;
2758
3918
  while (pending.size > 0) {
3919
+ const persisted = await this.options.store.get(run2.id);
3920
+ if (persisted?.cancellationRequested === true) {
3921
+ cancelled = true;
3922
+ run2.cancellationRequested = true;
3923
+ run2.cancellationRequestedAt = persisted.cancellationRequestedAt;
3924
+ await this.activeRuns.get(run2.id)?.cancelTasks();
3925
+ for (const step of def.steps.filter((s) => pending.has(s.id))) {
3926
+ const stepRun = { stepId: step.id, status: "cancelled" };
3927
+ run2.stepRuns.push(stepRun);
3928
+ options.onStep?.(stepRun);
3929
+ }
3930
+ pending.clear();
3931
+ break;
3932
+ }
2759
3933
  const ready = def.steps.filter(
2760
3934
  (step) => pending.has(step.id) && (deps.get(step.id) ?? []).every((d) => outcomes.has(d))
2761
3935
  );
@@ -2772,7 +3946,8 @@ var PipelineEngine = class {
2772
3946
  ...role ? { role } : {},
2773
3947
  combinedContext: contextParts.join("\n\n"),
2774
3948
  reports: [...allReports],
2775
- pipelineRunId: run2.id
3949
+ pipelineRunId: run2.id,
3950
+ runId: run2.id
2776
3951
  };
2777
3952
  return this.executeStep(step, ctx);
2778
3953
  })
@@ -2789,7 +3964,9 @@ ${contribution}`);
2789
3964
  }
2790
3965
  const stepRun = {
2791
3966
  stepId: step.id,
2792
- status: outcome.status === "skipped" ? "cancelled" : outcome.status === "failed" ? "failed" : "completed",
3967
+ attempt: outcome.attempt ?? 1,
3968
+ ...outcome.executionIds && outcome.executionIds.length > 0 ? { executionIds: outcome.executionIds } : {},
3969
+ status: outcome.status === "skipped" ? "cancelled" : outcome.status === "failed" ? "failed" : outcome.status === "partial" ? "partially_completed" : "completed",
2793
3970
  ...outcome.taskIds.length > 0 ? { taskIds: outcome.taskIds } : {},
2794
3971
  ...outcome.planId !== void 0 ? { planId: outcome.planId } : {},
2795
3972
  ...outcome.error !== void 0 ? { error: outcome.error } : {}
@@ -2798,10 +3975,12 @@ ${contribution}`);
2798
3975
  options.onStep?.(stepRun);
2799
3976
  });
2800
3977
  run2.reports = [...allReports];
2801
- await this.options.store.save(run2);
3978
+ await this.persist(run2);
2802
3979
  }
2803
- const failed = [...outcomes.values()].some((o) => o.status === "failed");
2804
- const finalStatus = failed ? "failed" : "completed";
3980
+ const allOutcomes = [...outcomes.values()];
3981
+ const failed = !cancelled && allOutcomes.some((o) => o.status === "failed");
3982
+ const partial = !cancelled && !failed && allOutcomes.some((o) => o.status === "partial");
3983
+ const finalStatus = cancelled ? "cancelled" : failed ? "failed" : partial ? "partial" : "completed";
2805
3984
  const planId = [...outcomes.values()].find((o) => o.planId !== void 0)?.planId;
2806
3985
  let resultSummary;
2807
3986
  for (let i = def.steps.length - 1; i >= 0; i -= 1) {
@@ -2829,16 +4008,19 @@ ${contribution}`);
2829
4008
  run2.reports = collected;
2830
4009
  }
2831
4010
  }
4011
+ const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
4012
+ this.activeRuns.delete(run2.id);
2832
4013
  const finished = {
2833
4014
  ...run2,
2834
4015
  status: finalStatus,
2835
- finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
4016
+ finishedAt,
4017
+ attempts: [...run2.attempts ?? [], { attempt: run2.attempt, startedAt: run2.startedAt ?? finishedAt, finishedAt, status: finalStatus }],
2836
4018
  ...run2.reports !== void 0 && run2.reports.length > 0 ? { reports: run2.reports } : {},
2837
4019
  ...combinedReportId !== void 0 ? { combinedReportId } : {},
2838
4020
  ...planId !== void 0 ? { planId } : {},
2839
4021
  ...resultSummary !== void 0 ? { resultSummary } : {}
2840
4022
  };
2841
- await this.options.store.save(finished);
4023
+ await this.persist(finished);
2842
4024
  this.log.info(`Pipeline ${def.id} finished: ${finalStatus}.`);
2843
4025
  await this.options.audit?.record({
2844
4026
  at: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2848,6 +4030,23 @@ ${contribution}`);
2848
4030
  });
2849
4031
  return finished;
2850
4032
  }
4033
+ /** First-class cancellation (§9.2): requests cancellation and terminates
4034
+ * tasks that are running in this process immediately. */
4035
+ async cancel(runId) {
4036
+ const run2 = await this.options.store.get(runId);
4037
+ if (!run2) {
4038
+ throw new PipelineError("PIPELINE_NOT_FOUND", `Pipeline run "${runId}" does not exist.`);
4039
+ }
4040
+ await this.options.store.save({
4041
+ ...run2,
4042
+ cancellationRequested: true,
4043
+ cancellationRequestedAt: (/* @__PURE__ */ new Date()).toISOString()
4044
+ });
4045
+ const active = this.activeRuns.get(runId);
4046
+ if (active) {
4047
+ await active.cancelTasks();
4048
+ }
4049
+ }
2851
4050
  async get(runId) {
2852
4051
  return this.options.store.get(runId);
2853
4052
  }
@@ -2871,9 +4070,16 @@ ${contribution}`);
2871
4070
  this.log.warn(`Step "${step.id}" failed \u2014 retry ${attempt}/${retries}.`);
2872
4071
  }
2873
4072
  last = await this.executeStepOnce(step, ctx);
2874
- if (last.status !== "failed") return last;
4073
+ if (last.status !== "failed") return { ...last, attempt: attempt + 1 };
2875
4074
  }
2876
- return last;
4075
+ return { ...last, attempt: retries + 1 };
4076
+ }
4077
+ /** Registers tasks created for a step as live, so cancellation can terminate them (§9.2). */
4078
+ trackLiveTasks(runId, taskIds) {
4079
+ if (!runId) return;
4080
+ const active = this.activeRuns.get(runId);
4081
+ if (!active) return;
4082
+ for (const id of taskIds) active.taskIds.add(id);
2877
4083
  }
2878
4084
  async executeStepOnce(step, ctx) {
2879
4085
  switch (step.kind) {
@@ -2906,7 +4112,10 @@ ${contribution}`);
2906
4112
  })
2907
4113
  )
2908
4114
  );
4115
+ this.trackLiveTasks(ctx.runId, created.map((t) => t.id));
2909
4116
  const results = await Promise.all(created.map((task) => this.options.tasks.run(task.id)));
4117
+ const executionIds = results.map((r) => r.executions?.at(-1)?.executionId).filter((id) => id !== void 0);
4118
+ const completedTasks = results.filter((r) => r.status === "completed");
2910
4119
  const failedTasks = results.filter((r) => r.status !== "completed");
2911
4120
  const lines = results.map((result, index) => {
2912
4121
  const agentName = agentNames[index] ?? "unknown";
@@ -2916,16 +4125,25 @@ ${contribution}`);
2916
4125
  const reports = results.flatMap((r) => r.result?.reports ?? []);
2917
4126
  const summary = lines.join("\n");
2918
4127
  if (failedTasks.length > 0) {
2919
- const firstError = failedTasks[0]?.result?.error ?? "unknown error";
4128
+ const firstError = failedTasks[0]?.result?.error ?? failedTasks[0]?.lastError ?? "unknown outcome";
4129
+ const states = failedTasks.map((r) => r.status).join(", ");
2920
4130
  return {
2921
- status: "failed",
4131
+ // Some agents succeeded → the step produced usable but incomplete
4132
+ // intelligence: partial, not failed (roadmap §9.3).
4133
+ status: completedTasks.length > 0 ? "partial" : "failed",
2922
4134
  summary,
2923
4135
  taskIds: created.map((t) => t.id),
2924
- error: `${failedTasks.length}/${results.length} task(s) failed \u2014 ${firstError}`,
4136
+ error: `${failedTasks.length}/${results.length} task(s) incomplete (${states}) \u2014 ${firstError}`,
2925
4137
  reports
2926
4138
  };
2927
4139
  }
2928
- return { status: "completed", summary, taskIds: created.map((t) => t.id), reports };
4140
+ return {
4141
+ status: "completed",
4142
+ summary,
4143
+ taskIds: created.map((t) => t.id),
4144
+ ...executionIds.length > 0 ? { executionIds } : {},
4145
+ reports
4146
+ };
2929
4147
  }
2930
4148
  async runSingle(step, ctx, agentName) {
2931
4149
  const task = await this.options.tasks.create({
@@ -2934,10 +4152,13 @@ ${contribution}`);
2934
4152
  ...ctx.role ? { role: ctx.role.id } : {},
2935
4153
  ...ctx.combinedContext.length > 0 ? { context: { background: ctx.combinedContext } } : {}
2936
4154
  });
4155
+ this.trackLiveTasks(ctx.runId, [task.id]);
2937
4156
  const result = await this.options.tasks.run(task.id);
2938
4157
  if (result.status !== "completed") {
2939
4158
  return {
2940
- status: "failed",
4159
+ // Unknown/timed_out outcomes are not failures — the work may have
4160
+ // happened remotely; the step is incomplete, not broken.
4161
+ status: result.status === "unknown" || result.status === "timed_out" ? "partial" : "failed",
2941
4162
  summary: result.result?.summary ?? "",
2942
4163
  taskIds: [task.id],
2943
4164
  error: result.result?.error ?? `task ${task.id} did not complete`,
@@ -3053,114 +4274,44 @@ function createDefaultChatFn(providers) {
3053
4274
  }
3054
4275
 
3055
4276
  // src/pipelines/store.ts
3056
- import { mkdir as mkdir5, readFile as readFile4, rename as rename4, writeFile as writeFile4 } from "fs/promises";
3057
- import { dirname as dirname5 } from "path";
3058
- var FilePipelineRunStore = class {
3059
- filePath;
3060
- queue = Promise.resolve();
3061
- constructor(directory = `${process.cwd()}/.omninode`) {
3062
- this.filePath = `${directory}/pipelines.json`;
4277
+ var FilePipelineRunStore = class extends JsonFileStore {
4278
+ constructor(directory) {
4279
+ super({ ...directory !== void 0 ? { directory } : {}, fileName: "pipelines.json" });
3063
4280
  }
3064
- async save(run2) {
3065
- return this.synchronized(async () => {
3066
- const runs = await this.load();
4281
+ save(run2) {
4282
+ return this.mutate((runs) => {
3067
4283
  const index = runs.findIndex((r) => r.id === run2.id);
3068
4284
  if (index >= 0) runs[index] = run2;
3069
4285
  else runs.push(run2);
3070
- await this.write(runs);
4286
+ return [runs, void 0];
3071
4287
  });
3072
4288
  }
3073
4289
  async get(id) {
3074
- return this.synchronized(async () => {
3075
- const runs = await this.load();
3076
- return runs.find((r) => r.id === id);
3077
- });
4290
+ return (await this.readAll()).find((r) => r.id === id);
3078
4291
  }
3079
4292
  async list(filter = {}) {
3080
- return this.synchronized(async () => {
3081
- const runs = await this.load();
3082
- return runs.filter((run2) => filter.pipelineId ? run2.pipelineId === filter.pipelineId : true).filter((run2) => filter.status ? run2.status === filter.status : true).sort((a, b) => (a.startedAt ?? "").localeCompare(b.startedAt ?? ""));
3083
- });
3084
- }
3085
- synchronized(operation) {
3086
- const result = this.queue.then(operation, operation);
3087
- this.queue = result.then(
3088
- () => void 0,
3089
- () => void 0
3090
- );
3091
- return result;
3092
- }
3093
- async load() {
3094
- try {
3095
- const raw = await readFile4(this.filePath, "utf8");
3096
- const parsed = JSON.parse(raw);
3097
- return Array.isArray(parsed) ? parsed : [];
3098
- } catch {
3099
- return [];
3100
- }
3101
- }
3102
- async write(runs) {
3103
- const tmp = `${this.filePath}.tmp`;
3104
- await mkdir5(dirname5(this.filePath), { recursive: true });
3105
- await writeFile4(tmp, `${JSON.stringify(runs, null, 2)}
3106
- `, "utf8");
3107
- await rename4(tmp, this.filePath);
4293
+ return (await this.readAll()).filter((run2) => filter.pipelineId ? run2.pipelineId === filter.pipelineId : true).filter((run2) => filter.status ? run2.status === filter.status : true).sort((a, b) => (a.startedAt ?? "").localeCompare(b.startedAt ?? ""));
3108
4294
  }
3109
4295
  };
3110
4296
 
3111
4297
  // src/planner/store.ts
3112
- import { mkdir as mkdir6, readFile as readFile5, rename as rename5, writeFile as writeFile5 } from "fs/promises";
3113
- import { dirname as dirname6 } from "path";
3114
- var FilePlanStore = class {
3115
- filePath;
3116
- queue = Promise.resolve();
3117
- constructor(directory = `${process.cwd()}/.omninode`) {
3118
- this.filePath = `${directory}/plans.json`;
4298
+ var FilePlanStore = class extends JsonFileStore {
4299
+ constructor(directory) {
4300
+ super({ ...directory !== void 0 ? { directory } : {}, fileName: "plans.json" });
3119
4301
  }
3120
- async save(plan) {
3121
- return this.synchronized(async () => {
3122
- const plans = await this.load();
4302
+ save(plan) {
4303
+ return this.mutate((plans) => {
3123
4304
  const index = plans.findIndex((p) => p.id === plan.id);
3124
4305
  if (index >= 0) plans[index] = plan;
3125
4306
  else plans.push(plan);
3126
- await this.write(plans);
4307
+ return [plans, void 0];
3127
4308
  });
3128
4309
  }
3129
4310
  async get(id) {
3130
- return this.synchronized(async () => {
3131
- const plans = await this.load();
3132
- return plans.find((p) => p.id === id);
3133
- });
4311
+ return (await this.readAll()).find((p) => p.id === id);
3134
4312
  }
3135
4313
  async list(filter = {}) {
3136
- return this.synchronized(async () => {
3137
- const plans = await this.load();
3138
- return plans.filter((plan) => filter.pipelineRunId ? plan.pipelineRunId === filter.pipelineRunId : true).filter((plan) => filter.taskId ? plan.taskId === filter.taskId : true).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
3139
- });
3140
- }
3141
- synchronized(operation) {
3142
- const result = this.queue.then(operation, operation);
3143
- this.queue = result.then(
3144
- () => void 0,
3145
- () => void 0
3146
- );
3147
- return result;
3148
- }
3149
- async load() {
3150
- try {
3151
- const raw = await readFile5(this.filePath, "utf8");
3152
- const parsed = JSON.parse(raw);
3153
- return Array.isArray(parsed) ? parsed : [];
3154
- } catch {
3155
- return [];
3156
- }
3157
- }
3158
- async write(plans) {
3159
- const tmp = `${this.filePath}.tmp`;
3160
- await mkdir6(dirname6(this.filePath), { recursive: true });
3161
- await writeFile5(tmp, `${JSON.stringify(plans, null, 2)}
3162
- `, "utf8");
3163
- await rename5(tmp, this.filePath);
4314
+ return (await this.readAll()).filter((plan) => filter.pipelineRunId ? plan.pipelineRunId === filter.pipelineRunId : true).filter((plan) => filter.taskId ? plan.taskId === filter.taskId : true).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
3164
4315
  }
3165
4316
  };
3166
4317
 
@@ -3194,7 +4345,11 @@ function buildPipelineEngine(config, log = logger) {
3194
4345
  agents,
3195
4346
  roles,
3196
4347
  store: new FileTaskStore(),
3197
- ...config.project.memory !== void 0 ? { memory: new MemoryService(createMemoryProvider(config.project.memory), log) } : {},
4348
+ ...config.project.memory !== void 0 ? {
4349
+ memory: new MemoryService(createMemoryProvider(config.project.memory), log, {
4350
+ required: config.project.memory.required
4351
+ })
4352
+ } : {},
3198
4353
  audit: new FileAuditLog(),
3199
4354
  log
3200
4355
  });
@@ -3252,6 +4407,24 @@ function registerPipelineCommands(program) {
3252
4407
  }
3253
4408
  await runPipelineAction(def.id, objective ?? previous.objective);
3254
4409
  });
4410
+ pipeline.command("cancel <run-id>").description("Request cancellation of a pipeline run (stops scheduling and cancels running tasks).").action(async (runId) => {
4411
+ const config = loadConfig();
4412
+ const engine = buildPipelineEngine(config);
4413
+ await engine.cancel(runId);
4414
+ console.log(`Cancellation requested for run ${runId}.`);
4415
+ console.log("A run executing in another process stops at its next check point.");
4416
+ });
4417
+ pipeline.command("validate <id>").description("Validate a pipeline definition before running it (\xA79.5).").action(async (id) => {
4418
+ const config = loadConfig();
4419
+ const def = findPipeline(config, id);
4420
+ const engine = buildPipelineEngine(config);
4421
+ engine.validate(def);
4422
+ console.log(`Pipeline "${def.id}" is valid (${def.steps.length} step(s)).`);
4423
+ for (const step of def.steps) {
4424
+ const deps = step.dependsOn?.join(", ") ?? "(previous step)";
4425
+ console.log(` ${step.id} kind=${step.kind} depends_on=${deps}`);
4426
+ }
4427
+ });
3255
4428
  pipeline.command("runs <id>").description("List recorded runs of a pipeline.").action(async (id) => {
3256
4429
  const config = loadConfig();
3257
4430
  const engine = buildPipelineEngine(config);
@@ -3261,7 +4434,8 @@ function registerPipelineCommands(program) {
3261
4434
  return;
3262
4435
  }
3263
4436
  for (const run2 of runs) {
3264
- console.log(`${run2.id} ${run2.status.padEnd(10)} ${run2.startedAt ?? "?"}`);
4437
+ const note = isInterrupted(run2) ? " (interrupted \u2014 process gone before finish)" : "";
4438
+ console.log(`${run2.id} ${run2.status.padEnd(10)} ${run2.startedAt ?? "?"}${note}`);
3265
4439
  }
3266
4440
  });
3267
4441
  pipeline.command("status <run-id>").description("Show the recorded state of a pipeline run.").action(async (runId) => {
@@ -3279,6 +4453,13 @@ function registerPipelineCommands(program) {
3279
4453
  console.log(` status: ${run2.status}`);
3280
4454
  console.log(` started: ${run2.startedAt ?? "?"}`);
3281
4455
  if (run2.finishedAt) console.log(` finished: ${run2.finishedAt}`);
4456
+ console.log(` attempt: ${run2.attempt}`);
4457
+ if (run2.cancellationRequested) {
4458
+ console.log(` cancellation requested at ${run2.cancellationRequestedAt ?? "?"}`);
4459
+ }
4460
+ if (isInterrupted(run2)) {
4461
+ console.log(" note: this run looks interrupted (no finishedAt) \u2014 retry with `pipeline retry`");
4462
+ }
3282
4463
  if (run2.resultSummary) console.log(` result: ${run2.resultSummary}`);
3283
4464
  for (const stepRun of run2.stepRuns) {
3284
4465
  console.log(` [${stepRun.stepId}] ${stepRun.status}`);
@@ -3320,6 +4501,9 @@ async function runPipelineAction(id, objective) {
3320
4501
  function registerRunAlias(program) {
3321
4502
  program.command("run <id> [objective]").description("Alias of `omninode pipeline run` \u2014 execute a configured pipeline end to end.").action(async (id, objective) => runPipelineAction(id, objective));
3322
4503
  }
4504
+ function isInterrupted(run2) {
4505
+ return run2.status === "running" && run2.finishedAt === void 0;
4506
+ }
3323
4507
  function findPipeline(config, id) {
3324
4508
  const def = config.project.pipelines.find((p) => p.id === id);
3325
4509
  if (!def) {