ostacky 0.5.8 → 0.5.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.
@@ -3,15 +3,63 @@
3
3
  import { McpServer } from '@modelcontextprotocol/server';
4
4
  import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
5
5
  import * as z from 'zod/v4';
6
- import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
7
- import { dirname } from 'node:path';
6
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';
7
+ import { dirname, basename } from 'node:path';
8
+
9
+ const MAX_TASKS = 50;
10
+ const MAX_SNAPSHOT_JSON_LENGTH = 100 * 1024; // 100KB per snapshot serialized
11
+ const MAX_STATE_FILE_SIZE = 1024 * 1024; // 1MB hard cap for the entire state file
12
+
13
+ /**
14
+ * Safe JSON.stringify that won't throw on circular references.
15
+ */
16
+ function safeJsonStringify(obj, pretty = false) {
17
+ const seen = new WeakSet();
18
+ try {
19
+ return JSON.stringify(
20
+ obj,
21
+ (key, value) => {
22
+ if (typeof value === 'object' && value !== null) {
23
+ if (seen.has(value)) return '[Circular]';
24
+ seen.add(value);
25
+ }
26
+ return value;
27
+ },
28
+ pretty ? 2 : undefined
29
+ );
30
+ } catch (e) {
31
+ return `[Unstringifiable: ${e.message}]`;
32
+ }
33
+ }
8
34
 
9
35
  function log(event, data) {
10
36
  const ts = new Date().toISOString();
11
- const payload = data ? ` ${JSON.stringify(data)}` : '';
37
+ const payload = data ? ` ${safeJsonStringify(data)}` : '';
12
38
  console.error(`[${ts}] ${event}${payload}`);
13
39
  }
14
40
 
41
+ /**
42
+ * Cleans up stale .tmp.* files from a previous crash.
43
+ */
44
+ function cleanupTmpFiles(statePath) {
45
+ if (!statePath) return;
46
+ const dir = dirname(statePath);
47
+ const name = basename(statePath);
48
+ try {
49
+ for (const entry of readdirSync(dir)) {
50
+ if (entry.startsWith(name + '.tmp.')) {
51
+ try {
52
+ unlinkSync(dir + '/' + entry);
53
+ } catch {
54
+ /* best-effort */
55
+ }
56
+ }
57
+ }
58
+ } catch {
59
+ /* directory may not exist yet */
60
+ }
61
+ }
62
+
15
63
  const STATES = Object.freeze({
16
64
  INTERPRETATION_PENDING: 'INTERPRETATION_PENDING',
17
65
  CLARIFICATION_PENDING: 'CLARIFICATION_PENDING',
@@ -50,8 +98,13 @@ class OstackyController {
50
98
 
51
99
  constructor(opts = {}) {
52
100
  this.#statePath = opts.statePath;
53
- this.#state = opts.initialState ? { ...DEFAULT_STATE, ...opts.initialState } : null;
54
- this.#loaded = false;
101
+ if (opts.initialState) {
102
+ this.#state = { ...DEFAULT_STATE, ...opts.initialState };
103
+ this.#loaded = true;
104
+ } else {
105
+ this.#state = null;
106
+ this.#loaded = false;
107
+ }
55
108
  }
56
109
 
57
110
  #load() {
@@ -61,11 +114,32 @@ class OstackyController {
61
114
  this.#loaded = true;
62
115
  return;
63
116
  }
117
+ // Try primary state file
64
118
  try {
65
119
  const raw = readFileSync(this.#statePath, 'utf8');
120
+ if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`State file too large: ${raw.length} bytes`);
66
121
  this.#state = { ...DEFAULT_STATE, ...JSON.parse(raw) };
122
+ this.#loaded = true;
123
+ return;
124
+ } catch (err) {
125
+ log('warn:load_primary_failed', { error: err.message });
126
+ }
127
+ // Fallback: try .backup
128
+ const backupPath = this.#statePath + '.backup';
129
+ try {
130
+ const raw = readFileSync(backupPath, 'utf8');
131
+ if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`Backup too large: ${raw.length} bytes`);
132
+ this.#state = { ...DEFAULT_STATE, ...JSON.parse(raw), error: 'State restored from backup' };
133
+ log('warn:state_restored_from_backup');
134
+ this.#loaded = true;
135
+ return;
67
136
  } catch {
68
- this.#state = { ...DEFAULT_STATE };
137
+ // No backup either — set error state instead of silent reset
138
+ this.#state = {
139
+ ...DEFAULT_STATE,
140
+ error: `State file corrupt: ${err.message}. No backup available. State reset to default.`,
141
+ };
142
+ log('warn:state_reset', { error: err.message });
69
143
  }
70
144
  this.#loaded = true;
71
145
  }
@@ -74,15 +148,57 @@ class OstackyController {
74
148
  if (!this.#statePath) return;
75
149
  const dir = dirname(this.#statePath);
76
150
  mkdirSync(dir, { recursive: true });
151
+ const serialized = safeJsonStringify(this.#state, true);
152
+ // Hard cap — if state exceeds 1MB, trim snapshots and retry
153
+ if (serialized.length > MAX_STATE_FILE_SIZE) {
154
+ log('warn:state_oversized', { size: serialized.length });
155
+ this.#state.snapshots = { codegraph: null, execution: null };
156
+ const trimmed = safeJsonStringify(this.#state, true);
157
+ if (trimmed.length > MAX_STATE_FILE_SIZE) {
158
+ log('error:state_too_large_even_after_trim');
159
+ return; // Don't persist — better to keep old state than write garbage
160
+ }
161
+ const tmp = this.#statePath + '.tmp.' + process.pid;
162
+ writeFileSync(tmp, trimmed, 'utf8');
163
+ renameSync(tmp, this.#statePath);
164
+ return;
165
+ }
77
166
  const tmp = this.#statePath + '.tmp.' + process.pid;
78
- writeFileSync(tmp, JSON.stringify(this.#state, null, 2), 'utf8');
167
+ writeFileSync(tmp, serialized, 'utf8');
79
168
  renameSync(tmp, this.#statePath);
169
+ // Best-effort backup
170
+ try {
171
+ const backupPath = this.#statePath + '.backup';
172
+ writeFileSync(backupPath, serialized, 'utf8');
173
+ } catch {
174
+ /* backup is best-effort */
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Trims old completed tasks when we exceed MAX_TASKS.
180
+ * Keeps the most recent MAX_TASKS entries.
181
+ */
182
+ #trimTasks() {
183
+ if (!this.#state.tasks) return;
184
+ const entries = Object.entries(this.#state.tasks);
185
+ if (entries.length <= MAX_TASKS) return;
186
+ // Sort by completedAt (desc), keep newest MAX_TASKS
187
+ entries.sort((a, b) => {
188
+ const da = a[1].completedAt || '';
189
+ const db = b[1].completedAt || '';
190
+ return db.localeCompare(da);
191
+ });
192
+ const trimmed = Object.fromEntries(entries.slice(0, MAX_TASKS));
193
+ this.#state.tasks = trimmed;
194
+ log('warn:tasks_trimmed', { before: entries.length, after: MAX_TASKS });
80
195
  }
81
196
 
82
197
  #transition(to, changes = {}) {
83
198
  this.#state.revision++;
84
199
  this.#state.state = to;
85
200
  Object.assign(this.#state, changes);
201
+ this.#trimTasks();
86
202
  this.#persist();
87
203
  }
88
204
 
@@ -201,7 +317,7 @@ class OstackyController {
201
317
  const to = this.#isAllowedTransition(this.#state.state, 'record_discovery');
202
318
  if (!to) return { error: `Cannot record discovery from state ${this.#state.state}` };
203
319
  if (!['0', '0+1', '1+'].includes(level)) return { error: `Invalid level: ${level}` };
204
- this.#transition('ROUTE_DECISION_PENDING', {
320
+ this.#transition(to, {
205
321
  routeDecisionId: routeDecisionId || 'route-' + Date.now(),
206
322
  routeChoice: null,
207
323
  snapshots: { ...this.#state.snapshots, codegraph: snapshot || this.#state.snapshots.codegraph },
@@ -396,6 +512,7 @@ class OstackyController {
396
512
  if (!this.#state.fileFingerprints) this.#state.fileFingerprints = {};
397
513
  this.#state.fileFingerprints[filePath] = fileHash;
398
514
  }
515
+ this.#trimTasks();
399
516
  this.#persist();
400
517
  return {
401
518
  taskId,
@@ -404,14 +521,42 @@ class OstackyController {
404
521
  .length,
405
522
  };
406
523
  }
524
+
525
+ /**
526
+ * Public flush — force-persists current state to disk.
527
+ * Used by graceful shutdown (private fields not accessible from outside).
528
+ */
529
+ flush() {
530
+ this.#persist();
531
+ }
407
532
  }
408
533
 
409
534
  const statePath = process.env.OSTACKY_STATE_PATH || '.opencode/ostacky-state.json';
410
535
  const controller = new OstackyController({ statePath });
411
536
 
537
+ /**
538
+ * Wraps an async tool handler to ALWAYS return a response (even on error).
539
+ * Without this, an unhandled exception in any tool handler leaves the LLM
540
+ * waiting forever — the root cause of agent freezes.
541
+ */
542
+ function safeHandler(fn) {
543
+ return async (params) => {
544
+ try {
545
+ const result = await fn(params);
546
+ return { content: [{ type: 'text', text: safeJsonStringify(result) }] };
547
+ } catch (error) {
548
+ log('tool:error', { name: fn.name || 'anonymous', error: error.message });
549
+ return {
550
+ content: [{ type: 'text', text: safeJsonStringify({ error: error.message }) }],
551
+ isError: true,
552
+ };
553
+ }
554
+ };
555
+ }
556
+
412
557
  const server = new McpServer({
413
558
  name: 'ostacky-controller',
414
- version: '0.5.8',
559
+ version: '0.5.10',
415
560
  });
416
561
 
417
562
  server.registerTool(
@@ -423,11 +568,10 @@ server.registerTool(
423
568
  changeId: z.string().optional().describe('Optional change ID for OpenSpec tracking'),
424
569
  }),
425
570
  },
426
- async ({ requestId, changeId }) => {
571
+ safeHandler(async ({ requestId, changeId }) => {
427
572
  log('tool:start_request');
428
- const result = await controller.startRequest({ requestId, changeId });
429
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
430
- }
573
+ return await controller.startRequest({ requestId, changeId });
574
+ })
431
575
  );
432
576
 
433
577
  server.registerTool(
@@ -438,11 +582,10 @@ server.registerTool(
438
582
  question: z.string().optional().describe('The clarification question'),
439
583
  }),
440
584
  },
441
- async ({ question }) => {
585
+ safeHandler(async ({ question }) => {
442
586
  log('tool:request_clarification');
443
- const result = await controller.requestClarification({ question });
444
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
445
- }
587
+ return await controller.requestClarification({ question });
588
+ })
446
589
  );
447
590
 
448
591
  server.registerTool(
@@ -451,11 +594,10 @@ server.registerTool(
451
594
  description: 'Record that clarification was answered. Transitions to DISCOVERY.',
452
595
  inputSchema: z.object({}),
453
596
  },
454
- async () => {
597
+ safeHandler(async () => {
455
598
  log('tool:record_clarification');
456
- const result = await controller.recordClarification();
457
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
458
- }
599
+ return await controller.recordClarification();
600
+ })
459
601
  );
460
602
 
461
603
  server.registerTool(
@@ -468,11 +610,10 @@ server.registerTool(
468
610
  snapshot: z.any().optional().describe('Optional CodeGraph snapshot'),
469
611
  }),
470
612
  },
471
- async ({ level, routeDecisionId, snapshot }) => {
613
+ safeHandler(async ({ level, routeDecisionId, snapshot }) => {
472
614
  log('tool:record_discovery', { level });
473
- const result = await controller.recordDiscovery({ level, routeDecisionId, snapshot });
474
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
475
- }
615
+ return await controller.recordDiscovery({ level, routeDecisionId, snapshot });
616
+ })
476
617
  );
477
618
 
478
619
  server.registerTool(
@@ -484,11 +625,10 @@ server.registerTool(
484
625
  choice: z.enum(['SPEC', 'DIRECT']).describe('Route choice'),
485
626
  }),
486
627
  },
487
- async ({ decisionId, choice }) => {
628
+ safeHandler(async ({ decisionId, choice }) => {
488
629
  log('tool:consume_route_decision', { choice });
489
- const result = await controller.consumeRouteDecision({ decisionId, choice });
490
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
491
- }
630
+ return await controller.consumeRouteDecision({ decisionId, choice });
631
+ })
492
632
  );
493
633
 
494
634
  server.registerTool(
@@ -497,11 +637,10 @@ server.registerTool(
497
637
  description: 'Mark specification phase as complete. Transitions to EXECUTION_ANALYSIS.',
498
638
  inputSchema: z.object({}),
499
639
  },
500
- async () => {
640
+ safeHandler(async () => {
501
641
  log('tool:spec_complete');
502
- const result = await controller.specComplete();
503
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
504
- }
642
+ return await controller.specComplete();
643
+ })
505
644
  );
506
645
 
507
646
  server.registerTool(
@@ -513,11 +652,10 @@ server.registerTool(
513
652
  snapshot: z.any().optional().describe('Execution analysis snapshot'),
514
653
  }),
515
654
  },
516
- async ({ executionDecisionId, snapshot }) => {
655
+ safeHandler(async ({ executionDecisionId, snapshot }) => {
517
656
  log('tool:record_execution_analysis');
518
- const result = await controller.recordExecutionAnalysis({ executionDecisionId, snapshot });
519
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
520
- }
657
+ return await controller.recordExecutionAnalysis({ executionDecisionId, snapshot });
658
+ })
521
659
  );
522
660
 
523
661
  server.registerTool(
@@ -529,11 +667,10 @@ server.registerTool(
529
667
  mode: z.enum(['INLINE', 'SUBAGENT_DRIVEN']).describe('Execution mode'),
530
668
  }),
531
669
  },
532
- async ({ decisionId, mode }) => {
670
+ safeHandler(async ({ decisionId, mode }) => {
533
671
  log('tool:consume_execution_decision', { mode });
534
- const result = await controller.consumeExecutionDecision({ decisionId, mode });
535
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
536
- }
672
+ return await controller.consumeExecutionDecision({ decisionId, mode });
673
+ })
537
674
  );
538
675
 
539
676
  server.registerTool(
@@ -542,11 +679,10 @@ server.registerTool(
542
679
  description: 'Mark implementation as complete. Transitions to SYNC.',
543
680
  inputSchema: z.object({}),
544
681
  },
545
- async () => {
682
+ safeHandler(async () => {
546
683
  log('tool:implementation_complete');
547
- const result = await controller.implementationComplete();
548
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
549
- }
684
+ return await controller.implementationComplete();
685
+ })
550
686
  );
551
687
 
552
688
  server.registerTool(
@@ -555,11 +691,10 @@ server.registerTool(
555
691
  description: 'Mark sync as complete. Transitions to DONE.',
556
692
  inputSchema: z.object({}),
557
693
  },
558
- async () => {
694
+ safeHandler(async () => {
559
695
  log('tool:sync_complete');
560
- const result = await controller.syncComplete();
561
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
562
- }
696
+ return await controller.syncComplete();
697
+ })
563
698
  );
564
699
 
565
700
  server.registerTool(
@@ -570,11 +705,10 @@ server.registerTool(
570
705
  reason: z.string().optional().describe('Reason for blocking'),
571
706
  }),
572
707
  },
573
- async ({ reason }) => {
708
+ safeHandler(async ({ reason }) => {
574
709
  log('tool:block');
575
- const result = await controller.block({ reason });
576
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
577
- }
710
+ return await controller.block({ reason });
711
+ })
578
712
  );
579
713
 
580
714
  server.registerTool(
@@ -585,11 +719,10 @@ server.registerTool(
585
719
  reason: z.string().optional().describe('Reason for replanning'),
586
720
  }),
587
721
  },
588
- async ({ reason }) => {
722
+ safeHandler(async ({ reason }) => {
589
723
  log('tool:replan');
590
- const result = await controller.replan({ reason });
591
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
592
- }
724
+ return await controller.replan({ reason });
725
+ })
593
726
  );
594
727
 
595
728
  server.registerTool(
@@ -598,10 +731,9 @@ server.registerTool(
598
731
  description: 'Get the current controller state (reads persistent store).',
599
732
  inputSchema: z.object({}),
600
733
  },
601
- async () => {
602
- const result = await controller.getState();
603
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
604
- }
734
+ safeHandler(async () => {
735
+ return await controller.getState();
736
+ })
605
737
  );
606
738
 
607
739
  server.registerTool(
@@ -610,10 +742,9 @@ server.registerTool(
610
742
  description: 'Get current task states.',
611
743
  inputSchema: z.object({}),
612
744
  },
613
- async () => {
614
- const result = await controller.getTasks();
615
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
616
- }
745
+ safeHandler(async () => {
746
+ return await controller.getTasks();
747
+ })
617
748
  );
618
749
 
619
750
  server.registerTool(
@@ -621,19 +752,34 @@ server.registerTool(
621
752
  {
622
753
  description:
623
754
  'Validate an edit against current file content. Returns EDITABLE, ALREADY_APPLIED, or CONFLICT. ' +
624
- 'Call BEFORE executing an edit tool. Only valid in EXECUTING_INLINE or EXECUTING_SUBAGENTS states.',
755
+ 'Call BEFORE executing an edit tool. Only valid in EXECUTING_INLINE or EXECUTING_SUBAGENTS states. ' +
756
+ 'IMPORTANT: content parameter is REQUIRED. Read the file first, then pass the full content.',
625
757
  inputSchema: z.object({
626
758
  oldString: z.string().describe('The exact string to find in content (must be unique).'),
627
759
  newString: z.string().describe('The replacement string.'),
628
- content: z.string().describe('The current file content (read fresh with Read tool).'),
760
+ content: z
761
+ .string()
762
+ .describe(
763
+ 'REQUIRED — The current file content. Read the file first with Read tool, then pass the full content here.'
764
+ ),
629
765
  taskId: z.string().optional().describe('Optional task ID for tracking.'),
630
766
  }),
631
767
  },
632
- async ({ oldString, newString, content, taskId }) => {
633
- log('tool:validate_edit', { taskId, oldLen: oldString.length, newLen: newString.length });
634
- const result = await controller.validateEdit({ oldString, newString, content, taskId });
635
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
636
- }
768
+ safeHandler(async ({ oldString, newString, content, taskId }) => {
769
+ log('tool:validate_edit', {
770
+ taskId,
771
+ oldLen: oldString?.length,
772
+ newLen: newString?.length,
773
+ hasContent: !!content,
774
+ });
775
+ if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
776
+ return {
777
+ outcome: 'CONFLICT',
778
+ reason: 'Missing required fields: content, oldString, and newString are all required. Read the file first, then pass content to validate_edit.',
779
+ };
780
+ }
781
+ return await controller.validateEdit({ oldString, newString, content, taskId });
782
+ })
637
783
  );
638
784
 
639
785
  server.registerTool(
@@ -648,22 +794,59 @@ server.registerTool(
648
794
  fileHash: z.string().optional().describe('Optional SHA-256 hash of the file after modification.'),
649
795
  }),
650
796
  },
651
- async ({ taskId, filePath, fileHash }) => {
797
+ safeHandler(async ({ taskId, filePath, fileHash }) => {
652
798
  log('tool:complete_task', { taskId, filePath });
653
- const result = await controller.completeTask({ taskId, filePath, fileHash });
654
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
655
- }
799
+ return await controller.completeTask({ taskId, filePath, fileHash });
800
+ })
656
801
  );
657
802
 
803
+ /**
804
+ * Graceful shutdown: clean up tmp files and flush state.
805
+ */
806
+ function setupGracefulShutdown(ctrl) {
807
+ const shutdown = (signal) => {
808
+ log('shutdown', { signal });
809
+ // Final persist attempt (flush via public method, sync inside)
810
+ try {
811
+ if (ctrl) ctrl.flush();
812
+ } catch {
813
+ /* best-effort */
814
+ }
815
+ // Clean up own tmp files
816
+ try {
817
+ cleanupTmpFiles(statePath);
818
+ } catch {
819
+ /* best-effort */
820
+ }
821
+ process.exit(signal === 'SIGINT' ? 130 : 0);
822
+ };
823
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
824
+ process.on('SIGINT', () => shutdown('SIGINT'));
825
+ // Prevent unhandled rejections from silently killing the server
826
+ process.on('unhandledRejection', (reason) => {
827
+ log('unhandled_rejection', { reason: String(reason) });
828
+ });
829
+ }
830
+
658
831
  async function main() {
659
832
  log('Starting ostacky-controller MCP...');
660
833
  log('State path:', { path: statePath });
834
+ // Clean up stale tmp files from previous runs
835
+ cleanupTmpFiles(statePath);
836
+ setupGracefulShutdown(controller);
661
837
  const transport = new StdioServerTransport();
662
838
  await server.connect(transport);
663
839
  log('ostacky-controller connected and ready');
664
840
  }
665
841
 
666
- main().catch((error) => {
667
- console.error('Fatal error:', error);
668
- process.exit(1);
669
- });
842
+ const isDirectRun =
843
+ process.argv[1] && (process.argv[1].endsWith('/index.js') || process.argv[1].endsWith('\\index.js'));
844
+
845
+ if (isDirectRun) {
846
+ main().catch((error) => {
847
+ console.error('Fatal error:', error);
848
+ process.exit(1);
849
+ });
850
+ }
851
+
852
+ export { OstackyController };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ostacky-controller",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "dependencies": {