@exaudeus/workrail 3.39.0 → 3.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/cli/commands/init.js +0 -3
  2. package/dist/cli-worktrain.js +58 -26
  3. package/dist/cli.js +0 -18
  4. package/dist/config/app-config.d.ts +0 -16
  5. package/dist/config/app-config.js +0 -14
  6. package/dist/config/config-file.js +0 -3
  7. package/dist/console-ui/assets/index-CQt4UhPB.js +28 -0
  8. package/dist/console-ui/assets/index-DGj8EsFR.css +1 -0
  9. package/dist/console-ui/index.html +2 -2
  10. package/dist/coordinators/pr-review.d.ts +23 -1
  11. package/dist/coordinators/pr-review.js +224 -5
  12. package/dist/daemon/daemon-events.d.ts +9 -1
  13. package/dist/daemon/soul-template.d.ts +2 -2
  14. package/dist/daemon/soul-template.js +11 -1
  15. package/dist/daemon/workflow-runner.d.ts +17 -3
  16. package/dist/daemon/workflow-runner.js +401 -28
  17. package/dist/di/container.js +1 -25
  18. package/dist/di/tokens.d.ts +0 -3
  19. package/dist/di/tokens.js +0 -3
  20. package/dist/engine/engine-factory.js +0 -1
  21. package/dist/infrastructure/console-defaults.d.ts +1 -0
  22. package/dist/infrastructure/console-defaults.js +4 -0
  23. package/dist/infrastructure/session/index.d.ts +0 -1
  24. package/dist/infrastructure/session/index.js +1 -3
  25. package/dist/manifest.json +124 -124
  26. package/dist/mcp/handlers/session.d.ts +1 -0
  27. package/dist/mcp/handlers/session.js +61 -13
  28. package/dist/mcp/output-schemas.d.ts +10 -10
  29. package/dist/mcp/server.js +1 -18
  30. package/dist/mcp/tools.d.ts +12 -12
  31. package/dist/mcp/transports/http-entry.js +0 -2
  32. package/dist/mcp/transports/stdio-entry.js +1 -2
  33. package/dist/mcp/types.d.ts +0 -2
  34. package/dist/trigger/daemon-console.d.ts +2 -0
  35. package/dist/trigger/daemon-console.js +1 -1
  36. package/dist/trigger/trigger-listener.d.ts +2 -0
  37. package/dist/trigger/trigger-listener.js +3 -1
  38. package/dist/trigger/trigger-router.d.ts +4 -3
  39. package/dist/trigger/trigger-router.js +13 -5
  40. package/dist/trigger/trigger-store.js +17 -4
  41. package/dist/types/workflow-source.d.ts +0 -1
  42. package/dist/types/workflow-source.js +3 -6
  43. package/dist/types/workflow.d.ts +1 -1
  44. package/dist/types/workflow.js +1 -2
  45. package/dist/v2/durable-core/domain/artifact-contract-validator.js +66 -0
  46. package/dist/v2/durable-core/schemas/artifacts/coordinator-signal.d.ts +25 -0
  47. package/dist/v2/durable-core/schemas/artifacts/coordinator-signal.js +31 -0
  48. package/dist/v2/durable-core/schemas/artifacts/index.d.ts +3 -1
  49. package/dist/v2/durable-core/schemas/artifacts/index.js +14 -1
  50. package/dist/v2/durable-core/schemas/artifacts/review-verdict.d.ts +41 -0
  51. package/dist/v2/durable-core/schemas/artifacts/review-verdict.js +30 -0
  52. package/dist/v2/durable-core/schemas/export-bundle/index.d.ts +236 -236
  53. package/dist/v2/durable-core/schemas/session/events.d.ts +50 -50
  54. package/dist/v2/durable-core/schemas/session/gaps.d.ts +2 -2
  55. package/dist/v2/durable-core/schemas/session/manifest.d.ts +4 -4
  56. package/dist/v2/durable-core/schemas/session/outputs.d.ts +8 -8
  57. package/dist/v2/usecases/console-routes.d.ts +2 -1
  58. package/dist/v2/usecases/console-routes.js +207 -5
  59. package/dist/v2/usecases/console-service.js +14 -0
  60. package/dist/v2/usecases/console-types.d.ts +1 -0
  61. package/docs/authoring.md +16 -16
  62. package/docs/design/coordinator-artifact-protocol-design-candidates.md +155 -0
  63. package/docs/design/coordinator-artifact-protocol-design-review.md +103 -0
  64. package/docs/design/coordinator-artifact-protocol-implementation-plan.md +259 -0
  65. package/docs/design/coordinator-message-queue-drain-plan.md +241 -0
  66. package/docs/design/coordinator-message-queue-drain-review.md +120 -0
  67. package/docs/design/coordinator-message-queue-drain.md +289 -0
  68. package/docs/design/shaping-workflow-external-research.md +119 -0
  69. package/docs/discovery/late-bound-goals-impl-plan.md +147 -0
  70. package/docs/discovery/late-bound-goals-review.md +82 -0
  71. package/docs/discovery/late-bound-goals.md +118 -0
  72. package/docs/discovery/steer-endpoint-design-candidates.md +288 -0
  73. package/docs/discovery/steer-endpoint-design-review-findings.md +104 -0
  74. package/docs/discovery/steer-endpoint-implementation-plan.md +284 -0
  75. package/docs/ideas/backlog.md +447 -97
  76. package/docs/ideas/design-candidates-console-session-tree-impl.md +64 -0
  77. package/docs/ideas/design-candidates-session-tree-view.md +196 -0
  78. package/docs/ideas/design-review-findings-console-session-tree-impl.md +75 -0
  79. package/docs/ideas/design-review-findings-session-tree-view.md +88 -0
  80. package/docs/ideas/implementation_plan_session_tree_view.md +238 -0
  81. package/package.json +2 -1
  82. package/spec/authoring-spec.json +16 -16
  83. package/spec/shape.schema.json +178 -0
  84. package/spec/workflow-tags.json +232 -47
  85. package/workflows/coding-task-workflow-agentic.json +491 -480
  86. package/workflows/mr-review-workflow.agentic.v2.json +5 -1
  87. package/workflows/wr.shaping.json +182 -0
  88. package/dist/console-ui/assets/index-3oXZ_A9m.js +0 -28
  89. package/dist/console-ui/assets/index-8dh0Psu-.css +0 -1
  90. package/dist/infrastructure/session/DashboardHeartbeat.d.ts +0 -8
  91. package/dist/infrastructure/session/DashboardHeartbeat.js +0 -39
  92. package/dist/infrastructure/session/DashboardLockRelease.d.ts +0 -2
  93. package/dist/infrastructure/session/DashboardLockRelease.js +0 -29
  94. package/dist/infrastructure/session/HttpServer.d.ts +0 -60
  95. package/dist/infrastructure/session/HttpServer.js +0 -912
  96. package/workflows/coding-task-workflow-agentic.lean.v2.json +0 -648
  97. package/workflows/coding-task-workflow-agentic.v2.json +0 -324
@@ -36,15 +36,21 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.DAEMON_SOUL_TEMPLATE = exports.DAEMON_SOUL_DEFAULT = exports.DAEMON_SESSIONS_DIR = void 0;
39
+ exports.DAEMON_SIGNALS_DIR = exports.DAEMON_SOUL_TEMPLATE = exports.DAEMON_SOUL_DEFAULT = exports.DAEMON_SESSIONS_DIR = void 0;
40
40
  exports.readDaemonSessionState = readDaemonSessionState;
41
41
  exports.readAllDaemonSessions = readAllDaemonSessions;
42
42
  exports.runStartupRecovery = runStartupRecovery;
43
43
  exports.makeContinueWorkflowTool = makeContinueWorkflowTool;
44
44
  exports.makeCompleteStepTool = makeCompleteStepTool;
45
45
  exports.makeBashTool = makeBashTool;
46
+ exports.makeReadTool = makeReadTool;
47
+ exports.makeWriteTool = makeWriteTool;
48
+ exports.makeGlobTool = makeGlobTool;
49
+ exports.makeGrepTool = makeGrepTool;
50
+ exports.makeEditTool = makeEditTool;
46
51
  exports.makeSpawnAgentTool = makeSpawnAgentTool;
47
52
  exports.makeReportIssueTool = makeReportIssueTool;
53
+ exports.makeSignalCoordinatorTool = makeSignalCoordinatorTool;
48
54
  exports.buildSessionRecap = buildSessionRecap;
49
55
  exports.buildSystemPrompt = buildSystemPrompt;
50
56
  exports.runWorkflow = runWorkflow;
@@ -54,6 +60,7 @@ const path = __importStar(require("node:path"));
54
60
  const os = __importStar(require("node:os"));
55
61
  const node_child_process_1 = require("node:child_process");
56
62
  const node_util_1 = require("node:util");
63
+ const tinyglobby_1 = require("tinyglobby");
57
64
  const node_crypto_1 = require("node:crypto");
58
65
  const sdk_1 = __importDefault(require("@anthropic-ai/sdk"));
59
66
  const bedrock_sdk_1 = require("@anthropic-ai/bedrock-sdk");
@@ -65,6 +72,7 @@ const index_js_2 = require("../v2/durable-core/ids/index.js");
65
72
  const node_outputs_js_1 = require("../v2/projections/node-outputs.js");
66
73
  const assert_never_js_1 = require("../runtime/assert-never.js");
67
74
  const execAsync = (0, node_util_1.promisify)(node_child_process_1.exec);
75
+ const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
68
76
  const BASH_TIMEOUT_MS = 5 * 60 * 1000;
69
77
  const MAX_SESSION_RECAP_NOTES = 3;
70
78
  const MAX_SESSION_NOTE_CHARS = 800;
@@ -365,7 +373,9 @@ function getSchemas() {
365
373
  ReadParams: {
366
374
  type: 'object',
367
375
  properties: {
368
- filePath: { type: 'string', description: 'Absolute path to the file to read' },
376
+ filePath: { type: 'string', description: 'Absolute path to the file to read. Content is returned in cat -n format: each line prefixed with its 1-indexed line number and a tab character.' },
377
+ offset: { type: 'number', description: '0-indexed line number to start reading from (inclusive). Omit to read from the beginning.' },
378
+ limit: { type: 'number', description: 'Maximum number of lines to return. Omit to read to end of file.' },
369
379
  },
370
380
  required: ['filePath'],
371
381
  },
@@ -377,6 +387,39 @@ function getSchemas() {
377
387
  },
378
388
  required: ['filePath', 'content'],
379
389
  },
390
+ GlobParams: {
391
+ type: 'object',
392
+ properties: {
393
+ pattern: { type: 'string', description: 'Glob pattern to match (e.g. "**/*.ts"). Supports standard glob syntax.' },
394
+ path: { type: 'string', description: 'Absolute path to search root. Defaults to the workspace root.' },
395
+ },
396
+ required: ['pattern'],
397
+ },
398
+ GrepParams: {
399
+ type: 'object',
400
+ properties: {
401
+ pattern: { type: 'string', description: 'Regular expression pattern to search for in file contents.' },
402
+ path: { type: 'string', description: 'Absolute path to search in. Defaults to the workspace root.' },
403
+ glob: { type: 'string', description: 'Glob pattern to restrict which files are searched (e.g. "*.ts").' },
404
+ type: { type: 'string', description: 'File type filter for ripgrep (e.g. "ts", "js", "py").' },
405
+ output_mode: { type: 'string', enum: ['content', 'files_with_matches', 'count'], description: 'Output mode. "files_with_matches": only file paths (default). "content": matching lines with context. "count": match counts per file.' },
406
+ head_limit: { type: 'number', description: 'Maximum number of output lines to return. Default: 250.' },
407
+ context: { type: 'number', description: 'Number of lines of context to show before and after each match (output_mode=content only).' },
408
+ '-i': { type: 'boolean', description: 'Case-insensitive search.' },
409
+ },
410
+ required: ['pattern'],
411
+ },
412
+ EditParams: {
413
+ type: 'object',
414
+ properties: {
415
+ file_path: { type: 'string', description: 'Absolute path to the file to edit. The file must have been read in this session via the Read tool.' },
416
+ old_string: { type: 'string', description: 'Exact string to find and replace. Must appear exactly once in the file (or use replace_all=true for multiple occurrences). Do NOT include line-number prefixes from Read output.' },
417
+ new_string: { type: 'string', description: 'Replacement string. Must differ from old_string.' },
418
+ replace_all: { type: 'boolean', description: 'Replace all occurrences of old_string. Default: false (fails if more than one match).' },
419
+ },
420
+ required: ['file_path', 'old_string', 'new_string'],
421
+ additionalProperties: false,
422
+ },
380
423
  SpawnAgentParams: {
381
424
  type: 'object',
382
425
  properties: {
@@ -479,7 +522,7 @@ function makeContinueWorkflowTool(sessionId, ctx, onAdvance, onComplete, schemas
479
522
  };
480
523
  }
481
524
  if (out.isComplete) {
482
- onComplete(params.notesMarkdown);
525
+ onComplete(params.notesMarkdown, Array.isArray(params.artifacts) ? params.artifacts : undefined);
483
526
  return {
484
527
  content: [{ type: 'text', text: 'Workflow complete. All steps have been executed.' }],
485
528
  details: out,
@@ -581,7 +624,7 @@ function makeCompleteStepTool(sessionId, ctx, getCurrentToken, onAdvance, onComp
581
624
  };
582
625
  }
583
626
  if (out.isComplete) {
584
- onComplete(notes);
627
+ onComplete(notes, Array.isArray(params.artifacts) ? params.artifacts : undefined);
585
628
  return {
586
629
  content: [{ type: 'text', text: JSON.stringify({ status: 'complete' }) }],
587
630
  details: out,
@@ -648,37 +691,282 @@ function makeBashTool(workspacePath, schemas, sessionId, emitter, workrailSessio
648
691
  },
649
692
  };
650
693
  }
651
- function makeReadTool(schemas, sessionId, emitter, workrailSessionId) {
694
+ function findActualString(fileContent, oldString) {
695
+ if (fileContent.includes(oldString))
696
+ return oldString;
697
+ const normalized = oldString
698
+ .replace(/[\u2018\u2019]/g, "'")
699
+ .replace(/[\u201C\u201D]/g, '"')
700
+ .replace(/\u2013/g, '-')
701
+ .replace(/\u2014/g, '--');
702
+ if (fileContent.includes(normalized))
703
+ return normalized;
704
+ return null;
705
+ }
706
+ const READ_SIZE_CAP_BYTES = 256 * 1024;
707
+ const GLOB_ALWAYS_EXCLUDE = ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'];
708
+ function makeReadTool(readFileState, schemas, sessionId, emitter, workrailSessionId) {
652
709
  return {
653
710
  name: 'Read',
654
- description: 'Read the contents of a file at the given absolute path.',
711
+ description: 'Read the contents of a file at the given absolute path. ' +
712
+ 'Content is returned in cat -n format: each line is prefixed with its 1-indexed line number and a tab character (e.g. "1\\tline one\\n2\\tline two"). ' +
713
+ 'Use offset (0-indexed start line) and limit (max lines) to read a slice of a large file.',
655
714
  inputSchema: schemas['ReadParams'],
656
715
  label: 'Read',
657
716
  execute: async (_toolCallId, params) => {
717
+ const filePath = params.filePath;
658
718
  if (sessionId)
659
- emitter?.emit({ kind: 'tool_called', sessionId, toolName: 'Read', summary: String(params.filePath).slice(0, 80), ...withWorkrailSession(workrailSessionId) });
660
- const content = await fs.readFile(params.filePath, 'utf8');
719
+ emitter?.emit({ kind: 'tool_called', sessionId, toolName: 'Read', summary: filePath.slice(0, 80), ...withWorkrailSession(workrailSessionId) });
720
+ const devPaths = ['/dev/stdin', '/dev/tty', '/dev/zero', '/dev/random', '/dev/full', '/dev/urandom'];
721
+ if (devPaths.some(d => filePath === d)) {
722
+ throw new Error(`Refusing to read device path: ${filePath}`);
723
+ }
724
+ const stat = await fs.stat(filePath);
725
+ const offset = params.offset ?? 0;
726
+ const limit = params.limit;
727
+ const isPaginated = params.offset !== undefined || params.limit !== undefined;
728
+ if (!isPaginated && stat.size > READ_SIZE_CAP_BYTES) {
729
+ throw new Error(`File is too large to read at once (${stat.size} bytes, cap is ${READ_SIZE_CAP_BYTES} bytes). ` +
730
+ `Use offset and limit parameters to read a specific range of lines.`);
731
+ }
732
+ const rawContent = await fs.readFile(filePath, 'utf8');
733
+ const allLines = rawContent.split('\n');
734
+ const isPartialView = offset !== 0 || limit != null;
735
+ const slicedLines = limit != null ? allLines.slice(offset, offset + limit) : allLines.slice(offset);
736
+ const startLine = offset;
737
+ const formatted = slicedLines.map((l, i) => `${startLine + i + 1}\t${l}`).join('\n');
738
+ readFileState.set(filePath, { content: rawContent, timestamp: stat.mtimeMs, isPartialView });
661
739
  return {
662
- content: [{ type: 'text', text: content }],
663
- details: { filePath: params.filePath, length: content.length },
740
+ content: [{ type: 'text', text: formatted }],
741
+ details: { filePath, totalLines: allLines.length, returnedLines: slicedLines.length, offset, isPartialView },
664
742
  };
665
743
  },
666
744
  };
667
745
  }
668
- function makeWriteTool(schemas, sessionId, emitter, workrailSessionId) {
746
+ function makeWriteTool(readFileState, schemas, sessionId, emitter, workrailSessionId) {
669
747
  return {
670
748
  name: 'Write',
671
- description: 'Write content to a file at the given absolute path. Creates parent directories if needed.',
749
+ description: 'Write content to a file at the given absolute path. Creates parent directories if needed. ' +
750
+ 'For existing files: the file must have been read in this session and must not have changed on disk since then. ' +
751
+ 'For new files (path does not exist): no prior read is required.',
672
752
  inputSchema: schemas['WriteParams'],
673
753
  label: 'Write',
674
754
  execute: async (_toolCallId, params) => {
755
+ const filePath = params.filePath;
756
+ if (sessionId)
757
+ emitter?.emit({ kind: 'tool_called', sessionId, toolName: 'Write', summary: filePath.slice(0, 80), ...withWorkrailSession(workrailSessionId) });
758
+ let existsOnDisk = false;
759
+ try {
760
+ await fs.access(filePath);
761
+ existsOnDisk = true;
762
+ }
763
+ catch {
764
+ }
765
+ if (existsOnDisk) {
766
+ const state = readFileState.get(filePath);
767
+ if (!state) {
768
+ throw new Error(`File has not been read in this session. Call Read first before writing to it: ${filePath}`);
769
+ }
770
+ const stat = await fs.stat(filePath);
771
+ if (stat.mtimeMs !== state.timestamp) {
772
+ throw new Error(`File has been modified since it was read. Re-read before writing: ${filePath}`);
773
+ }
774
+ }
775
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
776
+ await fs.writeFile(filePath, params.content, 'utf8');
777
+ const newStat = await fs.stat(filePath);
778
+ readFileState.set(filePath, { content: params.content, timestamp: newStat.mtimeMs, isPartialView: false });
779
+ return {
780
+ content: [{ type: 'text', text: `Written ${params.content.length} bytes to ${filePath}` }],
781
+ details: { filePath, length: params.content.length },
782
+ };
783
+ },
784
+ };
785
+ }
786
+ function makeGlobTool(workspacePath, schemas, sessionId, emitter, workrailSessionId) {
787
+ return {
788
+ name: 'Glob',
789
+ description: 'Find files matching a glob pattern. Returns newline-separated relative file paths, sorted by modification time descending. ' +
790
+ 'node_modules, .git, dist, and build directories are always excluded. ' +
791
+ 'Results are capped at 100 files.',
792
+ inputSchema: schemas['GlobParams'],
793
+ label: 'Glob',
794
+ execute: async (_toolCallId, params) => {
795
+ const pattern = params.pattern;
796
+ const searchRoot = params.path ?? workspacePath;
797
+ if (sessionId)
798
+ emitter?.emit({ kind: 'tool_called', sessionId, toolName: 'Glob', summary: pattern.slice(0, 80), ...withWorkrailSession(workrailSessionId) });
799
+ const GLOB_LIMIT = 100;
800
+ let paths;
801
+ try {
802
+ paths = await (0, tinyglobby_1.glob)(pattern, {
803
+ cwd: searchRoot,
804
+ ignore: GLOB_ALWAYS_EXCLUDE,
805
+ absolute: false,
806
+ });
807
+ }
808
+ catch {
809
+ paths = [];
810
+ }
811
+ const withMtimes = await Promise.all(paths.map(async (p) => {
812
+ try {
813
+ const stat = await fs.stat(path.join(searchRoot, p));
814
+ return { p, mtime: stat.mtimeMs };
815
+ }
816
+ catch {
817
+ return { p, mtime: 0 };
818
+ }
819
+ }));
820
+ withMtimes.sort((a, b) => b.mtime - a.mtime);
821
+ const sorted = withMtimes.map(x => x.p);
822
+ const truncated = sorted.length > GLOB_LIMIT;
823
+ const result = sorted.slice(0, GLOB_LIMIT);
824
+ let text = result.join('\n');
825
+ if (truncated) {
826
+ text += '\n[Results truncated at 100 files]';
827
+ }
828
+ return {
829
+ content: [{ type: 'text', text: text || '(no matches)' }],
830
+ details: { pattern, searchRoot, matchCount: sorted.length, truncated },
831
+ };
832
+ },
833
+ };
834
+ }
835
+ function makeGrepTool(workspacePath, schemas, sessionId, emitter, workrailSessionId) {
836
+ return {
837
+ name: 'Grep',
838
+ description: 'Search file contents using ripgrep (rg). Fast regex search with optional context lines, file-type filtering, and case-insensitive mode. ' +
839
+ 'output_mode: "files_with_matches" (default) returns only file paths; "content" returns matching lines; "count" returns match counts per file. ' +
840
+ 'node_modules and .git are always excluded.',
841
+ inputSchema: schemas['GrepParams'],
842
+ label: 'Grep',
843
+ execute: async (_toolCallId, params) => {
844
+ const pattern = params.pattern;
845
+ const searchPath = params.path ?? workspacePath;
846
+ const outputMode = params.output_mode ?? 'files_with_matches';
847
+ const headLimit = params.head_limit ?? 250;
848
+ if (sessionId)
849
+ emitter?.emit({ kind: 'tool_called', sessionId, toolName: 'Grep', summary: pattern.slice(0, 80), ...withWorkrailSession(workrailSessionId) });
850
+ const args = [
851
+ '--hidden',
852
+ '--glob', '!node_modules',
853
+ '--glob', '!.git',
854
+ '--max-columns', '500',
855
+ ];
856
+ if (params['-i'])
857
+ args.push('-i');
858
+ if (params.glob) {
859
+ args.push('--glob', params.glob);
860
+ }
861
+ if (params.type) {
862
+ args.push('--type', params.type);
863
+ }
864
+ switch (outputMode) {
865
+ case 'files_with_matches':
866
+ args.push('--files-with-matches');
867
+ break;
868
+ case 'count':
869
+ args.push('--count');
870
+ break;
871
+ case 'content':
872
+ args.push('--vimgrep');
873
+ if (params.context != null) {
874
+ args.push('-C', String(params.context));
875
+ }
876
+ break;
877
+ }
878
+ args.push('--', pattern, searchPath);
879
+ let stdout;
880
+ try {
881
+ const result = await execFileAsync('rg', args, { cwd: workspacePath, maxBuffer: 10 * 1024 * 1024 });
882
+ stdout = result.stdout;
883
+ }
884
+ catch (err) {
885
+ const nodeErr = err;
886
+ if (nodeErr.code === 'ENOENT') {
887
+ throw new Error('ripgrep (rg) is not installed. Install it with: brew install ripgrep (macOS) or apt install ripgrep (Ubuntu/Debian).');
888
+ }
889
+ if (typeof nodeErr.code === 'number' && nodeErr.code === 1) {
890
+ return {
891
+ content: [{ type: 'text', text: '(no matches)' }],
892
+ details: { pattern, searchPath, outputMode },
893
+ };
894
+ }
895
+ throw new Error(`rg failed: ${nodeErr.message ?? String(err)}`);
896
+ }
897
+ const lines = stdout.split('\n').filter(l => l.length > 0);
898
+ const truncated = lines.length > headLimit;
899
+ let result = lines.slice(0, headLimit).join('\n');
900
+ if (truncated) {
901
+ result += `\n[Results truncated at ${headLimit} lines. Use a more specific pattern or increase head_limit.]`;
902
+ }
903
+ return {
904
+ content: [{ type: 'text', text: result || '(no matches)' }],
905
+ details: { pattern, searchPath, outputMode, lineCount: lines.length, truncated },
906
+ };
907
+ },
908
+ };
909
+ }
910
+ function makeEditTool(workspacePath, readFileState, schemas, sessionId, emitter, workrailSessionId) {
911
+ return {
912
+ name: 'Edit',
913
+ description: 'Perform an exact string replacement in a file. ' +
914
+ 'The file must have been read in this session via the Read tool. ' +
915
+ 'By default, old_string must appear exactly once; use replace_all=true to replace all occurrences. ' +
916
+ 'Do NOT include line-number prefixes (e.g. "1\\t") from Read output in old_string or new_string.',
917
+ inputSchema: schemas['EditParams'],
918
+ label: 'Edit',
919
+ execute: async (_toolCallId, params) => {
920
+ const rawFilePath = params.file_path;
921
+ const absoluteFilePath = path.isAbsolute(rawFilePath)
922
+ ? rawFilePath
923
+ : path.join(workspacePath, rawFilePath);
924
+ if (!absoluteFilePath.startsWith(workspacePath)) {
925
+ throw new Error(`Edit target is outside the workspace: ${rawFilePath}`);
926
+ }
927
+ const filePath = absoluteFilePath;
928
+ const oldString = params.old_string;
929
+ const newString = params.new_string;
930
+ const replaceAll = params.replace_all ?? false;
675
931
  if (sessionId)
676
- emitter?.emit({ kind: 'tool_called', sessionId, toolName: 'Write', summary: String(params.filePath).slice(0, 80), ...withWorkrailSession(workrailSessionId) });
677
- await fs.mkdir(path.dirname(params.filePath), { recursive: true });
678
- await fs.writeFile(params.filePath, params.content, 'utf8');
932
+ emitter?.emit({ kind: 'tool_called', sessionId, toolName: 'Edit', summary: filePath.slice(0, 80), ...withWorkrailSession(workrailSessionId) });
933
+ if (oldString === newString) {
934
+ throw new Error('old_string and new_string are identical. No edit needed.');
935
+ }
936
+ const state = readFileState.get(filePath);
937
+ if (!state) {
938
+ throw new Error(`File has not been read in this session. Call Read first before editing: ${filePath}`);
939
+ }
940
+ let stat;
941
+ try {
942
+ stat = await fs.stat(filePath);
943
+ }
944
+ catch {
945
+ throw new Error(`File not found: ${filePath}. It may have been deleted after it was read.`);
946
+ }
947
+ if (stat.mtimeMs !== state.timestamp) {
948
+ throw new Error(`File has been modified since it was read. Re-read before editing: ${filePath}`);
949
+ }
950
+ const currentContent = await fs.readFile(filePath, 'utf8');
951
+ const actualString = findActualString(currentContent, oldString);
952
+ if (actualString === null) {
953
+ throw new Error(`String to replace not found in file. Make sure old_string exactly matches the file content ` +
954
+ `(do not include line-number prefixes from Read output): ${filePath}`);
955
+ }
956
+ const occurrences = currentContent.split(actualString).length - 1;
957
+ if (!replaceAll && occurrences > 1) {
958
+ throw new Error(`old_string appears ${occurrences} times in the file. ` +
959
+ `Provide a more specific string that matches exactly once, or set replace_all=true to replace all occurrences.`);
960
+ }
961
+ const updatedContent = replaceAll
962
+ ? currentContent.split(actualString).join(newString)
963
+ : currentContent.replace(actualString, newString);
964
+ await fs.writeFile(filePath, updatedContent, 'utf8');
965
+ const newStat = await fs.stat(filePath);
966
+ readFileState.set(filePath, { content: updatedContent, timestamp: newStat.mtimeMs, isPartialView: false });
679
967
  return {
680
- content: [{ type: 'text', text: `Written ${params.content.length} bytes to ${params.filePath}` }],
681
- details: { filePath: params.filePath, length: params.content.length },
968
+ content: [{ type: 'text', text: `The file ${filePath} has been updated successfully.` }],
969
+ details: { filePath, occurrencesReplaced: occurrences },
682
970
  };
683
971
  },
684
972
  };
@@ -691,7 +979,8 @@ function makeSpawnAgentTool(sessionId, ctx, apiKey, thisWorkrailSessionId, curre
691
979
  'Use this when a step requires delegating a well-defined sub-task to a separate workflow. ' +
692
980
  'IMPORTANT: The parent session\'s time limit (maxSessionMinutes) keeps ticking while the child runs. ' +
693
981
  'Configure the parent with enough time to cover both its own work and the child\'s work. ' +
694
- 'Returns: { childSessionId, outcome: "success"|"error"|"timeout", notes: string }. ' +
982
+ 'Returns: { childSessionId, outcome: "success"|"error"|"timeout", notes: string, artifacts?: readonly unknown[] }. ' +
983
+ 'On success, artifacts contains the child session\'s final step artifacts if any were produced. ' +
695
984
  'Check outcome before using notes -- on error/timeout, notes contains the error message.',
696
985
  inputSchema: schemas['SpawnAgentParams'],
697
986
  label: 'Spawn Agent',
@@ -755,6 +1044,7 @@ function makeSpawnAgentTool(sessionId, ctx, apiKey, thisWorkrailSessionId, curre
755
1044
  childSessionId,
756
1045
  outcome: 'success',
757
1046
  notes: childResult.lastStepNotes ?? '(no notes from child session)',
1047
+ ...(childResult.lastStepArtifacts !== undefined ? { artifacts: childResult.lastStepArtifacts } : {}),
758
1048
  };
759
1049
  }
760
1050
  else if (childResult._tag === 'error') {
@@ -873,6 +1163,74 @@ function makeReportIssueTool(sessionId, emitter, workrailSessionId, issuesDirOve
873
1163
  },
874
1164
  };
875
1165
  }
1166
+ exports.DAEMON_SIGNALS_DIR = path.join(os.homedir(), '.workrail', 'signals');
1167
+ async function appendSignalAsync(signalsDir, sessionId, record) {
1168
+ await fs.mkdir(signalsDir, { recursive: true });
1169
+ const filePath = path.join(signalsDir, `${sessionId}.jsonl`);
1170
+ const line = JSON.stringify({ ...record, ts: Date.now() }) + '\n';
1171
+ await fs.appendFile(filePath, line, 'utf8');
1172
+ }
1173
+ function makeSignalCoordinatorTool(sessionId, emitter, workrailSessionId, signalsDirOverride) {
1174
+ const signalsDir = signalsDirOverride ?? exports.DAEMON_SIGNALS_DIR;
1175
+ return {
1176
+ name: 'signal_coordinator',
1177
+ description: 'Emit a structured mid-session signal to the coordinator WITHOUT advancing the workflow step. ' +
1178
+ 'Use this to surface progress updates, intermediate findings, data requests, ' +
1179
+ 'approval requests, or blocking conditions while the session continues. ' +
1180
+ 'Always returns immediately -- fire-and-observe, never blocks. ' +
1181
+ 'Signal kinds: "progress" (heartbeat, no data needed), "finding" (intermediate result), ' +
1182
+ '"data_needed" (request external data), "approval_needed" (request coordinator approval), ' +
1183
+ '"blocked" (cannot continue without coordinator intervention).',
1184
+ inputSchema: {
1185
+ type: 'object',
1186
+ properties: {
1187
+ signalKind: {
1188
+ type: 'string',
1189
+ enum: ['progress', 'finding', 'data_needed', 'approval_needed', 'blocked'],
1190
+ description: 'The kind of signal to emit.',
1191
+ },
1192
+ payload: {
1193
+ type: 'object',
1194
+ additionalProperties: true,
1195
+ description: 'Structured data accompanying the signal. Pass {} for progress signals.',
1196
+ },
1197
+ },
1198
+ required: ['signalKind', 'payload'],
1199
+ additionalProperties: false,
1200
+ },
1201
+ label: 'signal_coordinator',
1202
+ execute: async (_toolCallId, params) => {
1203
+ const signalId = 'sig_' + (0, node_crypto_1.randomUUID)().replace(/-/g, '').slice(0, 8);
1204
+ const signalKind = String(params.signalKind ?? 'progress');
1205
+ const payload = (typeof params.payload === 'object' && params.payload !== null && !Array.isArray(params.payload))
1206
+ ? params.payload
1207
+ : {};
1208
+ console.log(`[WorkflowRunner] Tool: signal_coordinator sessionId=${sessionId} signalKind=${signalKind} signalId=${signalId}`);
1209
+ const record = {
1210
+ signalId,
1211
+ sessionId,
1212
+ ...(workrailSessionId != null ? { workrailSessionId } : {}),
1213
+ signalKind,
1214
+ payload,
1215
+ };
1216
+ void appendSignalAsync(signalsDir, sessionId, record).catch(() => {
1217
+ });
1218
+ emitter?.emit({
1219
+ kind: 'signal_emitted',
1220
+ sessionId,
1221
+ signalKind,
1222
+ signalId,
1223
+ payload,
1224
+ ...(workrailSessionId != null ? { workrailSessionId } : {}),
1225
+ });
1226
+ const result = { status: 'recorded', signalId };
1227
+ return {
1228
+ content: [{ type: 'text', text: JSON.stringify(result) }],
1229
+ details: result,
1230
+ };
1231
+ },
1232
+ };
1233
+ }
876
1234
  const BASE_SYSTEM_PROMPT = `\
877
1235
  You are WorkRail Auto, an autonomous agent that executes workflows step by step. You are running unattended -- there is no user watching. Your entire job is to faithfully complete the current workflow.
878
1236
 
@@ -900,6 +1258,7 @@ Good pattern: "Question: Should I check the middleware? Answer: The workflow ste
900
1258
  - \`Write\`: Write files.
901
1259
  - \`report_issue\`: Record a structured issue, error, or unexpected behavior. Call this AND complete_step (unless fatal). Does not stop the session -- it creates a record for the auto-fix coordinator.
902
1260
  - \`spawn_agent\`: Delegate a sub-task to a child WorkRail session. BLOCKS until the child completes. Returns \`{ childSessionId, outcome: "success"|"error"|"timeout", notes: string }\`. Always check \`outcome\` before using \`notes\`. IMPORTANT: your session's time limit (maxSessionMinutes) keeps running while the child executes -- ensure your parent session has enough time for both your work AND the child's work. Maximum spawn depth is 3 by default (configurable). Use only when a step explicitly asks for delegation or when a clearly separable sub-task would benefit from its own WorkRail audit trail.
1261
+ - \`signal_coordinator\`: Emit a structured mid-session signal to the coordinator WITHOUT advancing the workflow step. Use when the step asks you to surface a finding, request data, request approval, or report a blocking condition. Always returns immediately -- fire-and-observe. Signal kinds: "progress", "finding", "data_needed", "approval_needed", "blocked".
903
1262
 
904
1263
  ## Execution contract
905
1264
  1. Read the step carefully. Do ALL the work the step asks for.
@@ -964,7 +1323,7 @@ function buildUserMessage(text) {
964
1323
  timestamp: Date.now(),
965
1324
  };
966
1325
  }
967
- async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter) {
1326
+ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerRegistry) {
968
1327
  const sessionId = (0, node_crypto_1.randomUUID)();
969
1328
  console.log(`[WorkflowRunner] Session started: sessionId=${sessionId} workflowId=${trigger.workflowId}`);
970
1329
  emitter?.emit({
@@ -1002,24 +1361,26 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter) {
1002
1361
  }
1003
1362
  }
1004
1363
  let isComplete = false;
1005
- let pendingSteerText = null;
1364
+ const pendingSteerParts = [];
1006
1365
  let lastStepNotes;
1366
+ let lastStepArtifacts;
1007
1367
  let stepAdvanceCount = 0;
1008
1368
  const lastNToolCalls = [];
1009
1369
  const STUCK_REPEAT_THRESHOLD = 3;
1010
1370
  const issueSummaries = [];
1011
1371
  const MAX_ISSUE_SUMMARIES = 10;
1012
1372
  const onAdvance = (stepText, continueToken) => {
1013
- pendingSteerText = stepText;
1373
+ pendingSteerParts.push(stepText);
1014
1374
  stepAdvanceCount++;
1015
1375
  currentContinueToken = continueToken;
1016
1376
  if (workrailSessionId !== null)
1017
1377
  daemonRegistry?.heartbeat(workrailSessionId);
1018
1378
  emitter?.emit({ kind: 'step_advanced', sessionId, ...withWorkrailSession(workrailSessionId) });
1019
1379
  };
1020
- const onComplete = (notes) => {
1380
+ const onComplete = (notes, artifacts) => {
1021
1381
  isComplete = true;
1022
1382
  lastStepNotes = notes;
1383
+ lastStepArtifacts = artifacts;
1023
1384
  };
1024
1385
  let firstStep;
1025
1386
  if (trigger._preAllocatedStartResponse !== undefined) {
@@ -1052,6 +1413,9 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter) {
1052
1413
  if (workrailSessionId !== null) {
1053
1414
  daemonRegistry?.register(workrailSessionId, trigger.workflowId);
1054
1415
  }
1416
+ if (workrailSessionId !== null) {
1417
+ steerRegistry?.set(workrailSessionId, (text) => { pendingSteerParts.push(text); });
1418
+ }
1055
1419
  if (startContinueToken) {
1056
1420
  await persistTokens(sessionId, startContinueToken, startCheckpointToken);
1057
1421
  }
@@ -1065,18 +1429,23 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter) {
1065
1429
  const schemas = getSchemas();
1066
1430
  const spawnCurrentDepth = trigger.spawnDepth ?? 0;
1067
1431
  const spawnMaxDepth = trigger.agentConfig?.maxSubagentDepth ?? 3;
1432
+ const readFileState = new Map();
1068
1433
  const tools = [
1069
1434
  makeCompleteStepTool(sessionId, ctx, () => currentContinueToken, onAdvance, onComplete, (t) => { currentContinueToken = t; }, schemas, index_js_1.executeContinueWorkflow, emitter, workrailSessionId),
1070
1435
  makeContinueWorkflowTool(sessionId, ctx, onAdvance, onComplete, schemas, index_js_1.executeContinueWorkflow, emitter, workrailSessionId),
1071
1436
  makeBashTool(trigger.workspacePath, schemas, sessionId, emitter, workrailSessionId),
1072
- makeReadTool(schemas, sessionId, emitter, workrailSessionId),
1073
- makeWriteTool(schemas, sessionId, emitter, workrailSessionId),
1437
+ makeReadTool(readFileState, schemas, sessionId, emitter, workrailSessionId),
1438
+ makeWriteTool(readFileState, schemas, sessionId, emitter, workrailSessionId),
1439
+ makeGlobTool(trigger.workspacePath, schemas, sessionId, emitter, workrailSessionId),
1440
+ makeGrepTool(trigger.workspacePath, schemas, sessionId, emitter, workrailSessionId),
1441
+ makeEditTool(trigger.workspacePath, readFileState, schemas, sessionId, emitter, workrailSessionId),
1074
1442
  makeReportIssueTool(sessionId, emitter, workrailSessionId, undefined, (summary) => {
1075
1443
  if (issueSummaries.length < MAX_ISSUE_SUMMARIES) {
1076
1444
  issueSummaries.push(summary);
1077
1445
  }
1078
1446
  }),
1079
1447
  makeSpawnAgentTool(sessionId, ctx, apiKey, workrailSessionId ?? '', spawnCurrentDepth, spawnMaxDepth, runWorkflow, schemas, emitter),
1448
+ makeSignalCoordinatorTool(sessionId, emitter, workrailSessionId),
1080
1449
  ];
1081
1450
  const [soulContent, workspaceContext, sessionNotes] = await Promise.all([
1082
1451
  loadDaemonSoul(trigger.soulFile),
@@ -1191,10 +1560,10 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter) {
1191
1560
  ...withWorkrailSession(workrailSessionId),
1192
1561
  });
1193
1562
  }
1194
- if (pendingSteerText !== null && !isComplete) {
1195
- const text = pendingSteerText;
1196
- pendingSteerText = null;
1197
- agent.steer(buildUserMessage(text));
1563
+ if (pendingSteerParts.length > 0 && !isComplete) {
1564
+ const joined = pendingSteerParts.join('\n\n');
1565
+ pendingSteerParts.length = 0;
1566
+ agent.steer(buildUserMessage(joined));
1198
1567
  }
1199
1568
  });
1200
1569
  let stopReason = 'stop';
@@ -1235,6 +1604,9 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter) {
1235
1604
  unsubscribe();
1236
1605
  if (timeoutHandle !== undefined)
1237
1606
  clearTimeout(timeoutHandle);
1607
+ if (workrailSessionId !== null) {
1608
+ steerRegistry?.delete(workrailSessionId);
1609
+ }
1238
1610
  console.log(`[WorkflowRunner] Agent loop ended: sessionId=${sessionId} stopReason=${stopReason}${errorMessage ? ` error=${errorMessage.slice(0, 120)}` : ''}`);
1239
1611
  }
1240
1612
  if (timeoutReason !== null) {
@@ -1286,5 +1658,6 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter) {
1286
1658
  workflowId: trigger.workflowId,
1287
1659
  stopReason,
1288
1660
  ...(lastStepNotes !== undefined ? { lastStepNotes } : {}),
1661
+ ...(lastStepArtifacts !== undefined ? { lastStepArtifacts } : {}),
1289
1662
  };
1290
1663
  }
@@ -81,8 +81,6 @@ async function registerConfig(env) {
81
81
  tsyringe_1.container.register(tokens_js_1.DI.Config.CacheTTL, { useValue: config.cache.ttlMs });
82
82
  tsyringe_1.container.register(tokens_js_1.DI.Config.WorkflowDir, { useValue: config.paths.workflowDir });
83
83
  tsyringe_1.container.register(tokens_js_1.DI.Config.ProjectPath, { useValue: config.paths.projectPath });
84
- tsyringe_1.container.register(tokens_js_1.DI.Config.DashboardMode, { useValue: config.dashboard.mode });
85
- tsyringe_1.container.register(tokens_js_1.DI.Config.BrowserBehavior, { useValue: config.dashboard.browserBehavior });
86
84
  }
87
85
  if (!tsyringe_1.container.isRegistered(tokens_js_1.DI.Infra.FeatureFlags)) {
88
86
  const { CustomEnvFeatureFlagProvider } = await Promise.resolve().then(() => __importStar(require('../config/feature-flags.js')));
@@ -156,7 +154,6 @@ async function registerServices() {
156
154
  const { ToolDescriptionProvider } = await Promise.resolve().then(() => __importStar(require('../mcp/tool-description-provider.js')));
157
155
  const { DefaultWorkflowService } = await Promise.resolve().then(() => __importStar(require('../application/services/workflow-service.js')));
158
156
  const { SessionManager } = await Promise.resolve().then(() => __importStar(require('../infrastructure/session/SessionManager.js')));
159
- const { HttpServer } = await Promise.resolve().then(() => __importStar(require('../infrastructure/session/HttpServer.js')));
160
157
  tsyringe_1.container.register(tokens_js_1.DI.Infra.EnhancedLoopValidator, {
161
158
  useFactory: (0, tsyringe_1.instanceCachingFactory)((c) => c.resolve(EnhancedLoopValidator))
162
159
  });
@@ -181,9 +178,6 @@ async function registerServices() {
181
178
  tsyringe_1.container.register(tokens_js_1.DI.Infra.SessionManager, {
182
179
  useFactory: (0, tsyringe_1.instanceCachingFactory)((c) => c.resolve(SessionManager))
183
180
  });
184
- tsyringe_1.container.register(tokens_js_1.DI.Infra.HttpServer, {
185
- useFactory: (0, tsyringe_1.instanceCachingFactory)((c) => c.resolve(HttpServer))
186
- });
187
181
  if (!tsyringe_1.container.isRegistered(tokens_js_1.DI.Mcp.DescriptionProvider)) {
188
182
  tsyringe_1.container.registerSingleton(tokens_js_1.DI.Mcp.DescriptionProvider, ToolDescriptionProvider);
189
183
  }
@@ -358,25 +352,7 @@ async function startAsyncServices() {
358
352
  }
359
353
  if (asyncInitialized)
360
354
  return;
361
- try {
362
- const flags = tsyringe_1.container.resolve(tokens_js_1.DI.Infra.FeatureFlags);
363
- if (flags.isEnabled('sessionTools')) {
364
- const server = tsyringe_1.container.resolve(tokens_js_1.DI.Infra.HttpServer);
365
- try {
366
- await server.start();
367
- console.error('[DI] HTTP server started');
368
- }
369
- catch (httpError) {
370
- const message = httpError instanceof Error ? httpError.message : String(httpError);
371
- console.error(`[DI] Dashboard HTTP server unavailable: ${message}. MCP tools will still work.`);
372
- }
373
- }
374
- asyncInitialized = true;
375
- }
376
- catch (error) {
377
- const message = error instanceof Error ? error.message : String(error);
378
- throw new Error(`[DI] Async services initialization failed: ${message}`);
379
- }
355
+ asyncInitialized = true;
380
356
  }
381
357
  async function bootstrap(options = {}) {
382
358
  await initializeContainer(options);