clauder-hub 0.1.2 → 0.1.5

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 (26) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/server/dist/index.js +265 -20
  4. package/web/dist/assets/{MonacoEditor-Bdblyy-v.js → MonacoEditor-7a1aZ6Pf.js} +3 -3
  5. package/web/dist/assets/{cssMode-DOakqY50.js → cssMode-D42SG8q2.js} +1 -1
  6. package/web/dist/assets/{freemarker2-WsMzPf-p.js → freemarker2-HuLVqAyk.js} +1 -1
  7. package/web/dist/assets/{handlebars-CFf3afIj.js → handlebars-CQbtHmDj.js} +1 -1
  8. package/web/dist/assets/{html-Duk6L3Ci.js → html-DCQkaSoP.js} +1 -1
  9. package/web/dist/assets/{htmlMode-CuaMCYCU.js → htmlMode-Occ0oux7.js} +1 -1
  10. package/web/dist/assets/{index-DpS67tuQ.js → index-382ZKQ6T.js} +132 -127
  11. package/web/dist/assets/{index-CGbzqzmX.js → index-DB6lAoRD.js} +1 -1
  12. package/web/dist/assets/index-YQ0zZelJ.css +32 -0
  13. package/web/dist/assets/{javascript-57wNn1QZ.js → javascript-DkkoffJP.js} +1 -1
  14. package/web/dist/assets/{jsonMode-DPgMOUkd.js → jsonMode-CvTmblXx.js} +1 -1
  15. package/web/dist/assets/{liquid-DHlnUOel.js → liquid-DhsiR38F.js} +1 -1
  16. package/web/dist/assets/{lspLanguageFeatures-BIy_BVAi.js → lspLanguageFeatures-lfpHn74b.js} +1 -1
  17. package/web/dist/assets/{mdx-SCDblWWe.js → mdx-LuMf9AxQ.js} +1 -1
  18. package/web/dist/assets/{python-DcjgL-6k.js → python-BkVjahJB.js} +1 -1
  19. package/web/dist/assets/{razor-q_Zkc8t3.js → razor-CoVA4mXW.js} +1 -1
  20. package/web/dist/assets/{tsMode-N3PGleJF.js → tsMode-C3jqF6o4.js} +1 -1
  21. package/web/dist/assets/{typescript-UQxkBsMs.js → typescript-DzEhu0NR.js} +1 -1
  22. package/web/dist/assets/{workers-BVzxuZVI.js → workers-DO15KvMQ.js} +1 -1
  23. package/web/dist/assets/{xml-BDNQC6ch.js → xml-Dnv-hWWk.js} +1 -1
  24. package/web/dist/assets/{yaml-FyKAiCgl.js → yaml-hUd7D5AP.js} +1 -1
  25. package/web/dist/index.html +2 -2
  26. package/web/dist/assets/index-DI4uTQnz.css +0 -32
package/README.md CHANGED
@@ -59,7 +59,7 @@ Clauder Hub runs the real `claude` you already use, in real terminals, and adds
59
59
  <tr>
60
60
  <td>
61
61
  <h3>Answer from your phone</h3>
62
- Telegram and Slack bots relay every question with inline buttons, and BotFather-style commands (<code>/new</code>, <code>/sessions</code>, <code>/summary</code>, <code>/stop</code>) drive sessions from chat. No public URL needed.
62
+ Telegram and Slack bots relay every question with inline buttons, and BotFather-style commands (<code>/new</code>, <code>/jobs</code>, <code>/sessions</code>, <code>/summary</code>, <code>/stop</code>) drive sessions from chat. No public URL needed.
63
63
  </td>
64
64
  <td>
65
65
  <picture>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clauder-hub",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "A local dashboard that runs, watches and automates the Claude Code sessions of every project on the machine",
5
5
  "license": "MIT",
6
6
  "author": "goldyndevs <goldyndevs@gmail.com>",
@@ -27214,6 +27214,8 @@ CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status, created_at);
27214
27214
  ensureColumn("sessions", "setup", "TEXT");
27215
27215
  ensureColumn("channel_refs", "project_id", "TEXT");
27216
27216
  ensureColumn("channel_threads", "project_id", "TEXT");
27217
+ ensureColumn("runs", "resume", "INTEGER NOT NULL DEFAULT 0");
27218
+ ensureColumn("runs", "queued_at", "TEXT");
27217
27219
  }
27218
27220
  function now() {
27219
27221
  return (/* @__PURE__ */ new Date()).toISOString();
@@ -28558,14 +28560,14 @@ function claudeSettings() {
28558
28560
  }
28559
28561
  function saveClaudeSettings(input) {
28560
28562
  const next = { ...claudeSettings(), ...input };
28561
- const check2 = (ok, message) => {
28563
+ const check3 = (ok, message) => {
28562
28564
  if (!ok) throw new Error(message);
28563
28565
  };
28564
- check2(!next.permissionMode || PERMISSION_MODES.includes(next.permissionMode), `Unknown permission mode '${next.permissionMode}'.`);
28565
- check2(!next.runPermissionMode || PERMISSION_MODES.includes(next.runPermissionMode), `Unknown permission mode '${next.runPermissionMode}'.`);
28566
- check2(!next.effort || EFFORTS.includes(next.effort), `Effort is one of ${EFFORTS.join(", ")}.`);
28567
- check2(word.test(next.model) && word.test(next.fallbackModel), "A model is one word, like opus or claude-sonnet-5-5.");
28568
- check2(["", "on", "off"].includes(next.chrome), "Claude in Chrome is on, off or your own setting.");
28566
+ check3(!next.permissionMode || PERMISSION_MODES.includes(next.permissionMode), `Unknown permission mode '${next.permissionMode}'.`);
28567
+ check3(!next.runPermissionMode || PERMISSION_MODES.includes(next.runPermissionMode), `Unknown permission mode '${next.runPermissionMode}'.`);
28568
+ check3(!next.effort || EFFORTS.includes(next.effort), `Effort is one of ${EFFORTS.join(", ")}.`);
28569
+ check3(word.test(next.model) && word.test(next.fallbackModel), "A model is one word, like opus or claude-sonnet-5-5.");
28570
+ check3(["", "on", "off"].includes(next.chrome), "Claude in Chrome is on, off or your own setting.");
28569
28571
  next.addDirs = (Array.isArray(next.addDirs) ? next.addDirs : []).map((dir) => String(dir).trim()).filter(Boolean);
28570
28572
  db.prepare("INSERT INTO settings (key, value, updated_at) VALUES ('claude', ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at").run(
28571
28573
  JSON.stringify(next),
@@ -28743,6 +28745,14 @@ If the category was given as "auto", choose the one that fits the task best. Do
28743
28745
 
28744
28746
  Finish what is missing, then send the done block again.`
28745
28747
  },
28748
+ {
28749
+ key: "typed.resumeRun",
28750
+ label: "First message of a resumed run",
28751
+ audience: "claude-typed",
28752
+ description: "Typed into a stopped run's own session when it is resumed from the queue (Resume on the Automation page, /resume on a channel).",
28753
+ placeholders: [],
28754
+ text: "You were stopped part-way through this task. Check what is already done, then carry on from where you left off, without redoing finished work."
28755
+ },
28746
28756
  {
28747
28757
  key: "typed.addMcp",
28748
28758
  label: "First message of an Add MCP server task",
@@ -28893,6 +28903,8 @@ Reply *install* to run it (project scope) and resume the session, or *skip* to c
28893
28903
 
28894
28904
  {{p}}new - start a session (with a prompt, or I ask for one)
28895
28905
  {{p}}run - queue an unattended run
28906
+ {{p}}jobs - the saved jobs, to run one ({{p}}job <name> runs one by name)
28907
+ {{p}}resume - carry on a stopped run
28896
28908
  {{p}}sessions - the sessions, to pick one
28897
28909
  {{p}}summary - what a session did
28898
28910
  {{p}}stop - stop a running session
@@ -28947,7 +28959,7 @@ Reply to this message to talk to the session. Its questions come here too.`
28947
28959
  {{p}}projects - the projects, to pick one
28948
28960
  {{p}}use <project> - work on a project
28949
28961
  {{p}}addproject <words> - add a project: describe it, I find the folder
28950
- Then, on that project: {{p}}new, {{p}}run, {{p}}sessions, {{p}}summary, {{p}}stop, {{p}}queue, {{p}}cancel.
28962
+ Then, on that project: {{p}}new, {{p}}run, {{p}}jobs, {{p}}resume, {{p}}sessions, {{p}}summary, {{p}}stop, {{p}}queue, {{p}}cancel.
28951
28963
 
28952
28964
  Working on: {{current}}`
28953
28965
  },
@@ -28967,13 +28979,29 @@ Working on: {{current}}`
28967
28979
  placeholders: ["request", "p"],
28968
28980
  text: 'I found no project folder that looks like "{{request}}". Describe it another way (its name, what it does), or send its full path. Or {{p}}cancel.'
28969
28981
  },
28982
+ {
28983
+ key: "channel.jobConfirm",
28984
+ label: "Channel command: confirming a job run",
28985
+ audience: "channel",
28986
+ description: "Asked before a saved job is queued from the channel ({{p}}job, or a button from {{p}}jobs).",
28987
+ placeholders: ["job", "prompt"],
28988
+ text: "Run {{job}} now? It joins the queue as an unattended run; its questions and its result come here.\n\n{{prompt}}"
28989
+ },
28990
+ {
28991
+ key: "channel.resumeConfirm",
28992
+ label: "Channel command: confirming a resume",
28993
+ audience: "channel",
28994
+ description: "Asked before a stopped run is queued again to carry on its own session ({{p}}resume).",
28995
+ placeholders: ["run"],
28996
+ text: "Resume {{run}}? It joins the back of the queue and carries on its own conversation from where it stopped."
28997
+ },
28970
28998
  {
28971
28999
  key: "channel.stopConfirm",
28972
29000
  label: "Channel command: confirming a stop",
28973
29001
  audience: "channel",
28974
29002
  description: "Asked before a session is stopped from the channel.",
28975
29003
  placeholders: ["session"],
28976
- text: "Stop {{session}}? Its terminal closes; the conversation is kept and can be resumed from the dashboard."
29004
+ text: "Stop {{session}}? Its terminal closes; the conversation is kept and can be resumed from the dashboard, or with resume for a run."
28977
29005
  }
28978
29006
  ];
28979
29007
  }
@@ -29074,6 +29102,7 @@ function spawnTerminal(input) {
29074
29102
  clients: /* @__PURE__ */ new Set(),
29075
29103
  backlog: [],
29076
29104
  backlogSize: 0,
29105
+ lastOutputAt: Date.now(),
29077
29106
  cols,
29078
29107
  rows
29079
29108
  };
@@ -29081,6 +29110,7 @@ function spawnTerminal(input) {
29081
29110
  child.onData(bindProject((data) => {
29082
29111
  terminal.backlog.push(data);
29083
29112
  terminal.backlogSize += data.length;
29113
+ terminal.lastOutputAt = Date.now();
29084
29114
  while (terminal.backlogSize > BACKLOG_LIMIT && terminal.backlog.length > 1) {
29085
29115
  terminal.backlogSize -= terminal.backlog.shift().length;
29086
29116
  }
@@ -29167,11 +29197,11 @@ function killTerminal(terminal) {
29167
29197
  function waitForExit(terminal, timeoutMs = 1e4) {
29168
29198
  return new Promise((resolve) => {
29169
29199
  const started = Date.now();
29170
- const check2 = () => {
29200
+ const check3 = () => {
29171
29201
  if (!terminals.has(terminal.id) || Date.now() - started > timeoutMs) return resolve();
29172
- setTimeout(check2, 100);
29202
+ setTimeout(check3, 100);
29173
29203
  };
29174
- check2();
29204
+ check3();
29175
29205
  });
29176
29206
  }
29177
29207
  function killAll() {
@@ -30365,6 +30395,23 @@ function queueFromChannel(prompt) {
30365
30395
  const { position } = enqueue({ source: "manual", prompt, category: "auto" });
30366
30396
  return { title: "\u{1F5C2} Run queued", lines: [renderPrompt("channel.queued", { position: String(position) })], buttons: [[{ label: "\u{1F5C2} Queue", command: "queue" }]] };
30367
30397
  }
30398
+ function findJob(arg) {
30399
+ const wanted = arg.trim().toLowerCase();
30400
+ if (!wanted) return null;
30401
+ const jobs = listJobs();
30402
+ const exact = getJob(arg.trim()) ?? jobs.find((job) => job.name.toLowerCase() === wanted);
30403
+ if (exact) return exact;
30404
+ const matches = jobs.filter((job) => job.name.toLowerCase().includes(wanted));
30405
+ return matches.length === 1 ? matches[0] : null;
30406
+ }
30407
+ function jobPicker(title, jobs, empty) {
30408
+ if (!jobs.length) return empty;
30409
+ return {
30410
+ title,
30411
+ lines: jobs.slice(0, 15).map((job, index) => `${index + 1}. ${job.name} \xB7 ${job.kind === "cron" ? `cron ${job.schedule}` : "manual / webhook"}${job.enabled ? "" : " \xB7 disabled"}`),
30412
+ buttons: jobs.slice(0, 15).map((job, index) => [{ label: short(`\u25B6\uFE0F ${index + 1}. ${job.name}`, 60), command: `job ${job.id}` }])
30413
+ };
30414
+ }
30368
30415
  function queueCard() {
30369
30416
  const runs = listRuns(50).filter((run4) => run4.status === "queued" || run4.status === "running");
30370
30417
  if (!runs.length) return "The queue is empty.";
@@ -30450,6 +30497,58 @@ async function runCommand(channelId, input, from, options = {}) {
30450
30497
  if (arg) return name === "new" ? startFromChannel(channelId, arg, from, options) : queueFromChannel(arg);
30451
30498
  awaiting.set(key, name);
30452
30499
  return { title: name === "new" ? "\u25B6\uFE0F New session" : "\u{1F5C2} New run", lines: [renderPrompt(name === "new" ? "channel.askNew" : "channel.askRun", { p: p2 })], buttons: [[{ label: "Cancel", command: "cancel" }]] };
30500
+ case "jobs":
30501
+ return jobPicker("\u2699\uFE0F Jobs: pick one to run", listJobs(), "No jobs yet. Create one on the Automation page.");
30502
+ case "job": {
30503
+ if (!arg) return jobPicker("\u2699\uFE0F Run which job?", listJobs(), "No jobs yet. Create one on the Automation page.");
30504
+ const job = findJob(arg);
30505
+ if (!job) {
30506
+ const like = listJobs().filter((item) => item.name.toLowerCase().includes(arg.toLowerCase()));
30507
+ return jobPicker(`\u{1F914} Which job did you mean?`, like.length ? like : listJobs(), `No job like "${short(arg, 60)}". ${p2}jobs lists them.`);
30508
+ }
30509
+ return {
30510
+ title: "\u2699\uFE0F Run the job?",
30511
+ lines: [renderPrompt("channel.jobConfirm", { job: job.name, prompt: short(flat(job.prompt), 300) })],
30512
+ buttons: [
30513
+ [
30514
+ { label: "Yes, run it", command: `job! ${job.id}`, tone: "primary" },
30515
+ { label: "No", command: "cancel" }
30516
+ ]
30517
+ ]
30518
+ };
30519
+ }
30520
+ case "job!": {
30521
+ const job = getJob(arg);
30522
+ if (!job) return `That job is gone. ${p2}jobs lists them.`;
30523
+ const { position } = enqueue({ job, source: "manual" });
30524
+ return { title: `\u{1F5C2} ${job.name} queued`, lines: [renderPrompt("channel.queued", { position: String(position) })], buttons: [[{ label: "\u{1F5C2} Queue", command: "queue" }]] };
30525
+ }
30526
+ case "resume": {
30527
+ const runs = resumable();
30528
+ const run4 = arg ? runs.find((item) => item.id === arg) ?? runs[Number(arg) - 1] : void 0;
30529
+ if (!run4) {
30530
+ if (!runs.length) return "No stopped run to resume.";
30531
+ return {
30532
+ title: "\u23EF Resume which run?",
30533
+ lines: runs.map((item, index) => `${index + 1}. ${short(flat(item.jobName ?? item.prompt), 80)} \xB7 stopped ${item.finishedAt ? new Date(item.finishedAt).toLocaleString() : ""}`),
30534
+ buttons: runs.map((item, index) => [{ label: short(`\u23EF ${index + 1}. ${flat(item.jobName ?? item.prompt)}`, 60), command: `resume ${item.id}` }])
30535
+ };
30536
+ }
30537
+ return {
30538
+ title: "\u23EF Resume the run?",
30539
+ lines: [renderPrompt("channel.resumeConfirm", { run: flat(run4.jobName ?? short(run4.prompt, 80)) })],
30540
+ buttons: [
30541
+ [
30542
+ { label: "Yes, resume it", command: `resume! ${run4.id}`, tone: "primary" },
30543
+ { label: "No", command: "cancel" }
30544
+ ]
30545
+ ]
30546
+ };
30547
+ }
30548
+ case "resume!": {
30549
+ const { run: run4, position } = resumeRun(arg);
30550
+ return { title: `\u23EF ${flat(run4.jobName ?? short(run4.prompt, 60))} queued again`, lines: [renderPrompt("channel.queued", { position: String(position) })], buttons: [[{ label: "\u{1F5C2} Queue", command: "queue" }]] };
30551
+ }
30453
30552
  case "cancel":
30454
30553
  suggested.delete(suggestKey(channelId, from));
30455
30554
  return focused.delete(key) ? "Cancelled. Plain messages go nowhere until you pick a session." : "Cancelled.";
@@ -30509,7 +30608,7 @@ async function runCommand(channelId, input, from, options = {}) {
30509
30608
  return await interpret(channelId, from, input, options) ?? `I don't know ${p2}${name}. ${p2}help lists what I can do.`;
30510
30609
  }
30511
30610
  }
30512
- var COMMAND, awaiting, focused, FOCUS_IDLE_MS, numbered, keyOf, HUB_COMMANDS, short, flat, STATUS, suggested, suggestKey, sessionLine, COMMAND_GUIDE, GUESSABLE, GLOBAL_COMMAND_MENU, COMMAND_MENU;
30611
+ var COMMAND, awaiting, focused, FOCUS_IDLE_MS, numbered, keyOf, HUB_COMMANDS, short, flat, STATUS, resumable, suggested, suggestKey, sessionLine, COMMAND_GUIDE, GUESSABLE, GLOBAL_COMMAND_MENU, COMMAND_MENU;
30513
30612
  var init_commands = __esm({
30514
30613
  "server/src/channels/commands.ts"() {
30515
30614
  "use strict";
@@ -30527,16 +30626,20 @@ var init_commands = __esm({
30527
30626
  FOCUS_IDLE_MS = 60 * 6e4;
30528
30627
  numbered = /* @__PURE__ */ new Map();
30529
30628
  keyOf = (channelId) => `${currentProject().id}:${channelId}`;
30530
- HUB_COMMANDS = /* @__PURE__ */ new Set(["start", "help", "new", "run", "cancel", "leave", "sessions", "session", "summary", "stop", "stop!", "queue", "cancelrun"]);
30629
+ HUB_COMMANDS = /* @__PURE__ */ new Set(["start", "help", "new", "run", "jobs", "job", "job!", "resume", "resume!", "cancel", "leave", "sessions", "session", "summary", "stop", "stop!", "queue", "cancelrun"]);
30531
30630
  short = (text, length) => text.length > length ? `${text.slice(0, length - 1)}\u2026` : text;
30532
30631
  flat = (text) => text.replace(/\s+/g, " ").trim();
30533
30632
  STATUS = { waiting: "\u23F3 waiting for you", running: "\u25B6\uFE0F running", done: "\u2705 done", exited: "\u23F9 stopped", external: "outside the hub" };
30633
+ resumable = () => listRuns(50).filter((run4) => run4.sessionId && run4.status === "exited").slice(0, 10);
30534
30634
  suggested = /* @__PURE__ */ new Map();
30535
30635
  suggestKey = (channelId, from) => `${keyOf(channelId)}:${from}`;
30536
30636
  sessionLine = (session, index) => `${index + 1}. ${session.title} \xB7 ${STATUS[session.status] ?? session.status}${session.firstPrompt ? ` \xB7 asked: ${short(flat(session.firstPrompt), 120)}` : ""}`;
30537
30637
  COMMAND_GUIDE = [
30538
30638
  "new <what the session should do> - start a session",
30539
30639
  "run <what to do> - queue an unattended run",
30640
+ "jobs - list the saved jobs, to run one",
30641
+ "job <name> - run a saved job now (asks first)",
30642
+ "resume - carry on a stopped run (asks first)",
30540
30643
  "sessions - list the sessions",
30541
30644
  "session <number> - pick a session, to talk to it",
30542
30645
  "summary <number> - what a session did",
@@ -30544,13 +30647,15 @@ var init_commands = __esm({
30544
30647
  "queue - the run queue",
30545
30648
  "help - the commands"
30546
30649
  ].join("\n");
30547
- GUESSABLE = /* @__PURE__ */ new Set(["new", "run", "sessions", "session", "summary", "stop", "queue", "help"]);
30650
+ GUESSABLE = /* @__PURE__ */ new Set(["new", "run", "jobs", "job", "resume", "sessions", "session", "summary", "stop", "queue", "help"]);
30548
30651
  GLOBAL_COMMAND_MENU = [
30549
30652
  { command: "projects", description: "Pick a project" },
30550
30653
  { command: "use", description: "Work on a project: /use <name>" },
30551
30654
  { command: "addproject", description: "Add a project: describe it, I find it" },
30552
30655
  { command: "new", description: "Start a session in the picked project" },
30553
30656
  { command: "run", description: "Queue a run in the picked project" },
30657
+ { command: "jobs", description: "The picked project's jobs, to run one" },
30658
+ { command: "resume", description: "Carry on a stopped run in the picked project" },
30554
30659
  { command: "sessions", description: "The picked project's sessions" },
30555
30660
  { command: "summary", description: "What a session did" },
30556
30661
  { command: "stop", description: "Stop a running session" },
@@ -30561,6 +30666,8 @@ var init_commands = __esm({
30561
30666
  COMMAND_MENU = [
30562
30667
  { command: "new", description: "Start a session" },
30563
30668
  { command: "run", description: "Queue an unattended run" },
30669
+ { command: "jobs", description: "Run a saved job" },
30670
+ { command: "resume", description: "Carry on a stopped run" },
30564
30671
  { command: "sessions", description: "Pick a session" },
30565
30672
  { command: "summary", description: "What a session did" },
30566
30673
  { command: "stop", description: "Stop a running session" },
@@ -31171,6 +31278,113 @@ var init_telegram = __esm({
31171
31278
  }
31172
31279
  });
31173
31280
 
31281
+ // server/src/watchdog.ts
31282
+ var watchdog_exports = {};
31283
+ __export(watchdog_exports, {
31284
+ answerTrust: () => answerTrust,
31285
+ startWatchdog: () => startWatchdog
31286
+ });
31287
+ function screenTail(terminal) {
31288
+ let text = "";
31289
+ for (let index = terminal.backlog.length - 1; index >= 0 && text.length < SCREEN_TAIL * 2; index -= 1) text = terminal.backlog[index] + text;
31290
+ return text.slice(-SCREEN_TAIL * 2).replace(ANSI, "").slice(-SCREEN_TAIL);
31291
+ }
31292
+ async function answerTrust(terminal, trust) {
31293
+ if (!trust) return pressKeys(terminal, "\x1B");
31294
+ for (let step = 0; step < 4; step += 1) {
31295
+ const flat2 = squeeze(screenTail(terminal).slice(-1500));
31296
+ const cursor = flat2.lastIndexOf("\u276F");
31297
+ const after = cursor >= 0 ? flat2.slice(cursor + 1) : "";
31298
+ if (after.startsWith("yes")) return pressKeys(terminal, "\r");
31299
+ const yesAt = flat2.lastIndexOf("yes,itrust");
31300
+ await pressKeys(terminal, yesAt > cursor ? "\x1B[B" : "\x1B[A");
31301
+ await sleep2(300);
31302
+ }
31303
+ throw new Error("Could not find the trust option on the screen.");
31304
+ }
31305
+ function lastLines(screen) {
31306
+ const lines2 = screen.split(/\r?\n/).map((line) => line.replace(/\s+/g, " ").trim()).filter(Boolean);
31307
+ return lines2.slice(-14).join("\n").slice(-1200);
31308
+ }
31309
+ function check2() {
31310
+ for (const terminal of listTerminals()) {
31311
+ if (terminal.finished || terminal.restarting) continue;
31312
+ const open = listInteractions({ sessionId: terminal.sessionId, status: "open" });
31313
+ const screen = screenTail(terminal);
31314
+ const trustOpen = open.find((item) => item.data?.trust);
31315
+ if (showsTrustDialog(screen)) {
31316
+ if (!trustOpen) {
31317
+ createInteraction({
31318
+ sessionId: terminal.sessionId,
31319
+ kind: "question",
31320
+ title: "Trust this folder?",
31321
+ body: "Claude Code asks whether to trust the files in this project's folder before it starts. Nothing runs until it is answered.",
31322
+ options: [
31323
+ { key: "1", label: "Yes, trust this folder" },
31324
+ { key: "2", label: "No, exit" }
31325
+ ],
31326
+ data: { trust: true, questions: [{ question: "Trust this folder?", options: ["Yes, trust this folder", "No, exit"] }] }
31327
+ });
31328
+ }
31329
+ continue;
31330
+ }
31331
+ if (trustOpen) closeOpen(terminal.sessionId, "terminal", ["question"]);
31332
+ const stalled = open.find((item) => item.data?.stalled);
31333
+ if (stalled && terminal.lastOutputAt > Date.parse(stalled.createdAt) + 5e3) {
31334
+ closeOpen(terminal.sessionId, "terminal", ["turn"]);
31335
+ setSessionStatus(terminal.sessionId, "running");
31336
+ continue;
31337
+ }
31338
+ const quietFor = Date.now() - terminal.lastOutputAt;
31339
+ if (quietFor < STALL_MS || open.length || reported.get(terminal.id) === terminal.lastOutputAt) continue;
31340
+ if (!getSessionRow(terminal.sessionId)?.run_id) continue;
31341
+ reported.set(terminal.id, terminal.lastOutputAt);
31342
+ createInteraction({
31343
+ sessionId: terminal.sessionId,
31344
+ kind: "turn",
31345
+ title: "This run looks stuck",
31346
+ body: `Nothing has changed on its screen for ${Math.round(quietFor / 6e4)} minutes. It may be waiting on a dialog, or it stopped without reporting done. Reply to type into it, or stop it.
31347
+
31348
+ ${lastLines(screen)}`,
31349
+ data: { stalled: true }
31350
+ });
31351
+ }
31352
+ for (const id of reported.keys()) if (!allTerminals().some((terminal) => terminal.id === id)) reported.delete(id);
31353
+ }
31354
+ function startWatchdog(intervalMs = 3e3) {
31355
+ const timer = setInterval(bindProject(() => {
31356
+ try {
31357
+ check2();
31358
+ } catch (error) {
31359
+ console.error("[watchdog]", error.message);
31360
+ }
31361
+ }), intervalMs);
31362
+ return () => clearInterval(timer);
31363
+ }
31364
+ var TRUST_MARKERS, STALL_MS, SCREEN_TAIL, ANSI, squeeze, showsTrustDialog, sleep2, reported;
31365
+ var init_watchdog = __esm({
31366
+ "server/src/watchdog.ts"() {
31367
+ "use strict";
31368
+ init_context();
31369
+ init_interactions();
31370
+ init_sessions();
31371
+ init_terminals();
31372
+ TRUST_MARKERS = ["doyoutrustthefilesinthisfolder", "isthisaprojectyoucreatedoroneyoutrust", "yes,itrustthisfolder"];
31373
+ STALL_MS = Number(process.env.CLAUDER_HUB_STALL_MS) || 8 * 6e4;
31374
+ SCREEN_TAIL = 8e3;
31375
+ ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Za-z0-9]|\x1b[=>78]/g;
31376
+ squeeze = (text) => text.toLowerCase().replace(/\s+/g, "");
31377
+ showsTrustDialog = (screen) => {
31378
+ const flat2 = squeeze(screen.slice(-3e3));
31379
+ if (!TRUST_MARKERS.some((marker) => flat2.includes(marker))) return false;
31380
+ const end = Math.max(flat2.lastIndexOf("entertoconfirm"), flat2.lastIndexOf("itrustthisfolder"), flat2.lastIndexOf("no,exit"));
31381
+ return end >= 0 && flat2.length - end < 120;
31382
+ };
31383
+ sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
31384
+ reported = /* @__PURE__ */ new Map();
31385
+ }
31386
+ });
31387
+
31174
31388
  // server/src/mcpSuggestions.ts
31175
31389
  var mcpSuggestions_exports = {};
31176
31390
  __export(mcpSuggestions_exports, {
@@ -31395,6 +31609,11 @@ function closeOpen(sessionId, by, kinds) {
31395
31609
  }
31396
31610
  async function answerQuestion(interaction, answer) {
31397
31611
  const terminal = terminalForSession(interaction.sessionId);
31612
+ if (interaction.data.trust) {
31613
+ const { answerTrust: answerTrust2 } = await Promise.resolve().then(() => (init_watchdog(), watchdog_exports));
31614
+ await answerTrust2(terminal, answer.trim() !== "2");
31615
+ return;
31616
+ }
31398
31617
  const questions = interaction.data.questions ?? [];
31399
31618
  const split = answer.includes("\n") ? answer.split("\n") : answer.split(/[;,]/);
31400
31619
  const parts = questions.length > 1 ? split.map((part) => part.trim()) : [answer.trim()];
@@ -31596,7 +31815,7 @@ function bootGlobal() {
31596
31815
  };
31597
31816
  const stops = withProject(project, () => {
31598
31817
  recoverRuns();
31599
- const list = [startTranscriptWatcher(), startInteractionSweeper(), stopChannels];
31818
+ const list = [startTranscriptWatcher(), startInteractionSweeper(), startWatchdog(), stopChannels];
31600
31819
  startChannels({ device: false });
31601
31820
  return list;
31602
31821
  });
@@ -31654,7 +31873,7 @@ function boot(row) {
31654
31873
  withProject(project, () => {
31655
31874
  const stranded = recoverRuns();
31656
31875
  if (stranded) console.log(` note : ${row.name}: ${stranded} queued run(s) were running when the hub last stopped`);
31657
- stops.push(startTranscriptWatcher(), startInteractionSweeper());
31876
+ stops.push(startTranscriptWatcher(), startInteractionSweeper(), startWatchdog());
31658
31877
  stops.push(stopChannels);
31659
31878
  startChannels();
31660
31879
  stops.push(stopScheduler);
@@ -31735,6 +31954,7 @@ var init_projects = __esm({
31735
31954
  init_db();
31736
31955
  init_events();
31737
31956
  init_interactions();
31957
+ init_watchdog();
31738
31958
  init_jobs();
31739
31959
  init_paths();
31740
31960
  init_terminals();
@@ -33148,11 +33368,32 @@ function enqueue(input) {
33148
33368
  if (input.job) db.prepare("UPDATE jobs SET last_run_at = ? WHERE id = ?").run(now(), input.job.id);
33149
33369
  broadcast("runs", {});
33150
33370
  broadcast("jobs", {});
33151
- const position = db.prepare("SELECT COUNT(*) AS n FROM runs WHERE status = 'queued' AND created_at <= (SELECT created_at FROM runs WHERE id = ?)").get(id).n;
33371
+ const position = queuePosition(id);
33152
33372
  advance();
33153
33373
  const run4 = listRuns(500).find((item) => item.id === id);
33154
33374
  return { run: run4, position };
33155
33375
  }
33376
+ function queuePosition(id) {
33377
+ return db.prepare(`SELECT COUNT(*) AS n FROM runs WHERE status = 'queued' AND ${IN_LINE} <= (SELECT ${IN_LINE} FROM runs WHERE id = ?)`).get(id).n;
33378
+ }
33379
+ function stopRun(id) {
33380
+ const run4 = listRuns(500).find((item) => item.id === id);
33381
+ if (!run4 || run4.status !== "running" || !run4.sessionId) throw new Error("That run is not running.");
33382
+ const terminal = terminalForSession(run4.sessionId);
33383
+ if (!terminal) throw new Error("That run has no running terminal.");
33384
+ killTerminal(terminal);
33385
+ }
33386
+ function resumeRun(id) {
33387
+ const run4 = listRuns(500).find((item) => item.id === id);
33388
+ if (!run4?.sessionId) throw new Error("That run never started, so there is nothing to resume.");
33389
+ if (run4.status === "queued" || run4.status === "running") throw new Error("That run is already queued or running.");
33390
+ if (terminalForSession(run4.sessionId)) throw new Error("Its session is already open.");
33391
+ db.prepare("UPDATE runs SET status = 'queued', resume = 1, queued_at = ?, finished_at = NULL WHERE id = ?").run(now(), id);
33392
+ broadcast("runs", {});
33393
+ const position = queuePosition(id);
33394
+ advance();
33395
+ return { run: listRuns(500).find((item) => item.id === id), position };
33396
+ }
33156
33397
  function cancelRun(id) {
33157
33398
  db.prepare("UPDATE runs SET status = 'cancelled', finished_at = ? WHERE id = ? AND status = 'queued'").run(now(), id);
33158
33399
  broadcast("runs", {});
@@ -33177,11 +33418,11 @@ function advance() {
33177
33418
  try {
33178
33419
  const running = db.prepare("SELECT id FROM runs WHERE status = 'running' LIMIT 1").get();
33179
33420
  if (running) return;
33180
- const next = db.prepare("SELECT * FROM runs WHERE status = 'queued' ORDER BY created_at LIMIT 1").get();
33421
+ const next = db.prepare(`SELECT * FROM runs WHERE status = 'queued' ORDER BY ${IN_LINE} LIMIT 1`).get();
33181
33422
  if (!next) return;
33182
33423
  const job = next.job_id ? getJob(next.job_id) : null;
33183
33424
  try {
33184
- const terminal = startSession({
33425
+ const terminal = next.resume && next.session_id ? startSession({ resumeId: next.session_id, prompt: promptText("typed.resumeRun"), runId: next.id, permissionMode: claudeSettings().runPermissionMode }) : startSession({
33185
33426
  prompt: next.prompt,
33186
33427
  origin: next.source === "manual" ? "queue" : next.source,
33187
33428
  runId: next.id,
@@ -33236,7 +33477,7 @@ function recoverRuns() {
33236
33477
  db.prepare("UPDATE interactions SET status = 'closed', answered_by = 'hub restart' WHERE status = 'open'").run();
33237
33478
  return stranded;
33238
33479
  }
33239
- var crons, cronKey, toJob, toRun, advancing, waitingForClaude;
33480
+ var crons, cronKey, toJob, toRun, IN_LINE, advancing, waitingForClaude;
33240
33481
  var init_jobs = __esm({
33241
33482
  "server/src/jobs.ts"() {
33242
33483
  "use strict";
@@ -33276,10 +33517,12 @@ var init_jobs = __esm({
33276
33517
  category: row.category,
33277
33518
  status: row.status,
33278
33519
  sessionId: row.session_id,
33520
+ resume: Boolean(row.resume),
33279
33521
  createdAt: row.created_at,
33280
33522
  startedAt: row.started_at,
33281
33523
  finishedAt: row.finished_at
33282
33524
  });
33525
+ IN_LINE = "COALESCE(queued_at, created_at)";
33283
33526
  advancing = false;
33284
33527
  waitingForClaude = /* @__PURE__ */ new Set();
33285
33528
  }
@@ -34349,6 +34592,8 @@ api.post(
34349
34592
  })
34350
34593
  );
34351
34594
  api.get("/runs", wrap2(() => listRuns()));
34595
+ api.post("/runs/:id/stop", wrap2((req) => stopRun(req.params.id)));
34596
+ api.post("/runs/:id/resume", wrap2((req) => resumeRun(req.params.id)));
34352
34597
  api.post("/runs", wrap2((req) => enqueue({ source: "manual", prompt: String(req.body?.prompt ?? ""), category: req.body?.category || "auto" })));
34353
34598
  api.post("/runs/:id/cancel", wrap2((req) => cancelRun(req.params.id)));
34354
34599
  api.get("/mcp/suggestions", wrap2(() => listSuggestions()));