granclaw 0.0.1-beta.87 → 0.0.1-beta.89

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.
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WS_OPEN = void 0;
4
+ exports.broadcastToClients = broadcastToClients;
5
+ exports.getOrCreateChannelSet = getOrCreateChannelSet;
6
+ exports.WS_OPEN = 1;
7
+ function broadcastToClients(clients, data) {
8
+ const json = JSON.stringify(data);
9
+ let sent = 0;
10
+ for (const ws of clients) {
11
+ if (ws.readyState === exports.WS_OPEN) {
12
+ ws.send(json);
13
+ sent++;
14
+ }
15
+ }
16
+ return sent;
17
+ }
18
+ function getOrCreateChannelSet(map, channelId) {
19
+ let set = map.get(channelId);
20
+ if (!set) {
21
+ set = new Set();
22
+ map.set(channelId, set);
23
+ }
24
+ return set;
25
+ }
@@ -10,6 +10,7 @@ const path_1 = __importDefault(require("path"));
10
10
  const config_js_1 = require("../config.js");
11
11
  const agent_db_js_1 = require("../agent-db.js");
12
12
  const runner_pi_js_1 = require("./runner-pi.js");
13
+ const channel_broadcast_js_1 = require("./channel-broadcast.js");
13
14
  const messages_db_js_1 = require("../messages-db.js");
14
15
  const telegram_adapter_js_1 = require("./telegram-adapter.js");
15
16
  const browser_sessions_js_1 = require("../browser-sessions.js");
@@ -36,9 +37,7 @@ function main() {
36
37
  const wss = new ws_1.WebSocketServer({ port });
37
38
  const channelClients = new Map();
38
39
  function getChannelClients(channelId) {
39
- if (!channelClients.has(channelId))
40
- channelClients.set(channelId, new Set());
41
- return channelClients.get(channelId);
40
+ return (0, channel_broadcast_js_1.getOrCreateChannelSet)(channelClients, channelId);
42
41
  }
43
42
  wss.on('connection', (ws) => {
44
43
  let clientChannelId = 'ui';
@@ -84,14 +83,10 @@ function main() {
84
83
  telegramAdapter = new telegram_adapter_js_1.TelegramAdapter(agentId, telegramBotToken, workspaceDir);
85
84
  }
86
85
  function broadcastToChannel(channelId, data) {
87
- const json = JSON.stringify(data);
88
86
  const targets = channelClients.get(channelId);
89
87
  if (!targets)
90
88
  return;
91
- for (const ws of targets) {
92
- if (ws.readyState === ws_1.WebSocket.OPEN)
93
- ws.send(json);
94
- }
89
+ (0, channel_broadcast_js_1.broadcastToClients)(targets, data);
95
90
  }
96
91
  const busyChannels = new Set();
97
92
  function channelType(channelId) {
@@ -9,6 +9,7 @@ exports.bootstrapWorkspace = bootstrapWorkspace;
9
9
  exports.translateSessionEvent = translateSessionEvent;
10
10
  exports.stopAgent = stopAgent;
11
11
  exports.compactAgentSession = compactAgentSession;
12
+ exports.classifyBrowserError = classifyBrowserError;
12
13
  exports.runAgent = runAgent;
13
14
  const path_1 = __importDefault(require("path"));
14
15
  const fs_1 = __importDefault(require("fs"));
@@ -267,6 +268,31 @@ function extractAgentName(workspaceDir) {
267
268
  }
268
269
  return null;
269
270
  }
271
+ function classifyBrowserError(err, signal) {
272
+ const e = err;
273
+ if (signal?.aborted)
274
+ return 'ABORTED';
275
+ if (e?.name === 'AbortError' || e?.code === 'ABORT_ERR')
276
+ return 'ABORTED';
277
+ if (e?.killed === true && (e?.signal === 'SIGTERM' || e?.code === null))
278
+ return 'TIMEOUT';
279
+ if (e?.code === 'ETIMEDOUT')
280
+ return 'TIMEOUT';
281
+ const raw = `${e?.stderr ?? ''}\n${e?.stdout ?? ''}\n${e?.message ?? ''}`.toLowerCase();
282
+ if (raw.includes('econnrefused') ||
283
+ raw.includes('target closed') ||
284
+ raw.includes('session closed') ||
285
+ raw.includes('protocol error') ||
286
+ raw.includes('browser has disconnected') ||
287
+ raw.includes('cdp connection closed') ||
288
+ raw.includes('daemon not running') ||
289
+ raw.includes('no session')) {
290
+ return 'BROWSER_DIED';
291
+ }
292
+ if (raw.includes('timeout') || raw.includes('timed out'))
293
+ return 'TIMEOUT';
294
+ return 'CMD_ERROR';
295
+ }
270
296
  function getLanIp() {
271
297
  const ifaces = (0, os_1.networkInterfaces)();
272
298
  for (const list of Object.values(ifaces)) {
@@ -306,6 +332,13 @@ async function runAgent(agent, message, onChunk, options) {
306
332
  catch { }
307
333
  let envKey;
308
334
  let prevValue;
335
+ let toolHeartbeat = null;
336
+ const stopHeartbeat = () => {
337
+ if (toolHeartbeat) {
338
+ clearInterval(toolHeartbeat);
339
+ toolHeartbeat = null;
340
+ }
341
+ };
309
342
  try {
310
343
  const { getModel } = await (0, esm_import_js_1.esmImport)('@mariozechner/pi-ai');
311
344
  const { createAgentSession, SessionManager, DefaultResourceLoader, getAgentDir } = await (0, esm_import_js_1.esmImport)('@mariozechner/pi-coding-agent');
@@ -698,6 +731,7 @@ async function runAgent(agent, message, onChunk, options) {
698
731
  'You do not need to screenshot for audit — the whole session is recorded as video automatically.',
699
732
  'Saved logins persist automatically when the user has set up a profile via the dashboard Browser view.',
700
733
  'CAPTCHA & Cloudflare handling: the browser ships a stealth extension that handles Cloudflare JS interstitials ("Just a moment…") automatically (waits up to ~45s). If an actual CAPTCHA widget is detected (reCAPTCHA, hCaptcha, Turnstile, etc.), the runtime returns immediately with a warning — call request_human_browser_takeover right away.',
734
+ 'Error recovery: if a result contains "CATEGORY=BROWSER_DIED" the runtime has ALREADY respawned the browser — just retry the exact same command; do NOT tell the user the browser crashed. "CATEGORY=TIMEOUT" means the page is hung — try a different URL or approach. "CATEGORY=ABORTED" means the user cancelled — stop; do not retry. "CATEGORY=CMD_ERROR" is a genuine command problem — inspect the message.',
701
735
  'Examples: {"command":"open","args":["https://example.com"]}, {"command":"click","args":["--ref","e12"]}, {"command":"fill","args":["--ref","e5","Alice"]}',
702
736
  ],
703
737
  parameters: {
@@ -715,7 +749,7 @@ async function runAgent(agent, message, onChunk, options) {
715
749
  },
716
750
  required: ['command'],
717
751
  },
718
- async execute(_toolCallId, params) {
752
+ async execute(_toolCallId, params, signal) {
719
753
  const command = (params.command ?? '').trim();
720
754
  const args = Array.isArray(params.args) ? params.args.map(String) : [];
721
755
  if (!command) {
@@ -746,6 +780,7 @@ async function runAgent(agent, message, onChunk, options) {
746
780
  timeout: 60_000,
747
781
  maxBuffer: 10 * 1024 * 1024,
748
782
  env: { ...process.env, ...browser.env },
783
+ signal,
749
784
  });
750
785
  (0, session_manager_js_1.appendCommand)(browserState.handle, `${command} ${args.join(' ')}`.trim());
751
786
  const out = stdout.trim() || stderr.trim() || 'ok';
@@ -753,9 +788,20 @@ async function runAgent(agent, message, onChunk, options) {
753
788
  }
754
789
  catch (err) {
755
790
  (0, session_manager_js_1.appendCommand)(browserState.handle, `${command} ${args.join(' ')}`.trim());
791
+ const category = classifyBrowserError(err, signal);
792
+ if (category === 'BROWSER_DIED') {
793
+ browserState.handle = null;
794
+ return { content: [{ type: 'text', text: `browser session expired and has been restarted (CATEGORY=BROWSER_DIED) — retry the ${command} you were attempting; the runtime already handled the reset.` }] };
795
+ }
796
+ if (category === 'ABORTED') {
797
+ return { content: [{ type: 'text', text: `browser ${command} was cancelled (CATEGORY=ABORTED)` }] };
798
+ }
756
799
  const e = err;
757
- const msg = (e.stderr || e.stdout || e.message || String(err)).trim();
758
- return { content: [{ type: 'text', text: `browser ${command} failed: ${msg}` }] };
800
+ const raw = (e.stderr || e.stdout || e.message || String(err)).trim();
801
+ if (category === 'TIMEOUT') {
802
+ return { content: [{ type: 'text', text: `browser ${command} timed out after 60s (CATEGORY=TIMEOUT). Page is likely hung — try a different URL or fall back to fetch_website.` }] };
803
+ }
804
+ return { content: [{ type: 'text', text: `browser ${command} failed (CATEGORY=CMD_ERROR): ${raw}` }] };
759
805
  }
760
806
  },
761
807
  });
@@ -1004,7 +1050,7 @@ async function runAgent(agent, message, onChunk, options) {
1004
1050
  cwd: workspaceDir,
1005
1051
  agentDir,
1006
1052
  extensionFactories,
1007
- ...(appendSystemPrompt !== undefined ? { appendSystemPrompt } : {}),
1053
+ ...(appendSystemPrompt !== undefined ? { appendSystemPrompt: [appendSystemPrompt] } : {}),
1008
1054
  });
1009
1055
  await resourceLoader.reload();
1010
1056
  const { session } = await createAgentSession({
@@ -1027,6 +1073,7 @@ async function runAgent(agent, message, onChunk, options) {
1027
1073
  console.error(`[runner-pi:${agent.id}] failed to configure auto-compaction:`, err);
1028
1074
  }
1029
1075
  let errorEmitted = false;
1076
+ const HEARTBEAT_INTERVAL_MS = 30_000;
1030
1077
  session.subscribe((event) => {
1031
1078
  try {
1032
1079
  switch (event.type) {
@@ -1042,9 +1089,20 @@ async function runAgent(agent, message, onChunk, options) {
1042
1089
  case 'tool_execution_start': {
1043
1090
  onChunk({ type: 'tool_call', tool: event.toolName, input: event.args });
1044
1091
  (0, logs_db_js_1.logAction)(agent.id, 'tool_call', { tool: event.toolName, input: event.args });
1092
+ stopHeartbeat();
1093
+ toolHeartbeat = setInterval(() => {
1094
+ try {
1095
+ onChunk({ type: 'heartbeat' });
1096
+ }
1097
+ catch { }
1098
+ }, HEARTBEAT_INTERVAL_MS);
1099
+ if (typeof toolHeartbeat?.unref === 'function') {
1100
+ toolHeartbeat.unref();
1101
+ }
1045
1102
  break;
1046
1103
  }
1047
1104
  case 'tool_execution_end': {
1105
+ stopHeartbeat();
1048
1106
  const output = event.result;
1049
1107
  const outputStr = typeof output === 'string' ? output : JSON.stringify(output);
1050
1108
  onChunk({ type: 'tool_result', tool: event.toolName, output });
@@ -1167,6 +1225,7 @@ async function runAgent(agent, message, onChunk, options) {
1167
1225
  catch { }
1168
1226
  }
1169
1227
  finally {
1228
+ stopHeartbeat();
1170
1229
  activeSessions.delete(agent.id);
1171
1230
  if (browserState.handle) {
1172
1231
  try {
@@ -119,7 +119,7 @@ async function startRecording(handle, res) {
119
119
  const started = await tryStart();
120
120
  if (!started)
121
121
  return false;
122
- const FILE_APPEAR_TIMEOUT_MS = 5000;
122
+ const FILE_APPEAR_TIMEOUT_MS = 15_000;
123
123
  const FILE_APPEAR_POLL_MS = 100;
124
124
  const deadline = Date.now() + FILE_APPEAR_TIMEOUT_MS;
125
125
  let fileOk = false;
@@ -135,7 +135,7 @@ async function startRecording(handle, res) {
135
135
  await new Promise((r) => setTimeout(r, FILE_APPEAR_POLL_MS));
136
136
  }
137
137
  if (!fileOk) {
138
- console.warn(`[browser/session-manager] record start reported success but ${recordingPath} did not materialize within ${FILE_APPEAR_TIMEOUT_MS}ms — check that ffmpeg is installed and the host is not overloaded`);
138
+ console.warn(`[browser/session-manager] record start reported success but ${recordingPath} did not materialize within ${FILE_APPEAR_TIMEOUT_MS}ms — session will proceed without WebM recording`);
139
139
  try {
140
140
  await execFileAsync(bin, stopArgv, { cwd: handle.workspaceDir, timeout: 5000 });
141
141
  }
@@ -5,12 +5,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  require("dotenv/config");
7
7
  const net_1 = __importDefault(require("net"));
8
+ const node_events_1 = require("node:events");
8
9
  const server_js_1 = require("./orchestrator/server.js");
10
+ (0, node_events_1.setMaxListeners)(50);
9
11
  const agent_manager_js_1 = require("./orchestrator/agent-manager.js");
10
12
  const scheduler_js_1 = require("./scheduler.js");
11
13
  const telemetry_js_1 = require("./telemetry.js");
12
14
  const config_js_1 = require("./config.js");
13
15
  const browser_sessions_js_1 = require("./browser-sessions.js");
16
+ const workflows_db_js_1 = require("./workflows-db.js");
14
17
  const PORT = Number(process.env.PORT ?? 3001);
15
18
  function checkPortAvailable(port, label) {
16
19
  return new Promise((resolve, reject) => {
@@ -50,6 +53,15 @@ preflight()
50
53
  catch (err) {
51
54
  console.error(`[orchestrator] finalizeAllActiveSessions failed for ${agent.id}:`, err);
52
55
  }
56
+ try {
57
+ const flipped = (0, workflows_db_js_1.finalizeRunningRuns)(agent.id);
58
+ if (flipped > 0) {
59
+ console.log(`[orchestrator] finalized ${flipped} leftover running workflow run(s) for ${agent.id}`);
60
+ }
61
+ }
62
+ catch (err) {
63
+ console.error(`[orchestrator] finalizeRunningRuns failed for ${agent.id}:`, err);
64
+ }
53
65
  }
54
66
  (0, agent_manager_js_1.startAllAgents)();
55
67
  (0, scheduler_js_1.startScheduler)();
@@ -624,6 +624,26 @@ function createServer() {
624
624
  (0, agent_manager_js_1.restartAgent)(req.params.id);
625
625
  res.json({ ok: true });
626
626
  });
627
+ function resolveSkillsDir(workspaceDir) {
628
+ for (const candidate of ['.pi/skills', '.agent/skills', '.claude/skills']) {
629
+ const p = path_1.default.join(workspaceDir, candidate);
630
+ if (fs_1.default.existsSync(p))
631
+ return p;
632
+ }
633
+ return null;
634
+ }
635
+ function parseFrontmatter(content) {
636
+ const fm = content.match(/^---\n([\s\S]*?)\n---/);
637
+ if (!fm)
638
+ return {};
639
+ const out = {};
640
+ for (const line of fm[1].split('\n')) {
641
+ const m = line.match(/^([A-Za-z_-]+):\s*(.+)$/);
642
+ if (m)
643
+ out[m[1]] = m[2].trim();
644
+ }
645
+ return out;
646
+ }
627
647
  app.get('/agents/:id/skills', (req, res) => {
628
648
  const managed = (0, agent_manager_js_1.getManagedAgent)(req.params.id);
629
649
  if (!managed) {
@@ -631,11 +651,8 @@ function createServer() {
631
651
  return;
632
652
  }
633
653
  const workspaceDir = path_1.default.resolve(config_js_1.REPO_ROOT, managed.config.workspaceDir);
634
- const agentSkillsDir = path_1.default.join(workspaceDir, '.agent', 'skills');
635
- const skillsDir = fs_1.default.existsSync(agentSkillsDir)
636
- ? agentSkillsDir
637
- : path_1.default.join(workspaceDir, '.claude', 'skills');
638
- if (!fs_1.default.existsSync(skillsDir)) {
654
+ const skillsDir = resolveSkillsDir(workspaceDir);
655
+ if (!skillsDir) {
639
656
  res.json({ skills: [] });
640
657
  return;
641
658
  }
@@ -646,19 +663,49 @@ function createServer() {
646
663
  const skillMd = path_1.default.join(skillsDir, entry.name, 'SKILL.md');
647
664
  if (!fs_1.default.existsSync(skillMd))
648
665
  continue;
649
- const content = fs_1.default.readFileSync(skillMd, 'utf8');
650
- const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
651
- if (!fmMatch)
666
+ const fm = parseFrontmatter(fs_1.default.readFileSync(skillMd, 'utf8'));
667
+ if (!fm.name && !fm.description)
652
668
  continue;
653
- const nameMatch = fmMatch[1].match(/^name:\s*(.+)/m);
654
- const descMatch = fmMatch[1].match(/^description:\s*(.+)/m);
655
669
  skills.push({
656
- name: nameMatch?.[1]?.trim() ?? entry.name,
657
- description: descMatch?.[1]?.trim() ?? '',
670
+ name: fm.name ?? entry.name,
671
+ description: fm.description ?? '',
672
+ userInvocable: fm['user-invocable'] === 'true',
658
673
  });
659
674
  }
675
+ skills.sort((a, b) => a.name.localeCompare(b.name));
660
676
  res.json({ skills });
661
677
  });
678
+ app.get('/agents/:id/skills/:name', (req, res) => {
679
+ const managed = (0, agent_manager_js_1.getManagedAgent)(req.params.id);
680
+ if (!managed) {
681
+ res.status(404).json({ error: 'Agent not found' });
682
+ return;
683
+ }
684
+ const workspaceDir = path_1.default.resolve(config_js_1.REPO_ROOT, managed.config.workspaceDir);
685
+ const skillsDir = resolveSkillsDir(workspaceDir);
686
+ if (!skillsDir) {
687
+ res.status(404).json({ error: 'No skills directory' });
688
+ return;
689
+ }
690
+ const requested = path_1.default.resolve(skillsDir, req.params.name, 'SKILL.md');
691
+ if (!requested.startsWith(skillsDir + path_1.default.sep)) {
692
+ res.status(400).json({ error: 'Invalid skill name' });
693
+ return;
694
+ }
695
+ if (!fs_1.default.existsSync(requested)) {
696
+ res.status(404).json({ error: 'Skill not found' });
697
+ return;
698
+ }
699
+ const content = fs_1.default.readFileSync(requested, 'utf8');
700
+ const fm = parseFrontmatter(content);
701
+ res.json({
702
+ name: fm.name ?? req.params.name,
703
+ description: fm.description ?? '',
704
+ userInvocable: fm['user-invocable'] === 'true',
705
+ allowedTools: fm['allowed-tools'] ?? '',
706
+ content,
707
+ });
708
+ });
662
709
  app.get('/agents/:id/tasks', (req, res) => {
663
710
  const managed = (0, agent_manager_js_1.getManagedAgent)(req.params.id);
664
711
  if (!managed) {
@@ -1002,7 +1049,11 @@ function createServer() {
1002
1049
  res.status(404).json({ error: 'Agent not found' });
1003
1050
  return;
1004
1051
  }
1005
- res.json((0, workflows_db_js_1.listWorkflows)(req.params.id));
1052
+ const workflows = (0, workflows_db_js_1.listWorkflows)(req.params.id).map(w => ({
1053
+ ...w,
1054
+ lastRun: (0, workflows_db_js_1.getLatestRun)(req.params.id, w.id),
1055
+ }));
1056
+ res.json(workflows);
1006
1057
  });
1007
1058
  app.post('/agents/:id/workflows', (req, res) => {
1008
1059
  const managed = (0, agent_manager_js_1.getManagedAgent)(req.params.id);
@@ -18,6 +18,8 @@ exports.getRun = getRun;
18
18
  exports.createRunStep = createRunStep;
19
19
  exports.updateRunStep = updateRunStep;
20
20
  exports.getRunningRuns = getRunningRuns;
21
+ exports.finalizeRunningRuns = finalizeRunningRuns;
22
+ exports.getLatestRun = getLatestRun;
21
23
  exports.closeWorkflowsDb = closeWorkflowsDb;
22
24
  const path_1 = __importDefault(require("path"));
23
25
  const crypto_1 = require("crypto");
@@ -213,6 +215,41 @@ function getRunningRuns(agentId) {
213
215
  return [];
214
216
  }
215
217
  }
218
+ function finalizeRunningRuns(agentId) {
219
+ try {
220
+ const db = getDb(agentId);
221
+ const now = Date.now();
222
+ const err = 'Run interrupted — backend restarted while executing.';
223
+ db.prepare(`
224
+ UPDATE run_steps SET status = 'failed', error = COALESCE(error, ?), finished_at = COALESCE(finished_at, ?)
225
+ WHERE status = 'running'
226
+ `).run(err, now);
227
+ db.prepare(`
228
+ UPDATE run_steps SET status = 'skipped'
229
+ WHERE status = 'pending' AND run_id IN (SELECT id FROM runs WHERE status = 'running')
230
+ `).run();
231
+ const result = db.prepare(`
232
+ UPDATE runs SET status = 'failed', finished_at = COALESCE(finished_at, ?)
233
+ WHERE status = 'running'
234
+ `).run(now);
235
+ return result.changes ?? 0;
236
+ }
237
+ catch {
238
+ return 0;
239
+ }
240
+ }
241
+ function getLatestRun(agentId, workflowId) {
242
+ try {
243
+ const db = getDb(agentId);
244
+ const row = db.prepare(`
245
+ SELECT * FROM runs WHERE workflow_id = ? ORDER BY started_at DESC LIMIT 1
246
+ `).get(workflowId);
247
+ return row ? rowToRun(row) : null;
248
+ }
249
+ catch {
250
+ return null;
251
+ }
252
+ }
216
253
  function closeWorkflowsDb(agentId) {
217
254
  const agent = (0, config_js_1.getAgent)(agentId);
218
255
  if (!agent)