clauder-hub 0.1.2 → 0.1.4

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 +139 -9
  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.4",
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();
@@ -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
  }
@@ -30365,6 +30393,23 @@ function queueFromChannel(prompt) {
30365
30393
  const { position } = enqueue({ source: "manual", prompt, category: "auto" });
30366
30394
  return { title: "\u{1F5C2} Run queued", lines: [renderPrompt("channel.queued", { position: String(position) })], buttons: [[{ label: "\u{1F5C2} Queue", command: "queue" }]] };
30367
30395
  }
30396
+ function findJob(arg) {
30397
+ const wanted = arg.trim().toLowerCase();
30398
+ if (!wanted) return null;
30399
+ const jobs = listJobs();
30400
+ const exact = getJob(arg.trim()) ?? jobs.find((job) => job.name.toLowerCase() === wanted);
30401
+ if (exact) return exact;
30402
+ const matches = jobs.filter((job) => job.name.toLowerCase().includes(wanted));
30403
+ return matches.length === 1 ? matches[0] : null;
30404
+ }
30405
+ function jobPicker(title, jobs, empty) {
30406
+ if (!jobs.length) return empty;
30407
+ return {
30408
+ title,
30409
+ 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"}`),
30410
+ buttons: jobs.slice(0, 15).map((job, index) => [{ label: short(`\u25B6\uFE0F ${index + 1}. ${job.name}`, 60), command: `job ${job.id}` }])
30411
+ };
30412
+ }
30368
30413
  function queueCard() {
30369
30414
  const runs = listRuns(50).filter((run4) => run4.status === "queued" || run4.status === "running");
30370
30415
  if (!runs.length) return "The queue is empty.";
@@ -30450,6 +30495,58 @@ async function runCommand(channelId, input, from, options = {}) {
30450
30495
  if (arg) return name === "new" ? startFromChannel(channelId, arg, from, options) : queueFromChannel(arg);
30451
30496
  awaiting.set(key, name);
30452
30497
  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" }]] };
30498
+ case "jobs":
30499
+ return jobPicker("\u2699\uFE0F Jobs: pick one to run", listJobs(), "No jobs yet. Create one on the Automation page.");
30500
+ case "job": {
30501
+ if (!arg) return jobPicker("\u2699\uFE0F Run which job?", listJobs(), "No jobs yet. Create one on the Automation page.");
30502
+ const job = findJob(arg);
30503
+ if (!job) {
30504
+ const like = listJobs().filter((item) => item.name.toLowerCase().includes(arg.toLowerCase()));
30505
+ return jobPicker(`\u{1F914} Which job did you mean?`, like.length ? like : listJobs(), `No job like "${short(arg, 60)}". ${p2}jobs lists them.`);
30506
+ }
30507
+ return {
30508
+ title: "\u2699\uFE0F Run the job?",
30509
+ lines: [renderPrompt("channel.jobConfirm", { job: job.name, prompt: short(flat(job.prompt), 300) })],
30510
+ buttons: [
30511
+ [
30512
+ { label: "Yes, run it", command: `job! ${job.id}`, tone: "primary" },
30513
+ { label: "No", command: "cancel" }
30514
+ ]
30515
+ ]
30516
+ };
30517
+ }
30518
+ case "job!": {
30519
+ const job = getJob(arg);
30520
+ if (!job) return `That job is gone. ${p2}jobs lists them.`;
30521
+ const { position } = enqueue({ job, source: "manual" });
30522
+ return { title: `\u{1F5C2} ${job.name} queued`, lines: [renderPrompt("channel.queued", { position: String(position) })], buttons: [[{ label: "\u{1F5C2} Queue", command: "queue" }]] };
30523
+ }
30524
+ case "resume": {
30525
+ const runs = resumable();
30526
+ const run4 = arg ? runs.find((item) => item.id === arg) ?? runs[Number(arg) - 1] : void 0;
30527
+ if (!run4) {
30528
+ if (!runs.length) return "No stopped run to resume.";
30529
+ return {
30530
+ title: "\u23EF Resume which run?",
30531
+ lines: runs.map((item, index) => `${index + 1}. ${short(flat(item.jobName ?? item.prompt), 80)} \xB7 stopped ${item.finishedAt ? new Date(item.finishedAt).toLocaleString() : ""}`),
30532
+ buttons: runs.map((item, index) => [{ label: short(`\u23EF ${index + 1}. ${flat(item.jobName ?? item.prompt)}`, 60), command: `resume ${item.id}` }])
30533
+ };
30534
+ }
30535
+ return {
30536
+ title: "\u23EF Resume the run?",
30537
+ lines: [renderPrompt("channel.resumeConfirm", { run: flat(run4.jobName ?? short(run4.prompt, 80)) })],
30538
+ buttons: [
30539
+ [
30540
+ { label: "Yes, resume it", command: `resume! ${run4.id}`, tone: "primary" },
30541
+ { label: "No", command: "cancel" }
30542
+ ]
30543
+ ]
30544
+ };
30545
+ }
30546
+ case "resume!": {
30547
+ const { run: run4, position } = resumeRun(arg);
30548
+ 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" }]] };
30549
+ }
30453
30550
  case "cancel":
30454
30551
  suggested.delete(suggestKey(channelId, from));
30455
30552
  return focused.delete(key) ? "Cancelled. Plain messages go nowhere until you pick a session." : "Cancelled.";
@@ -30509,7 +30606,7 @@ async function runCommand(channelId, input, from, options = {}) {
30509
30606
  return await interpret(channelId, from, input, options) ?? `I don't know ${p2}${name}. ${p2}help lists what I can do.`;
30510
30607
  }
30511
30608
  }
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;
30609
+ 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
30610
  var init_commands = __esm({
30514
30611
  "server/src/channels/commands.ts"() {
30515
30612
  "use strict";
@@ -30527,16 +30624,20 @@ var init_commands = __esm({
30527
30624
  FOCUS_IDLE_MS = 60 * 6e4;
30528
30625
  numbered = /* @__PURE__ */ new Map();
30529
30626
  keyOf = (channelId) => `${currentProject().id}:${channelId}`;
30530
- HUB_COMMANDS = /* @__PURE__ */ new Set(["start", "help", "new", "run", "cancel", "leave", "sessions", "session", "summary", "stop", "stop!", "queue", "cancelrun"]);
30627
+ HUB_COMMANDS = /* @__PURE__ */ new Set(["start", "help", "new", "run", "jobs", "job", "job!", "resume", "resume!", "cancel", "leave", "sessions", "session", "summary", "stop", "stop!", "queue", "cancelrun"]);
30531
30628
  short = (text, length) => text.length > length ? `${text.slice(0, length - 1)}\u2026` : text;
30532
30629
  flat = (text) => text.replace(/\s+/g, " ").trim();
30533
30630
  STATUS = { waiting: "\u23F3 waiting for you", running: "\u25B6\uFE0F running", done: "\u2705 done", exited: "\u23F9 stopped", external: "outside the hub" };
30631
+ resumable = () => listRuns(50).filter((run4) => run4.sessionId && run4.status === "exited").slice(0, 10);
30534
30632
  suggested = /* @__PURE__ */ new Map();
30535
30633
  suggestKey = (channelId, from) => `${keyOf(channelId)}:${from}`;
30536
30634
  sessionLine = (session, index) => `${index + 1}. ${session.title} \xB7 ${STATUS[session.status] ?? session.status}${session.firstPrompt ? ` \xB7 asked: ${short(flat(session.firstPrompt), 120)}` : ""}`;
30537
30635
  COMMAND_GUIDE = [
30538
30636
  "new <what the session should do> - start a session",
30539
30637
  "run <what to do> - queue an unattended run",
30638
+ "jobs - list the saved jobs, to run one",
30639
+ "job <name> - run a saved job now (asks first)",
30640
+ "resume - carry on a stopped run (asks first)",
30540
30641
  "sessions - list the sessions",
30541
30642
  "session <number> - pick a session, to talk to it",
30542
30643
  "summary <number> - what a session did",
@@ -30544,13 +30645,15 @@ var init_commands = __esm({
30544
30645
  "queue - the run queue",
30545
30646
  "help - the commands"
30546
30647
  ].join("\n");
30547
- GUESSABLE = /* @__PURE__ */ new Set(["new", "run", "sessions", "session", "summary", "stop", "queue", "help"]);
30648
+ GUESSABLE = /* @__PURE__ */ new Set(["new", "run", "jobs", "job", "resume", "sessions", "session", "summary", "stop", "queue", "help"]);
30548
30649
  GLOBAL_COMMAND_MENU = [
30549
30650
  { command: "projects", description: "Pick a project" },
30550
30651
  { command: "use", description: "Work on a project: /use <name>" },
30551
30652
  { command: "addproject", description: "Add a project: describe it, I find it" },
30552
30653
  { command: "new", description: "Start a session in the picked project" },
30553
30654
  { command: "run", description: "Queue a run in the picked project" },
30655
+ { command: "jobs", description: "The picked project's jobs, to run one" },
30656
+ { command: "resume", description: "Carry on a stopped run in the picked project" },
30554
30657
  { command: "sessions", description: "The picked project's sessions" },
30555
30658
  { command: "summary", description: "What a session did" },
30556
30659
  { command: "stop", description: "Stop a running session" },
@@ -30561,6 +30664,8 @@ var init_commands = __esm({
30561
30664
  COMMAND_MENU = [
30562
30665
  { command: "new", description: "Start a session" },
30563
30666
  { command: "run", description: "Queue an unattended run" },
30667
+ { command: "jobs", description: "Run a saved job" },
30668
+ { command: "resume", description: "Carry on a stopped run" },
30564
30669
  { command: "sessions", description: "Pick a session" },
30565
30670
  { command: "summary", description: "What a session did" },
30566
30671
  { command: "stop", description: "Stop a running session" },
@@ -33148,11 +33253,32 @@ function enqueue(input) {
33148
33253
  if (input.job) db.prepare("UPDATE jobs SET last_run_at = ? WHERE id = ?").run(now(), input.job.id);
33149
33254
  broadcast("runs", {});
33150
33255
  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;
33256
+ const position = queuePosition(id);
33152
33257
  advance();
33153
33258
  const run4 = listRuns(500).find((item) => item.id === id);
33154
33259
  return { run: run4, position };
33155
33260
  }
33261
+ function queuePosition(id) {
33262
+ 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;
33263
+ }
33264
+ function stopRun(id) {
33265
+ const run4 = listRuns(500).find((item) => item.id === id);
33266
+ if (!run4 || run4.status !== "running" || !run4.sessionId) throw new Error("That run is not running.");
33267
+ const terminal = terminalForSession(run4.sessionId);
33268
+ if (!terminal) throw new Error("That run has no running terminal.");
33269
+ killTerminal(terminal);
33270
+ }
33271
+ function resumeRun(id) {
33272
+ const run4 = listRuns(500).find((item) => item.id === id);
33273
+ if (!run4?.sessionId) throw new Error("That run never started, so there is nothing to resume.");
33274
+ if (run4.status === "queued" || run4.status === "running") throw new Error("That run is already queued or running.");
33275
+ if (terminalForSession(run4.sessionId)) throw new Error("Its session is already open.");
33276
+ db.prepare("UPDATE runs SET status = 'queued', resume = 1, queued_at = ?, finished_at = NULL WHERE id = ?").run(now(), id);
33277
+ broadcast("runs", {});
33278
+ const position = queuePosition(id);
33279
+ advance();
33280
+ return { run: listRuns(500).find((item) => item.id === id), position };
33281
+ }
33156
33282
  function cancelRun(id) {
33157
33283
  db.prepare("UPDATE runs SET status = 'cancelled', finished_at = ? WHERE id = ? AND status = 'queued'").run(now(), id);
33158
33284
  broadcast("runs", {});
@@ -33177,11 +33303,11 @@ function advance() {
33177
33303
  try {
33178
33304
  const running = db.prepare("SELECT id FROM runs WHERE status = 'running' LIMIT 1").get();
33179
33305
  if (running) return;
33180
- const next = db.prepare("SELECT * FROM runs WHERE status = 'queued' ORDER BY created_at LIMIT 1").get();
33306
+ const next = db.prepare(`SELECT * FROM runs WHERE status = 'queued' ORDER BY ${IN_LINE} LIMIT 1`).get();
33181
33307
  if (!next) return;
33182
33308
  const job = next.job_id ? getJob(next.job_id) : null;
33183
33309
  try {
33184
- const terminal = startSession({
33310
+ const terminal = next.resume && next.session_id ? startSession({ resumeId: next.session_id, prompt: promptText("typed.resumeRun"), runId: next.id, permissionMode: claudeSettings().runPermissionMode }) : startSession({
33185
33311
  prompt: next.prompt,
33186
33312
  origin: next.source === "manual" ? "queue" : next.source,
33187
33313
  runId: next.id,
@@ -33236,7 +33362,7 @@ function recoverRuns() {
33236
33362
  db.prepare("UPDATE interactions SET status = 'closed', answered_by = 'hub restart' WHERE status = 'open'").run();
33237
33363
  return stranded;
33238
33364
  }
33239
- var crons, cronKey, toJob, toRun, advancing, waitingForClaude;
33365
+ var crons, cronKey, toJob, toRun, IN_LINE, advancing, waitingForClaude;
33240
33366
  var init_jobs = __esm({
33241
33367
  "server/src/jobs.ts"() {
33242
33368
  "use strict";
@@ -33276,10 +33402,12 @@ var init_jobs = __esm({
33276
33402
  category: row.category,
33277
33403
  status: row.status,
33278
33404
  sessionId: row.session_id,
33405
+ resume: Boolean(row.resume),
33279
33406
  createdAt: row.created_at,
33280
33407
  startedAt: row.started_at,
33281
33408
  finishedAt: row.finished_at
33282
33409
  });
33410
+ IN_LINE = "COALESCE(queued_at, created_at)";
33283
33411
  advancing = false;
33284
33412
  waitingForClaude = /* @__PURE__ */ new Set();
33285
33413
  }
@@ -34349,6 +34477,8 @@ api.post(
34349
34477
  })
34350
34478
  );
34351
34479
  api.get("/runs", wrap2(() => listRuns()));
34480
+ api.post("/runs/:id/stop", wrap2((req) => stopRun(req.params.id)));
34481
+ api.post("/runs/:id/resume", wrap2((req) => resumeRun(req.params.id)));
34352
34482
  api.post("/runs", wrap2((req) => enqueue({ source: "manual", prompt: String(req.body?.prompt ?? ""), category: req.body?.category || "auto" })));
34353
34483
  api.post("/runs/:id/cancel", wrap2((req) => cancelRun(req.params.id)));
34354
34484
  api.get("/mcp/suggestions", wrap2(() => listSuggestions()));