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.
- package/README.md +16 -13
- package/assets/agents/ostacky.md +81 -43
- package/assets/commands/install-stack.md +1 -1
- package/assets/mcp/ostacky-controller/index.js +263 -80
- package/assets/mcp/ostacky-controller/package.json +1 -1
- package/assets/tests/agent-contract.test.js +227 -0
- package/assets/tests/controller.test.js +187 -0
- package/assets/tests/validate-config.sh +18 -0
- package/dist/cli.js +27 -28
- package/dist/mcp/ostacky-controller/index.js +219 -88
- package/manifest.json +23 -23
- package/package.json +1 -1
|
@@ -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 ? ` ${
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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.
|
|
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
|
-
|
|
429
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
457
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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
|
-
|
|
503
|
-
|
|
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
|
-
|
|
519
|
-
|
|
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
|
-
|
|
535
|
-
|
|
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
|
-
|
|
548
|
-
|
|
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
|
-
|
|
561
|
-
|
|
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
|
-
|
|
576
|
-
|
|
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
|
-
|
|
591
|
-
|
|
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
|
-
|
|
603
|
-
|
|
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
|
-
|
|
615
|
-
|
|
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
|
|
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', {
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
654
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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 };
|