@goodandready/dsh-goal 0.1.4 → 0.1.6

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/lib/index.js CHANGED
@@ -41,7 +41,7 @@ function sessionIdOf(invocationOrReq, fallback = 'default') {
41
41
  export const Config = z.object({
42
42
  maxIterations: z.number().default(25).description('Safety limit: max autonomous iterations per goal'),
43
43
  autoDrive: z.boolean().default(true).description('Automatically continue the loop after each turn'),
44
- enableSound: z.boolean().default(true).description('Play a sound when a goal completes'),
44
+ enableSound: z.boolean().default(true).description('Play synthesized audio chime on goal completion or failure'),
45
45
  storagePath: z.string().default('').description('Custom filesystem path for persistent state storage'),
46
46
  });
47
47
 
@@ -52,6 +52,9 @@ export function apply(ctx, config = {}) {
52
52
  const sessionAgents = new Map(); // sessionId -> agent
53
53
  let lastActiveAgent = null;
54
54
 
55
+ // SSE clients: sid -> Set of res objects
56
+ const sseClients = new Map();
57
+
55
58
  let currentSettings = {
56
59
  maxIterations: config?.maxIterations ?? 25,
57
60
  autoDrive: config?.autoDrive ?? true,
@@ -71,6 +74,30 @@ export function apply(ctx, config = {}) {
71
74
  storagePath,
72
75
  });
73
76
 
77
+ // Subscribe to engine changes for realtime Server-Sent Events broadcasting
78
+ engine.subscribe((snapshot, sid) => {
79
+ const clients = sseClients.get(sid);
80
+ if (clients && clients.size > 0) {
81
+ const payload = `data: ${JSON.stringify(snapshot)}\n\n`;
82
+ for (const clientRes of clients) {
83
+ try {
84
+ clientRes.write(payload);
85
+ } catch (_) {}
86
+ }
87
+ }
88
+ if (sid !== 'default' && sseClients.has('default')) {
89
+ const defClients = sseClients.get('default');
90
+ if (defClients && defClients.size > 0) {
91
+ const payload = `data: ${JSON.stringify(snapshot)}\n\n`;
92
+ for (const clientRes of defClients) {
93
+ try {
94
+ clientRes.write(payload);
95
+ } catch (_) {}
96
+ }
97
+ }
98
+ }
99
+ });
100
+
74
101
  // Динамическое внедрение сервиса agents для управления жизненным циклом
75
102
  ctx.inject(['agents'], (actx) => {
76
103
  agentsService = actx.agents;
@@ -87,6 +114,13 @@ export function apply(ctx, config = {}) {
87
114
  }
88
115
  } else {
89
116
  runningAgents.delete(agent);
117
+ if (lastActiveAgent === agent && (status === 'stopped' || status === 'completed' || status === 'error')) {
118
+ lastActiveAgent = null;
119
+ }
120
+ const sid = sessionIdOf(agent, null);
121
+ if (sid && sessionAgents.get(sid) === agent && (status === 'stopped' || status === 'completed' || status === 'error')) {
122
+ sessionAgents.delete(sid);
123
+ }
90
124
  }
91
125
  });
92
126
 
@@ -383,10 +417,22 @@ export function apply(ctx, config = {}) {
383
417
  });
384
418
  });
385
419
 
386
- // 5. Регистрация HTTP REST API маршрутов
420
+ // 5. Регистрация HTTP REST API маршрутов и SSE событий
387
421
  ctx.effect(() => {
388
422
  if (!ctx.webServer?.register) return () => {};
389
423
 
424
+ // Keepalive ping timer for SSE connections (every 20s)
425
+ const keepaliveTimer = setInterval(() => {
426
+ for (const clients of sseClients.values()) {
427
+ for (const res of clients) {
428
+ try {
429
+ res.write(': keepalive\n\n');
430
+ } catch (_) {}
431
+ }
432
+ }
433
+ }, 20000);
434
+ if (typeof keepaliveTimer.unref === 'function') keepaliveTimer.unref();
435
+
390
436
  const unreg = ctx.webServer.register({
391
437
  kind: 'prefix',
392
438
  path: '/dsh-goal',
@@ -394,6 +440,34 @@ export function apply(ctx, config = {}) {
394
440
  const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
395
441
  const pathname = url.pathname;
396
442
 
443
+ // GET /dsh-goal/events — Server-Sent Events realtime snapshot stream
444
+ if (req.method === 'GET' && (pathname === '/dsh-goal/events' || pathname === '/dsh-goal/events/')) {
445
+ const sid = sessionIdOf(req, 'default');
446
+ res.writeHead(200, {
447
+ 'Content-Type': 'text/event-stream',
448
+ 'Cache-Control': 'no-cache, no-transform',
449
+ 'Connection': 'keep-alive',
450
+ 'X-Accel-Buffering': 'no',
451
+ });
452
+
453
+ if (!sseClients.has(sid)) {
454
+ sseClients.set(sid, new Set());
455
+ }
456
+ sseClients.get(sid).add(res);
457
+
458
+ const initialSnap = engine.getSnapshot(sid);
459
+ res.write(`data: ${JSON.stringify(initialSnap)}\n\n`);
460
+
461
+ req.on('close', () => {
462
+ const set = sseClients.get(sid);
463
+ if (set) {
464
+ set.delete(res);
465
+ if (set.size === 0) sseClients.delete(sid);
466
+ }
467
+ });
468
+ return;
469
+ }
470
+
397
471
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
398
472
 
399
473
  // GET /dsh-goal/state
@@ -428,8 +502,23 @@ export function apply(ctx, config = {}) {
428
502
  }
429
503
 
430
504
  let body = '';
431
- req.on('data', (chunk) => { body += chunk; });
505
+ let bodySize = 0;
506
+ const MAX_PAYLOAD_BYTES = 256 * 1024;
507
+ let limitExceeded = false;
508
+
509
+ req.on('data', (chunk) => {
510
+ bodySize += chunk.length;
511
+ if (bodySize > MAX_PAYLOAD_BYTES) {
512
+ limitExceeded = true;
513
+ req.pause();
514
+ res.statusCode = 413;
515
+ return res.end(JSON.stringify({ error: 'Payload too large: max 256 KB allowed' }));
516
+ }
517
+ body += chunk;
518
+ });
519
+
432
520
  req.on('end', () => {
521
+ if (limitExceeded) return;
433
522
  try {
434
523
  const data = JSON.parse(body || '{}');
435
524
  const sid = data.sessionId || sessionIdOf(req, 'default');
@@ -461,6 +550,10 @@ export function apply(ctx, config = {}) {
461
550
  sessionAgents.delete(sid);
462
551
  break;
463
552
  case 'update_milestone':
553
+ if (!milestoneId || !status) {
554
+ res.statusCode = 400;
555
+ return res.end(JSON.stringify({ error: 'milestoneId and status are required' }));
556
+ }
464
557
  engine.updateMilestone(milestoneId, status, notes, sid);
465
558
  result = engine.getSnapshot(sid);
466
559
  break;
@@ -485,9 +578,18 @@ export function apply(ctx, config = {}) {
485
578
  });
486
579
 
487
580
  return () => {
581
+ clearInterval(keepaliveTimer);
488
582
  if (typeof unreg === 'function') unreg();
583
+ for (const clients of sseClients.values()) {
584
+ for (const res of clients) {
585
+ try {
586
+ res.end();
587
+ } catch (_) {}
588
+ }
589
+ }
590
+ sseClients.clear();
489
591
  };
490
- }, 'dsh-goal: HTTP WebServer Routes');
592
+ }, 'dsh-goal: HTTP WebServer Routes & SSE');
491
593
 
492
594
  // 6. Подписка на события сессии (автономный цикл)
493
595
  ctx.effect(() => {
@@ -504,10 +606,18 @@ export function apply(ctx, config = {}) {
504
606
  return;
505
607
  }
506
608
 
609
+ // Item 3: Smart Progress Guard — detect idle loops without progress
610
+ const stallCount = engine.incrementStallCount(sid);
611
+ if (stallCount >= 2) {
612
+ engine.pause('Агент не продвинулся по плану за последние 2 итерации — требуется внимание оператора', sid);
613
+ return;
614
+ }
615
+
507
616
  const canContinue = engine.incrementIteration(sid);
508
617
  if (!canContinue) return;
509
618
 
510
- setTimeout(() => {
619
+ // Item 2: Low-Latency AutoDrive — setImmediate instead of static setTimeout
620
+ const triggerNextTurn = () => {
511
621
  const currentSnap = engine.getSnapshot(sid);
512
622
  if (currentSnap.hasActiveGoal && currentSnap.state === GoalState.RUNNING) {
513
623
  const promptMsg = createGoalUserMessage(
@@ -543,7 +653,13 @@ export function apply(ctx, config = {}) {
543
653
  }
544
654
  }
545
655
  }
546
- }, 300);
656
+ };
657
+
658
+ if (typeof setImmediate === 'function') {
659
+ setImmediate(triggerNextTurn);
660
+ } else {
661
+ setTimeout(triggerNextTurn, 0);
662
+ }
547
663
  }
548
664
  };
549
665
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-goal",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Goal mode & autonomous execution plugin for DeepSeek Harness with sticky top banner",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",