svamp-cli 0.2.246 → 0.2.248

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/dist/{agentCommands-wOdV4Do1.mjs → agentCommands-B_ozIVKT.mjs} +28 -11
  2. package/dist/{auth-Bg0Z_bP-.mjs → auth-BLDmT7XN.mjs} +2 -1
  3. package/dist/cli.mjs +83 -68
  4. package/dist/{commands-BhLGBhBS.mjs → commands-C3TQOYUo.mjs} +2 -1
  5. package/dist/{commands-Z8Ki8iA9.mjs → commands-CL8YI8k5.mjs} +2 -1
  6. package/dist/{commands-Bi_LZXPe.mjs → commands-CRMQUQZG.mjs} +2 -1
  7. package/dist/{commands-CudvGpEz.mjs → commands-Cs2fNd-_.mjs} +7 -7
  8. package/dist/{commands-CniwP5Tm.mjs → commands-DS4i8ohA.mjs} +10 -6
  9. package/dist/{commands-CkduYG4a.mjs → commands-Djvi1cZ2.mjs} +3 -2
  10. package/dist/{commands-D9khoYtI.mjs → commands-DwhA-iV3.mjs} +3 -2
  11. package/dist/{fleet-DCMPBC7b.mjs → fleet-CJZOVjOY.mjs} +13 -4
  12. package/dist/{frpc-nvGz8fWs.mjs → frpc-CsjEXBuS.mjs} +2 -1
  13. package/dist/{headlessCli-d6XjdG6n.mjs → headlessCli-qBontdlf.mjs} +3 -2
  14. package/dist/index.mjs +2 -1
  15. package/dist/{package-CUTX-CB-.mjs → package-B7z6GsNX.mjs} +2 -2
  16. package/dist/{rpc-CDI7i151.mjs → rpc-BGFgegj5.mjs} +1 -3
  17. package/dist/{rpc-s_Hibfpl.mjs → rpc-C1DI7HfZ.mjs} +2 -1
  18. package/dist/{run-DRR6psuh.mjs → run-C61rsl7r.mjs} +2 -1
  19. package/dist/{run-BPZ_1RCI.mjs → run-CHIppB2r.mjs} +739 -35
  20. package/dist/{scheduler-E1Bw4HUY.mjs → scheduler-Csp21zzg.mjs} +1 -3
  21. package/dist/{serveCommands-Af_7qqzA.mjs → serveCommands-BYHk72ZO.mjs} +5 -5
  22. package/dist/{serveManager-DGRpRAFM.mjs → serveManager-Di3xrvG2.mjs} +3 -2
  23. package/dist/{sideband-C-0cCNeL.mjs → sideband-Cs-kNLye.mjs} +2 -1
  24. package/package.json +2 -2
  25. package/dist/runStore-DKC_bMSi.mjs +0 -239
  26. package/dist/store-BTs0H_y0.mjs +0 -148
@@ -19,6 +19,7 @@ import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
19
19
  import { z } from 'zod';
20
20
  import { mkdir, rm, chmod, access, mkdtemp, copyFile, writeFile, readdir, stat, readFile as readFile$1 } from 'node:fs/promises';
21
21
  import { promisify as promisify$1 } from 'node:util';
22
+ import { parse, stringify } from 'yaml';
22
23
 
23
24
  let connectToServerFn = null;
24
25
  async function getConnectToServer() {
@@ -2984,6 +2985,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
2984
2985
  if (!client) throw new Error(`Tunnel '${params.name}' not found`);
2985
2986
  client.destroy();
2986
2987
  tunnels.delete(params.name);
2988
+ handlers.forgetExposedTunnel?.(params.name);
2987
2989
  return { name: params.name, stopped: true };
2988
2990
  },
2989
2991
  /**
@@ -2996,7 +2998,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
2996
2998
  const tunnels = handlers.tunnels;
2997
2999
  if (!tunnels) throw new Error("Tunnel management not available");
2998
3000
  if (tunnels.has(params.name)) throw new Error(`Tunnel '${params.name}' already running`);
2999
- const { FrpcTunnel } = await import('./frpc-nvGz8fWs.mjs');
3001
+ const { FrpcTunnel } = await import('./frpc-CsjEXBuS.mjs');
3000
3002
  const tunnel = new FrpcTunnel({
3001
3003
  name: params.name,
3002
3004
  ports: params.ports,
@@ -3447,7 +3449,7 @@ QUESTION: ${params.question || "Summarize this concisely."}` }
3447
3449
  }
3448
3450
  const deps = buildSessionDeps(rpc, { cwd, ownerEmail: owner });
3449
3451
  const sender = { name: context?.user?.email || context?.user?.id || "user", kind: "user", verified: true };
3450
- const { toolsForRole } = await import('./sideband-C-0cCNeL.mjs');
3452
+ const { toolsForRole } = await import('./sideband-Cs-kNLye.mjs');
3451
3453
  const r2 = await runWiseAgent({ message: params.message, sender, config: { tools: toolsForRole(role2) }, deps, transport, model: resolved.model });
3452
3454
  return fmt(r2);
3453
3455
  }
@@ -3546,7 +3548,7 @@ QUESTION: ${params.question || "Summarize this concisely."}` }
3546
3548
  if (r.error || !r.sender) return { error: r.error || "unauthorized" };
3547
3549
  const callId = "call_" + Math.random().toString(16).slice(2, 12);
3548
3550
  const rendered = renderMessage(c, { sender: r.sender, body: { message: kwargs.message }, callId });
3549
- const { queryCore } = await import('./commands-BhLGBhBS.mjs');
3551
+ const { queryCore } = await import('./commands-C3TQOYUo.mjs');
3550
3552
  const timeout = c.reply?.timeout_sec || 120;
3551
3553
  let result;
3552
3554
  try {
@@ -10920,6 +10922,438 @@ function summarize(issues) {
10920
10922
  return { total: issues.length, ready: by("ready"), in_progress: by("in_progress"), archived: by("archived") };
10921
10923
  }
10922
10924
 
10925
+ function isWorkflowEnabled(wf) {
10926
+ return wf.enabled !== false;
10927
+ }
10928
+ function workflowSteps(wf) {
10929
+ return Object.values(wf.jobs || {}).flatMap((j) => j?.steps || []);
10930
+ }
10931
+ function workflowCrons(wf) {
10932
+ return (wf.on?.schedule || []).map((s) => s?.cron).filter((c) => !!c);
10933
+ }
10934
+ function workflowsDir(projectRoot) {
10935
+ return join(projectRoot, ".svamp", "workflows");
10936
+ }
10937
+ function workflowPath(projectRoot, name) {
10938
+ return join(workflowsDir(projectRoot), `${name}.yaml`);
10939
+ }
10940
+ function normalizeOn(on) {
10941
+ if (!on || typeof on !== "object") {
10942
+ return void 0;
10943
+ }
10944
+ const out = {};
10945
+ if (Array.isArray(on.schedule)) {
10946
+ const sched = on.schedule.map((s) => ({ cron: String(s?.cron ?? s ?? "") })).filter((s) => s.cron);
10947
+ if (sched.length) out.schedule = sched;
10948
+ } else if (typeof on.schedule === "string" && on.schedule.trim()) {
10949
+ out.schedule = [{ cron: on.schedule.trim() }];
10950
+ }
10951
+ if ("workflow_dispatch" in on || on.dispatch === true) out.workflow_dispatch = true;
10952
+ if (typeof on.channel === "string" && on.channel) out.channel = on.channel;
10953
+ if (Array.isArray(on.issue) && on.issue.length) out.issue = on.issue.map(String);
10954
+ if ("idle" in on && on.idle !== false || on.idle === true) out.idle = true;
10955
+ return Object.keys(out).length ? out : void 0;
10956
+ }
10957
+ function normalizeJobs(jobs) {
10958
+ if (Array.isArray(jobs)) {
10959
+ const steps = jobs.map((j) => ({ run: String(j?.run ?? "") })).filter((s) => s.run);
10960
+ return steps.length ? { run: { steps } } : {};
10961
+ }
10962
+ if (jobs && typeof jobs === "object") {
10963
+ const out = {};
10964
+ for (const [id, job] of Object.entries(jobs)) {
10965
+ const rawSteps = Array.isArray(job?.steps) ? job.steps : job?.run ? [{ run: job.run }] : [];
10966
+ const steps = rawSteps.map((s) => ({ run: String(s?.run ?? ""), ...s?.name ? { name: String(s.name) } : {} })).filter((s) => s.run);
10967
+ if (steps.length) out[String(id)] = { steps };
10968
+ }
10969
+ return out;
10970
+ }
10971
+ return {};
10972
+ }
10973
+ function parseWorkflow(content) {
10974
+ try {
10975
+ const o = parse(content);
10976
+ if (!o || typeof o !== "object" || !o.name) return null;
10977
+ return {
10978
+ name: String(o.name),
10979
+ on: normalizeOn(o.on),
10980
+ jobs: normalizeJobs(o.jobs),
10981
+ session: o.session ?? null,
10982
+ // Only `enabled: false` disables; anything else (absent/true) is active.
10983
+ ...o.enabled === false ? { enabled: false } : {}
10984
+ };
10985
+ } catch {
10986
+ return null;
10987
+ }
10988
+ }
10989
+ function serializeWorkflow(wf) {
10990
+ const clean = { name: wf.name };
10991
+ if (wf.session) clean.session = wf.session;
10992
+ if (wf.enabled === false) clean.enabled = false;
10993
+ const on = {};
10994
+ if (wf.on?.schedule?.length) on.schedule = wf.on.schedule.map((s) => ({ cron: s.cron }));
10995
+ if (wf.on?.workflow_dispatch) on.workflow_dispatch = {};
10996
+ if (wf.on?.channel) on.channel = wf.on.channel;
10997
+ if (wf.on?.issue?.length) on.issue = wf.on.issue;
10998
+ if (wf.on?.idle) on.idle = {};
10999
+ if (Object.keys(on).length) clean.on = on;
11000
+ const jobs = {};
11001
+ for (const [id, job] of Object.entries(wf.jobs || {})) {
11002
+ jobs[id] = { steps: (job.steps || []).map((s) => s.name ? { name: s.name, run: s.run } : { run: s.run }) };
11003
+ }
11004
+ clean.jobs = Object.keys(jobs).length ? jobs : { run: { steps: [] } };
11005
+ return stringify(clean);
11006
+ }
11007
+ function listWorkflows(projectRoot) {
11008
+ const dir = workflowsDir(projectRoot);
11009
+ if (!existsSync(dir)) return [];
11010
+ const out = [];
11011
+ for (const name of readdirSync(dir)) {
11012
+ if (!name.endsWith(".yaml") && !name.endsWith(".yml")) continue;
11013
+ const p = join(dir, name);
11014
+ try {
11015
+ const wf = parseWorkflow(readFileSync(p, "utf-8"));
11016
+ if (wf) {
11017
+ try {
11018
+ const st = statSync(p);
11019
+ wf.created = new Date(st.birthtimeMs || st.mtimeMs).toISOString();
11020
+ } catch {
11021
+ }
11022
+ out.push(wf);
11023
+ }
11024
+ } catch {
11025
+ }
11026
+ }
11027
+ return out.sort((a, b) => a.name.localeCompare(b.name));
11028
+ }
11029
+ function getWorkflow(projectRoot, name) {
11030
+ const p = workflowPath(projectRoot, name);
11031
+ if (!existsSync(p)) return null;
11032
+ try {
11033
+ return parseWorkflow(readFileSync(p, "utf-8"));
11034
+ } catch {
11035
+ return null;
11036
+ }
11037
+ }
11038
+ function rawWorkflow(projectRoot, name) {
11039
+ const p = workflowPath(projectRoot, name);
11040
+ return existsSync(p) ? readFileSync(p, "utf-8") : null;
11041
+ }
11042
+ function saveWorkflow(projectRoot, wf) {
11043
+ const dir = workflowsDir(projectRoot);
11044
+ mkdirSync(dir, { recursive: true });
11045
+ const path = workflowPath(projectRoot, wf.name);
11046
+ const tmp = `${path}.tmp-${process.pid}`;
11047
+ writeFileSync(tmp, serializeWorkflow(wf));
11048
+ renameSync(tmp, path);
11049
+ }
11050
+ function setWorkflowEnabled(projectRoot, name, enabled) {
11051
+ const wf = getWorkflow(projectRoot, name);
11052
+ if (!wf) return null;
11053
+ const next = { ...wf };
11054
+ if (enabled) delete next.enabled;
11055
+ else next.enabled = false;
11056
+ saveWorkflow(projectRoot, next);
11057
+ return next;
11058
+ }
11059
+ function removeWorkflow(projectRoot, name) {
11060
+ const p = workflowPath(projectRoot, name);
11061
+ if (!existsSync(p)) return false;
11062
+ try {
11063
+ unlinkSync(p);
11064
+ return true;
11065
+ } catch {
11066
+ return false;
11067
+ }
11068
+ }
11069
+
11070
+ const MAX_RUNS_PER_WORKFLOW = 50;
11071
+ function maxRunsPerWorkflow() {
11072
+ const raw = Number(process.env.SVAMP_MAX_RUNS_PER_WORKFLOW);
11073
+ if (Number.isFinite(raw) && raw > 0) return Math.min(Math.floor(raw), 5e3);
11074
+ return MAX_RUNS_PER_WORKFLOW;
11075
+ }
11076
+ const MAX_STREAM_CHARS = 8e3;
11077
+ const STEP_TIMEOUT_MS = 12e4;
11078
+ function capStream(s, max = MAX_STREAM_CHARS) {
11079
+ if (s.length <= max) return s;
11080
+ const head = Math.floor(max * 0.6);
11081
+ const tail = max - head;
11082
+ const omitted = s.length - max;
11083
+ return `${s.slice(0, head)}
11084
+ \u2026 [${omitted} chars omitted] \u2026
11085
+ ${s.slice(s.length - tail)}`;
11086
+ }
11087
+ function runsDir(projectRoot) {
11088
+ return join(workflowsDir(projectRoot), ".runs");
11089
+ }
11090
+ function runsFile(projectRoot, workflow) {
11091
+ const safe = workflow.replace(/[^\w.-]+/g, "_");
11092
+ return join(runsDir(projectRoot), `${safe}.jsonl`);
11093
+ }
11094
+ function shortRunId(seed) {
11095
+ let h = 2166136261;
11096
+ for (let i = 0; i < seed.length; i++) {
11097
+ h ^= seed.charCodeAt(i);
11098
+ h = Math.imul(h, 16777619);
11099
+ }
11100
+ return (h >>> 0).toString(36).padStart(7, "0").slice(0, 7);
11101
+ }
11102
+ function recordRun(projectRoot, run) {
11103
+ try {
11104
+ const dir = runsDir(projectRoot);
11105
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
11106
+ const file = runsFile(projectRoot, run.workflow);
11107
+ const existing = loadRuns(projectRoot, run.workflow);
11108
+ const idx = existing.findIndex((r) => r.id === run.id);
11109
+ if (idx >= 0) existing[idx] = run;
11110
+ else existing.push(run);
11111
+ const kept = existing.slice(-maxRunsPerWorkflow());
11112
+ const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
11113
+ writeFileSync(tmp, kept.map((r) => JSON.stringify(r)).join("\n") + "\n");
11114
+ renameSync(tmp, file);
11115
+ } catch {
11116
+ }
11117
+ }
11118
+ function loadRuns(projectRoot, workflow) {
11119
+ try {
11120
+ const file = runsFile(projectRoot, workflow);
11121
+ if (!existsSync(file)) return [];
11122
+ return readFileSync(file, "utf-8").split("\n").filter(Boolean).map((l) => {
11123
+ try {
11124
+ return JSON.parse(l);
11125
+ } catch {
11126
+ return null;
11127
+ }
11128
+ }).filter((r) => !!r);
11129
+ } catch {
11130
+ return [];
11131
+ }
11132
+ }
11133
+ function listRuns(projectRoot, workflow, opts = {}) {
11134
+ const o = typeof opts === "number" ? { limit: opts } : opts;
11135
+ const all = loadRuns(projectRoot, workflow);
11136
+ const total = all.length;
11137
+ const newestFirst = all.slice().reverse();
11138
+ const offset = Math.max(0, Math.floor(o.offset ?? 0));
11139
+ const limit = o.limit != null && o.limit > 0 ? Math.floor(o.limit) : total;
11140
+ const page = newestFirst.slice(offset, offset + limit).map((r) => {
11141
+ const failedStep = r.steps.findIndex((s) => s.exitCode !== 0 && s.exitCode !== null || s.timedOut);
11142
+ const { steps, ...rest } = r;
11143
+ return { ...rest, stepCount: steps.length, failedStep: failedStep >= 0 ? failedStep : void 0 };
11144
+ });
11145
+ return { runs: page, total, offset, hasMore: offset + page.length < total };
11146
+ }
11147
+ function getRun(projectRoot, workflow, runId) {
11148
+ return loadRuns(projectRoot, workflow).find((r) => r.id === runId) || null;
11149
+ }
11150
+ function defaultExecStep(root, cmd, env) {
11151
+ return new Promise((resolve) => {
11152
+ let stdout = "", stderr = "", timedOut = false, settled = false;
11153
+ const finish = (exitCode) => {
11154
+ if (settled) return;
11155
+ settled = true;
11156
+ resolve({ exitCode, stdout, stderr, timedOut });
11157
+ };
11158
+ try {
11159
+ const child = spawn("sh", ["-c", cmd], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] });
11160
+ const timer = setTimeout(() => {
11161
+ timedOut = true;
11162
+ try {
11163
+ child.kill("SIGKILL");
11164
+ } catch {
11165
+ }
11166
+ }, STEP_TIMEOUT_MS);
11167
+ child.stdout?.on("data", (d) => {
11168
+ stdout += d.toString();
11169
+ if (stdout.length > MAX_STREAM_CHARS * 4) stdout = capStream(stdout);
11170
+ });
11171
+ child.stderr?.on("data", (d) => {
11172
+ stderr += d.toString();
11173
+ if (stderr.length > MAX_STREAM_CHARS * 4) stderr = capStream(stderr);
11174
+ });
11175
+ child.on("error", (e) => {
11176
+ clearTimeout(timer);
11177
+ stderr += `
11178
+ [spawn error] ${e.message}`;
11179
+ finish(null);
11180
+ });
11181
+ child.on("close", (code) => {
11182
+ clearTimeout(timer);
11183
+ finish(code);
11184
+ });
11185
+ } catch (e) {
11186
+ stderr += `[spawn threw] ${e?.message || e}`;
11187
+ finish(null);
11188
+ }
11189
+ });
11190
+ }
11191
+ function escalateWorkflowFailure(projectRoot, wf, run) {
11192
+ try {
11193
+ if (process.env.SVAMP_WORKFLOW_ESCALATE === "0") return void 0;
11194
+ const failLabel = `wf-fail:${wf.name}`;
11195
+ const open = listIssues(projectRoot, { label: failLabel }).filter((i) => i.status !== "archived");
11196
+ if (open.length > 0) return void 0;
11197
+ const failed = run.steps.find((s) => s.timedOut || s.exitCode !== 0 && s.exitCode !== null) || run.steps[run.steps.length - 1];
11198
+ const failCause = run.error ? `runner error: ${run.error}` : failed?.timedOut ? `step timed out after ${STEP_TIMEOUT_MS / 1e3}s` : `step exited with code ${failed?.exitCode ?? "null (killed/signal)"}`;
11199
+ const defLines = workflowSteps(wf).map((s, i) => ` ${i}. ${s.run}`).join("\n") || " (no steps)";
11200
+ const stderrTail = failed?.stderr ? capStream(failed.stderr, 3e3) : "(none)";
11201
+ const stdoutTail = failed?.stdout ? capStream(failed.stdout, 1500) : "(none)";
11202
+ const body = [
11203
+ `Workflow **${wf.name}** failed on its last run (${run.trigger} trigger${run.triggerContext ? ` \xB7 ${run.triggerContext}` : ""}). Diagnose the failure and **correct the workflow definition** so future runs succeed \u2014 edit it with \`svamp workflow\` (see \`svamp workflow --help\`), or pause/remove it if it's obsolete.`,
11204
+ "",
11205
+ "## Failure",
11206
+ `- Cause: ${failCause}`,
11207
+ `- Failed step: ${failed ? `#${failed.index} \u2014 \`${failed.cmd}\`` : "(unknown)"}`,
11208
+ `- Run id: ${run.id} \xB7 finished: ${run.finishedAt || "?"}`,
11209
+ "",
11210
+ "## Workflow definition",
11211
+ "```",
11212
+ `name: ${wf.name}`,
11213
+ `steps:`,
11214
+ defLines,
11215
+ "```",
11216
+ "",
11217
+ "## Failed step stderr (tail)",
11218
+ "```",
11219
+ stderrTail,
11220
+ "```",
11221
+ "",
11222
+ "## Failed step stdout (tail)",
11223
+ "```",
11224
+ stdoutTail,
11225
+ "```"
11226
+ ].join("\n");
11227
+ const issue = addIssue(projectRoot, {
11228
+ title: `Fix failing workflow "${wf.name}" (${failCause})`,
11229
+ labels: ["workflow", "automation", "auto-escalated", failLabel],
11230
+ verify: { type: "agent", text: `The workflow "${wf.name}" is corrected (or removed/paused) so it no longer fails with: ${failCause}` },
11231
+ scope: wf.session ? "session" : "project",
11232
+ session: wf.session ?? null,
11233
+ triaged: true,
11234
+ body
11235
+ });
11236
+ return issue.id;
11237
+ } catch {
11238
+ return void 0;
11239
+ }
11240
+ }
11241
+ async function runWorkflow(projectRoot, wf, opts) {
11242
+ const now = opts.now || (() => {
11243
+ const d = /* @__PURE__ */ new Date();
11244
+ return { iso: d.toISOString(), ms: d.getTime() };
11245
+ });
11246
+ const exec = opts.execStep || defaultExecStep;
11247
+ const start = now();
11248
+ const id = shortRunId(`${wf.name}:${start.iso}:${start.ms}`);
11249
+ const env = wf.session ? { ...process.env, SVAMP_SESSION_ID: wf.session } : process.env;
11250
+ const run = {
11251
+ id,
11252
+ workflow: wf.name,
11253
+ trigger: opts.trigger,
11254
+ triggerContext: opts.triggerContext,
11255
+ actor: opts.actor ?? wf.session ?? null,
11256
+ startedAt: start.iso,
11257
+ status: "running",
11258
+ steps: []
11259
+ };
11260
+ recordRun(projectRoot, run);
11261
+ const steps = workflowSteps(wf);
11262
+ try {
11263
+ for (let i = 0; i < steps.length; i++) {
11264
+ const s0 = now();
11265
+ const r = await exec(projectRoot, steps[i].run, env);
11266
+ const s1 = now();
11267
+ run.steps.push({
11268
+ index: i,
11269
+ cmd: steps[i].run,
11270
+ exitCode: r.exitCode,
11271
+ stdout: capStream(r.stdout),
11272
+ stderr: capStream(r.stderr),
11273
+ durationMs: s1.ms - s0.ms,
11274
+ timedOut: r.timedOut || void 0
11275
+ });
11276
+ if (r.timedOut || r.exitCode !== 0) {
11277
+ run.status = "failed";
11278
+ break;
11279
+ }
11280
+ }
11281
+ if (run.status === "running") run.status = "success";
11282
+ } catch (e) {
11283
+ run.status = "failed";
11284
+ run.error = e?.message || String(e);
11285
+ }
11286
+ const end = now();
11287
+ run.finishedAt = end.iso;
11288
+ run.durationMs = end.ms - start.ms;
11289
+ if (run.status === "failed") {
11290
+ const escalated = escalateWorkflowFailure(projectRoot, wf, run);
11291
+ if (escalated) {
11292
+ run.escalatedIssue = escalated;
11293
+ opts.log?.(`[workflow] run ${id} "${wf.name}" failed \u2192 escalated to issue #${escalated}`);
11294
+ }
11295
+ }
11296
+ recordRun(projectRoot, run);
11297
+ opts.log?.(`[workflow] run ${id} "${wf.name}" \u2192 ${run.status} (${run.steps.length} step${run.steps.length === 1 ? "" : "s"})`);
11298
+ return run;
11299
+ }
11300
+
11301
+ const DEFAULT_IDLE_COOLDOWN_MS = 15e3;
11302
+ function idleCooldownMs() {
11303
+ const raw = Number(process.env.SVAMP_WORKFLOW_IDLE_COOLDOWN_MS);
11304
+ if (Number.isFinite(raw) && raw >= 0) return Math.floor(raw);
11305
+ return DEFAULT_IDLE_COOLDOWN_MS;
11306
+ }
11307
+ function shouldFireIdle(prevActive, event, lastFireMs, now, cooldownMs) {
11308
+ if (prevActive !== true) return false;
11309
+ if (event.active !== false) return false;
11310
+ if (lastFireMs != null && now - lastFireMs < cooldownMs) return false;
11311
+ return true;
11312
+ }
11313
+ class IdleTriggerTracker {
11314
+ constructor(cooldownMs = idleCooldownMs()) {
11315
+ this.cooldownMs = cooldownMs;
11316
+ }
11317
+ prevActive = /* @__PURE__ */ new Map();
11318
+ lastFire = /* @__PURE__ */ new Map();
11319
+ observe(event, now) {
11320
+ if (!event || event.type !== "activity" || !event.sessionId || typeof event.active !== "boolean") return false;
11321
+ const sid = event.sessionId;
11322
+ const fire = shouldFireIdle(this.prevActive.get(sid), event, this.lastFire.get(sid), now, this.cooldownMs);
11323
+ this.prevActive.set(sid, event.active);
11324
+ if (fire) this.lastFire.set(sid, now);
11325
+ return fire;
11326
+ }
11327
+ /** Drop tracked state for a session (e.g. when it is archived/removed) so its memory is reclaimed. */
11328
+ forget(sessionId) {
11329
+ this.prevActive.delete(sessionId);
11330
+ this.lastFire.delete(sessionId);
11331
+ }
11332
+ }
11333
+ function fireIdleWorkflows(root, sessionId, deps = {}) {
11334
+ let workflows;
11335
+ try {
11336
+ workflows = listWorkflows(root);
11337
+ } catch {
11338
+ return [];
11339
+ }
11340
+ const matches = workflows.filter((wf) => wf.on?.idle === true && isWorkflowEnabled(wf));
11341
+ return matches.map((wf) => {
11342
+ deps.log?.(`[workflow] idle fired "${wf.name}" in ${root} for session ${sessionId}`);
11343
+ return runWorkflow(root, wf, {
11344
+ trigger: "idle",
11345
+ triggerContext: sessionId,
11346
+ actor: wf.session || null,
11347
+ execStep: deps.execStep,
11348
+ now: deps.now,
11349
+ log: deps.log
11350
+ }).catch((e) => {
11351
+ deps.log?.(`[workflow] idle run threw for "${wf.name}": ${e?.message || e}`);
11352
+ return void 0;
11353
+ });
11354
+ });
11355
+ }
11356
+
10923
11357
  const UNBOUNDED_LOOP_ITERATIONS = 1e6;
10924
11358
  function backlogOraclePending(projectRoot, sessionId) {
10925
11359
  const inScope = (i) => !sessionId || isVisibleTo(i, sessionId);
@@ -11010,30 +11444,84 @@ function tokensInLastHour(ledger, now) {
11010
11444
  if (!ledger || !Array.isArray(ledger.window)) return 0;
11011
11445
  return ledger.window.filter(([ts]) => now - ts < HOUR_MS).reduce((s, [, t]) => s + (t > 0 ? t : 0), 0);
11012
11446
  }
11447
+ function isCostCeiling(kind) {
11448
+ return !!kind && kind !== "iteration";
11449
+ }
11013
11450
  function checkLoopBudget(ledger, limits, now, startedAt) {
11014
11451
  const L = limits || {};
11015
11452
  const led = ledger || { turns: 0, tokens: 0, window: [] };
11016
11453
  const pos = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
11017
11454
  if (pos(L.max_iterations) && L.max_iterations < UNBOUNDED_LOOP_ITERATIONS && led.turns >= L.max_iterations) {
11018
- return { exceeded: true, reason: `iteration budget reached (${led.turns}/${L.max_iterations} turns)` };
11455
+ return { exceeded: true, reason: `iteration budget reached (${led.turns}/${L.max_iterations} turns)`, kind: "iteration" };
11019
11456
  }
11020
11457
  if (pos(L.max_runtime_sec) && pos(startedAt)) {
11021
11458
  const elapsed = (now - startedAt) / 1e3;
11022
11459
  if (elapsed >= L.max_runtime_sec) {
11023
- return { exceeded: true, reason: `runtime budget reached (${Math.round(elapsed)}s/${L.max_runtime_sec}s)` };
11460
+ return { exceeded: true, reason: `runtime budget reached (${Math.round(elapsed)}s/${L.max_runtime_sec}s)`, kind: "runtime" };
11024
11461
  }
11025
11462
  }
11026
11463
  if (pos(L.max_tokens) && led.tokens >= L.max_tokens) {
11027
- return { exceeded: true, reason: `token budget reached (${led.tokens}/${L.max_tokens} tokens)` };
11464
+ return { exceeded: true, reason: `token budget reached (${led.tokens}/${L.max_tokens} tokens)`, kind: "tokens" };
11028
11465
  }
11029
11466
  if (pos(L.max_tokens_per_hour)) {
11030
11467
  const rate = tokensInLastHour(led, now);
11031
11468
  if (rate >= L.max_tokens_per_hour) {
11032
- return { exceeded: true, reason: `token-rate budget reached (${rate}/${L.max_tokens_per_hour} tokens in the last hour)` };
11469
+ return { exceeded: true, reason: `token-rate budget reached (${rate}/${L.max_tokens_per_hour} tokens in the last hour)`, kind: "tokens_per_hour" };
11033
11470
  }
11034
11471
  }
11035
11472
  return { exceeded: false };
11036
11473
  }
11474
+ const DEFAULT_LOOP_STUCK_CHECKPOINTS = 3;
11475
+ const DEFAULT_LOOP_MAX_AUTORESUMES = 3;
11476
+ const PROGRESS_HISTORY_CAP = 12;
11477
+ function resolveStuckLimit(envValue) {
11478
+ const n = Number(envValue);
11479
+ if (Number.isFinite(n) && n >= 1) return Math.floor(n);
11480
+ return DEFAULT_LOOP_STUCK_CHECKPOINTS;
11481
+ }
11482
+ function resolveMaxAutoResumes(envValue) {
11483
+ const n = Number(envValue);
11484
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
11485
+ return DEFAULT_LOOP_MAX_AUTORESUMES;
11486
+ }
11487
+ function recordProgressMarker(history, marker, cap = PROGRESS_HISTORY_CAP) {
11488
+ const base = Array.isArray(history) ? history : [];
11489
+ const next = [...base, marker];
11490
+ return next.length > cap ? next.slice(next.length - cap) : next;
11491
+ }
11492
+ function assessLoopProgress(history, opts) {
11493
+ const stuckLimit = opts && typeof opts.stuckLimit === "number" && opts.stuckLimit >= 1 ? Math.floor(opts.stuckLimit) : DEFAULT_LOOP_STUCK_CHECKPOINTS;
11494
+ const h = (Array.isArray(history) ? history : []).filter((m) => m && typeof m.pending === "number" && Number.isFinite(m.pending));
11495
+ if (h.length === 0) return { progressing: true, stuckCheckpoints: 0 };
11496
+ let best = Infinity;
11497
+ let stuck = 0;
11498
+ for (const m of h) {
11499
+ if (m.pending < best) {
11500
+ best = m.pending;
11501
+ stuck = 0;
11502
+ } else {
11503
+ stuck++;
11504
+ }
11505
+ }
11506
+ return { progressing: stuck < stuckLimit, stuckCheckpoints: stuck };
11507
+ }
11508
+ function decideLoopContinuation(input) {
11509
+ if (input.costCeilingBreached) {
11510
+ return { action: "stop", kind: "cost", reason: input.costReason || "cost budget exhausted" };
11511
+ }
11512
+ const stuckLimit = typeof input.stuckLimit === "number" && input.stuckLimit >= 1 ? Math.floor(input.stuckLimit) : DEFAULT_LOOP_STUCK_CHECKPOINTS;
11513
+ const maxAutoResumes = typeof input.maxAutoResumes === "number" && input.maxAutoResumes >= 0 ? Math.floor(input.maxAutoResumes) : DEFAULT_LOOP_MAX_AUTORESUMES;
11514
+ const autoResumes = typeof input.autoResumes === "number" && input.autoResumes > 0 ? Math.floor(input.autoResumes) : 0;
11515
+ const { progressing, stuckCheckpoints } = assessLoopProgress(input.progressHistory, { stuckLimit });
11516
+ if (progressing) {
11517
+ return { action: "continue", kind: "progress", consumeAutoResume: false, reason: `still making progress (${stuckCheckpoints}/${stuckLimit} stuck checkpoints)` };
11518
+ }
11519
+ if (autoResumes < maxAutoResumes) {
11520
+ return { action: "continue", kind: "autoresume", consumeAutoResume: true, reason: `no progress across ${stuckCheckpoints} checkpoints \u2014 auto-resume ${autoResumes + 1}/${maxAutoResumes}` };
11521
+ }
11522
+ const tail = `no progress across ${stuckCheckpoints} checkpoints; ${maxAutoResumes} auto-resume(s) exhausted`;
11523
+ return { action: "stop", kind: "stuck", reason: input.stuckReason ? `${input.stuckReason} \u2014 ${tail}` : tail };
11524
+ }
11037
11525
  const DEFAULT_LOOP_EVALUATOR_MODEL = "claude-haiku-4-5-20251001";
11038
11526
  function resolveEvaluatorModel(envValue) {
11039
11527
  if (envValue === void 0) return DEFAULT_LOOP_EVALUATOR_MODEL;
@@ -11852,6 +12340,30 @@ const SVAMP_HOME = process.env.SVAMP_HOME || join$1(os$1.homedir(), ".svamp");
11852
12340
  const DAEMON_STATE_FILE = join$1(SVAMP_HOME, "daemon.state.json");
11853
12341
  const DAEMON_LOCK_FILE = join$1(SVAMP_HOME, "daemon.lock");
11854
12342
  const DAEMON_STOP_MARKER_FILE = join$1(SVAMP_HOME, "daemon.stop");
12343
+ const PENDING_RESTART_FILE = join$1(SVAMP_HOME, "pending-restart.json");
12344
+ function writePendingRestart(p) {
12345
+ try {
12346
+ if (!existsSync$1(SVAMP_HOME)) mkdirSync$1(SVAMP_HOME, { recursive: true });
12347
+ const rec = { scheduledAt: Date.now(), ...p };
12348
+ writeFileSync$1(PENDING_RESTART_FILE, JSON.stringify(rec), "utf-8");
12349
+ } catch {
12350
+ }
12351
+ }
12352
+ function readPendingRestart() {
12353
+ try {
12354
+ if (!existsSync$1(PENDING_RESTART_FILE)) return null;
12355
+ const rec = JSON.parse(readFileSync$1(PENDING_RESTART_FILE, "utf-8"));
12356
+ return rec && typeof rec.scheduledAt === "number" ? rec : null;
12357
+ } catch {
12358
+ return null;
12359
+ }
12360
+ }
12361
+ function clearPendingRestart() {
12362
+ try {
12363
+ if (existsSync$1(PENDING_RESTART_FILE)) unlinkSync$1(PENDING_RESTART_FILE);
12364
+ } catch {
12365
+ }
12366
+ }
11855
12367
  function writeStopMarker(reason) {
11856
12368
  try {
11857
12369
  writeFileSync$1(DAEMON_STOP_MARKER_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${reason}
@@ -11988,6 +12500,19 @@ function writeGoalLoopState(directory, sessionId, state) {
11988
12500
  } catch {
11989
12501
  }
11990
12502
  }
12503
+ function safeBacklogPendingCount(projectRoot, sessionId) {
12504
+ try {
12505
+ return backlogOraclePending(projectRoot, sessionId).length;
12506
+ } catch {
12507
+ return 0;
12508
+ }
12509
+ }
12510
+ function updateLoopProgress(ls, pending, now) {
12511
+ const progress_history = recordProgressMarker(ls?.progress_history, { ts: now, pending });
12512
+ const madeProgress = assessLoopProgress(progress_history).stuckCheckpoints === 0;
12513
+ const prev = typeof ls?.auto_resumes === "number" && ls.auto_resumes > 0 ? ls.auto_resumes : 0;
12514
+ return { progress_history, auto_resumes: madeProgress ? 0 : prev };
12515
+ }
11991
12516
  function loopResumeMessage(directory, sessionId) {
11992
12517
  try {
11993
12518
  const ls = readLoopState(directory, sessionId);
@@ -12194,7 +12719,14 @@ function createSvampConfigChecker(directory, sessionId, getMetadata, setMetadata
12194
12719
  }
12195
12720
  }
12196
12721
  if (Array.isArray(config.session_links)) {
12197
- const links = config.session_links.filter((l) => l && typeof l.url === "string" && l.url.trim()).map((l) => ({ id: String(l.id || ""), label: String(l.label || "View"), url: String(l.url).trim(), ...l.icon ? { icon: String(l.icon) } : {} }));
12722
+ const links = config.session_links.filter((l) => l && typeof l.url === "string" && l.url.trim()).map((l) => ({
12723
+ id: String(l.id || ""),
12724
+ label: String(l.label || "View"),
12725
+ url: String(l.url).trim(),
12726
+ ...l.icon ? { icon: String(l.icon) } : {},
12727
+ // #0266: carry the service backing ({kind,name}) so the Launch Pad can decommission it.
12728
+ ...l.service && typeof l.service.name === "string" && (l.service.kind === "tunnel" || l.service.kind === "process") ? { service: { kind: l.service.kind, name: String(l.service.name) } } : {}
12729
+ }));
12198
12730
  const cur = meta.sessionLinks;
12199
12731
  if (JSON.stringify(cur || []) !== JSON.stringify(links)) {
12200
12732
  setMetadata((m) => ({ ...m, sessionLinks: links }));
@@ -12246,7 +12778,7 @@ function createSvampConfigChecker(directory, sessionId, getMetadata, setMetadata
12246
12778
  const gaveUpCfg = readLoopJson(directory, sessionId, "loop.config.json") || {};
12247
12779
  const curMax = typeof gaveUpCfg.max_iterations === "number" ? gaveUpCfg.max_iterations : iter;
12248
12780
  const nextMax = Math.max(curMax * 2, iter + 10);
12249
- const msg = s.phase === "done" ? `\u{1F501} Loop complete \u2705 \u2014 finished after ${iter} iteration${plural}.` : `\u{1F501} Loop paused \u{1F6D1} \u2014 hit its iteration limit (${iter}/${curMax}) and did NOT finish${s.gave_up_reason ? ` (${s.gave_up_reason})` : ""}. Resume & extend with: svamp session loop-resume ${sessionId} --max ${nextMax}`;
12781
+ const msg = s.phase === "done" ? `\u{1F501} Loop complete \u2705 \u2014 finished after ${iter} iteration${plural}.` : `\u{1F501} Loop paused \u{1F6D1} \u2014 did NOT finish after ${iter} iteration${plural}${s.gave_up_reason ? ` (${s.gave_up_reason})` : ""}. Resume & extend with: svamp session loop-resume ${sessionId} --max ${nextMax}`;
12250
12782
  sessionService.pushMessage(
12251
12783
  s.phase === "gave_up" ? { type: "message", message: msg, level: "warning" } : { type: "message", message: msg },
12252
12784
  "event"
@@ -12379,7 +12911,7 @@ function createSvampConfigChecker(directory, sessionId, getMetadata, setMetadata
12379
12911
  stripOldLoopHooks(directory);
12380
12912
  } catch {
12381
12913
  }
12382
- writeGoalLoopState(directory, sessionId, { active: true, phase: "running", iteration: 0, task: condition, goal_task: task, until, oracle, max_iterations: hardMax, budget, started_at: Date.now(), holds: 0, ledger: { turns: 0, tokens: 0, window: [] } });
12914
+ writeGoalLoopState(directory, sessionId, { active: true, phase: "running", iteration: 0, task: condition, goal_task: task, until, oracle, max_iterations: hardMax, budget, started_at: Date.now(), holds: 0, ledger: { turns: 0, tokens: 0, window: [] }, progress_history: [], auto_resumes: 0 });
12383
12915
  const existingQueue = getMetadata().messageQueue || [];
12384
12916
  setMetadata((m) => ({ ...m, messageQueue: [...existingQueue, { id: randomUUID$1(), text: `/goal ${condition}`, displayText: `\u{1F3AF} Goal: ${(task || until || "").slice(0, 100)}`, createdAt: Date.now() }] }));
12385
12917
  onLoopActivated?.();
@@ -12827,8 +13359,30 @@ async function startDaemon(options) {
12827
13359
  const list = loadExposedTunnels().filter((t) => t.name !== name);
12828
13360
  saveExposedTunnels(list);
12829
13361
  }
13362
+ async function reconcileTunnelSessionLinks(sessionId, tunnelName, urls) {
13363
+ if (!sessionId || urls.size === 0) return;
13364
+ try {
13365
+ const dir = loadSessionIndex()[sessionId]?.directory;
13366
+ if (!dir) return;
13367
+ const { reconcileServiceLinks } = await import('./agentCommands-B_ozIVKT.mjs');
13368
+ const configPath = getSvampConfigPath(dir, sessionId);
13369
+ const config = readSvampConfig(configPath);
13370
+ const entries = Array.from(urls.entries());
13371
+ const incoming = entries.map(([port, url]) => ({
13372
+ url: String(url),
13373
+ label: entries.length > 1 ? `${tunnelName}:${port}` : tunnelName,
13374
+ service: { kind: "tunnel", name: tunnelName }
13375
+ }));
13376
+ if (reconcileServiceLinks(config, incoming)) {
13377
+ writeSvampConfig(configPath, config);
13378
+ logger.log(`[exposed-tunnels] Reconciled Launch Pad link(s) for ${tunnelName} \u2192 session ${sessionId}`);
13379
+ }
13380
+ } catch (err) {
13381
+ logger.log(`[exposed-tunnels] Link reconcile failed for ${tunnelName}: ${err?.message || err}`);
13382
+ }
13383
+ }
12830
13384
  async function createExposedTunnel(spec) {
12831
- const { FrpcTunnel } = await import('./frpc-nvGz8fWs.mjs');
13385
+ const { FrpcTunnel } = await import('./frpc-CsjEXBuS.mjs');
12832
13386
  const tunnel = new FrpcTunnel({
12833
13387
  name: spec.name,
12834
13388
  ports: spec.ports,
@@ -12848,7 +13402,7 @@ async function startDaemon(options) {
12848
13402
  return tunnel;
12849
13403
  }
12850
13404
  const tunnelRecreateState = /* @__PURE__ */ new Map();
12851
- const { ServeManager } = await import('./serveManager-DGRpRAFM.mjs');
13405
+ const { ServeManager } = await import('./serveManager-Di3xrvG2.mjs');
12852
13406
  const serveManager = new ServeManager(SVAMP_HOME, (msg) => logger.log(`[SERVE] ${msg}`), hyphaServerUrl);
12853
13407
  ensureAutoInstalledSkills(logger).catch(() => {
12854
13408
  });
@@ -13053,6 +13607,22 @@ ${v.guidance ? v.guidance + "\n" : ""}Criteria: ${v.criteria || "(none)"}
13053
13607
  return taken;
13054
13608
  };
13055
13609
  let machineServiceRef = null;
13610
+ const idleTriggerTracker = new IdleTriggerTracker();
13611
+ const routeSessionEventForIdle = (update, dir) => {
13612
+ try {
13613
+ if (!update || update.type !== "activity") return;
13614
+ if (!idleTriggerTracker.observe(update, Date.now())) return;
13615
+ let root;
13616
+ try {
13617
+ root = resolveProjectRoot(dir);
13618
+ } catch {
13619
+ return;
13620
+ }
13621
+ fireIdleWorkflows(root, String(update.sessionId), { log: (m) => logger.log(m) });
13622
+ } catch (e) {
13623
+ logger.log(`[workflow] idle trigger error: ${e?.message || e}`);
13624
+ }
13625
+ };
13056
13626
  const spawnSession = async (options2) => {
13057
13627
  logger.log("Spawning session:", JSON.stringify(options2));
13058
13628
  const { directory, approvedNewDirectoryCreation = true, resumeSessionId } = options2;
@@ -13512,6 +14082,7 @@ ${parts.join("\n")}`);
13512
14082
  );
13513
14083
  const ledger = ls.ledger;
13514
14084
  const budget = ls.budget;
14085
+ const progress_history = ls.progress_history;
13515
14086
  if (result.action === "rekick") {
13516
14087
  const cond = ls.task || "";
13517
14088
  writeGoalLoopState(directory, sessionId, {
@@ -13526,17 +14097,55 @@ ${parts.join("\n")}`);
13526
14097
  budget,
13527
14098
  started_at: startedAt,
13528
14099
  holds: holds + 1,
13529
- ledger
14100
+ ledger,
14101
+ progress_history,
14102
+ auto_resumes: ls.auto_resumes
13530
14103
  });
13531
14104
  const guidance = result.guidance.replace(/\s+/g, " ").slice(0, 1200);
13532
14105
  sessionService.pushMessage({ type: "message", message: `\u{1F501} Loop not done \u2014 independent review: ${result.reason}`, level: "warning" }, "event");
13533
14106
  enqueueLoopMessage(`/goal ${cond} \u2014 INDEPENDENT REVIEW FEEDBACK (resolve before declaring done): ${guidance}`, "\u{1F501} Re-verifying loop");
13534
14107
  if (!trackedSession.stopped) setTimeout(() => processMessageQueueRef?.(), 200);
13535
14108
  } else if (result.action === "gave_up") {
13536
- writeGoalLoopState(directory, sessionId, { active: false, phase: "gave_up", completed_at: Date.now(), gave_up_reason: result.reason, holds, ledger });
13537
- checkSvampConfig?.();
14109
+ const decision = decideLoopContinuation({
14110
+ costCeilingBreached: false,
14111
+ progressHistory: progress_history || [],
14112
+ stuckLimit: resolveStuckLimit(process.env.SVAMP_LOOP_STUCK_CHECKPOINTS),
14113
+ autoResumes: typeof ls.auto_resumes === "number" ? ls.auto_resumes : 0,
14114
+ maxAutoResumes: resolveMaxAutoResumes(process.env.SVAMP_LOOP_MAX_AUTORESUMES),
14115
+ stuckReason: result.reason
14116
+ });
14117
+ if (decision.action === "continue") {
14118
+ const cond = ls.task || "";
14119
+ const nextAuto = (typeof ls.auto_resumes === "number" ? ls.auto_resumes : 0) + (decision.consumeAutoResume ? 1 : 0);
14120
+ writeGoalLoopState(directory, sessionId, {
14121
+ active: true,
14122
+ phase: "continue",
14123
+ iteration: ls.iteration || 0,
14124
+ task: ls.task,
14125
+ goal_task: ls.goal_task,
14126
+ until: ls.until,
14127
+ oracle: oracleCmd,
14128
+ max_iterations: ls.max_iterations,
14129
+ budget,
14130
+ started_at: startedAt,
14131
+ holds,
14132
+ ledger,
14133
+ progress_history,
14134
+ auto_resumes: nextAuto
14135
+ });
14136
+ const label = decision.kind === "autoresume" ? "\u{1F501} Auto-resuming stalled loop" : "\u{1F501} Loop still progressing";
14137
+ sessionService.pushMessage({ type: "message", message: `${label} \u2014 ${decision.reason}` }, "event");
14138
+ logger.log(`[Session ${sessionId}] [loop-persist] ${decision.kind}: ${decision.reason}`);
14139
+ const push = decision.kind === "autoresume" ? `/goal ${cond} \u2014 AUTO-RESUME after an apparent stall (${decision.reason}). Re-verify the remaining work and PUSH IT TO COMPLETION; do not stop early. Prior reviewer note: ${result.reason}` : `/goal ${cond} \u2014 still making progress; KEEP GOING until genuinely done. Prior reviewer note: ${result.reason}`;
14140
+ enqueueLoopMessage(push, label);
14141
+ if (!trackedSession.stopped) setTimeout(() => processMessageQueueRef?.(), 200);
14142
+ } else {
14143
+ writeGoalLoopState(directory, sessionId, { active: false, phase: "gave_up", completed_at: Date.now(), gave_up_reason: decision.reason, holds, ledger, progress_history, auto_resumes: ls.auto_resumes });
14144
+ logger.log(`[Session ${sessionId}] [loop-persist] stuck-stop: ${decision.reason}`);
14145
+ checkSvampConfig?.();
14146
+ }
13538
14147
  } else {
13539
- writeGoalLoopState(directory, sessionId, { active: false, phase: "done", completed_at: Date.now(), holds, ledger });
14148
+ writeGoalLoopState(directory, sessionId, { active: false, phase: "done", completed_at: Date.now(), holds, ledger, progress_history, auto_resumes: ls.auto_resumes });
13540
14149
  checkSvampConfig?.();
13541
14150
  }
13542
14151
  } catch (e) {
@@ -14005,22 +14614,28 @@ ${parts.join("\n")}`);
14005
14614
  });
14006
14615
  ls.ledger = ledger;
14007
14616
  const startedAt = typeof ls.started_at === "number" ? ls.started_at : typeof ls.resumed_at === "number" ? ls.resumed_at : void 0;
14008
- const budgetCheck = process.env.SVAMP_LOOP_BUDGET === "0" ? { exceeded: false } : checkLoopBudget(ledger, { max_iterations: ls.max_iterations, ...ls.budget || {} }, now, startedAt);
14009
- if (budgetCheck.exceeded) {
14617
+ const budgetCheck = process.env.SVAMP_LOOP_BUDGET === "0" ? { exceeded: false, kind: void 0 } : checkLoopBudget(ledger, { max_iterations: ls.max_iterations, ...ls.budget || {} }, now, startedAt);
14618
+ const pending = safeBacklogPendingCount(resolveProjectRoot(directory), sessionId);
14619
+ const prog = updateLoopProgress(ls, pending, now);
14620
+ ls.progress_history = prog.progress_history;
14621
+ ls.auto_resumes = prog.auto_resumes;
14622
+ if (budgetCheck.exceeded && isCostCeiling(budgetCheck.kind)) {
14010
14623
  writeGoalLoopState(directory, sessionId, {
14011
14624
  ...ls,
14012
14625
  active: false,
14013
14626
  phase: "gave_up",
14014
14627
  completed_at: now,
14015
14628
  gave_up_reason: `resource budget exhausted \u2014 ${budgetCheck.reason}`,
14016
- ledger
14629
+ ledger,
14630
+ progress_history: prog.progress_history,
14631
+ auto_resumes: prog.auto_resumes
14017
14632
  });
14018
- sessionService.pushMessage({ type: "message", message: `\u{1F6D1} Loop stopped \u2014 ${budgetCheck.reason}. Resume & extend with: svamp session loop-resume ${sessionId} --max <N>`, level: "warning" }, "event");
14633
+ sessionService.pushMessage({ type: "message", message: `\u{1F6D1} Loop stopped \u2014 ${budgetCheck.reason} (hard cost ceiling \u2014 will not auto-resume). Resume & extend with: svamp session loop-resume ${sessionId} --max <N>`, level: "warning" }, "event");
14019
14634
  checkSvampConfig?.();
14020
14635
  } else if (process.env.SVAMP_LOOP_VERIFY === "0") {
14021
- writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "done", completed_at: now, ledger });
14636
+ writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "done", completed_at: now, ledger, progress_history: prog.progress_history, auto_resumes: prog.auto_resumes });
14022
14637
  } else {
14023
- writeGoalLoopState(directory, sessionId, { ...ls, ledger });
14638
+ writeGoalLoopState(directory, sessionId, { ...ls, ledger, progress_history: prog.progress_history, auto_resumes: prog.auto_resumes });
14024
14639
  void verifyGoalCompletion(ls);
14025
14640
  }
14026
14641
  }
@@ -14873,11 +15488,11 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
14873
15488
  });
14874
15489
  },
14875
15490
  onIssue: async (params) => {
14876
- const { issueRpc } = await import('./rpc-s_Hibfpl.mjs');
15491
+ const { issueRpc } = await import('./rpc-C1DI7HfZ.mjs');
14877
15492
  return issueRpc(params?.cwd || directory, params || {}, { notifySession: notifyIssueOwner, rekickLoopOwner });
14878
15493
  },
14879
15494
  onWorkflow: async (params) => {
14880
- const { workflowRpc } = await import('./rpc-CDI7i151.mjs');
15495
+ const { workflowRpc } = await import('./rpc-BGFgegj5.mjs');
14881
15496
  return workflowRpc(params?.cwd || directory, params || {});
14882
15497
  },
14883
15498
  onRipgrep: async (args, cwd) => {
@@ -14956,7 +15571,10 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
14956
15571
  return { success: !!tree, tree };
14957
15572
  }
14958
15573
  },
14959
- { messagesDir: getSessionDir(directory, sessionId), onSessionEvent: (update) => machineServiceRef?.notifySessionEvent(update) }
15574
+ { messagesDir: getSessionDir(directory, sessionId), onSessionEvent: (update) => {
15575
+ machineServiceRef?.notifySessionEvent(update);
15576
+ routeSessionEventForIdle(update, directory);
15577
+ } }
14960
15578
  );
14961
15579
  const svampConfig = createSvampConfigChecker(
14962
15580
  directory,
@@ -15410,11 +16028,11 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
15410
16028
  });
15411
16029
  },
15412
16030
  onIssue: async (params) => {
15413
- const { issueRpc } = await import('./rpc-s_Hibfpl.mjs');
16031
+ const { issueRpc } = await import('./rpc-C1DI7HfZ.mjs');
15414
16032
  return issueRpc(params?.cwd || directory, params || {}, { notifySession: notifyIssueOwner, rekickLoopOwner });
15415
16033
  },
15416
16034
  onWorkflow: async (params) => {
15417
- const { workflowRpc } = await import('./rpc-CDI7i151.mjs');
16035
+ const { workflowRpc } = await import('./rpc-BGFgegj5.mjs');
15418
16036
  return workflowRpc(params?.cwd || directory, params || {});
15419
16037
  },
15420
16038
  onRipgrep: async (args, cwd) => {
@@ -15493,7 +16111,10 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
15493
16111
  return { success: !!tree, tree };
15494
16112
  }
15495
16113
  },
15496
- { messagesDir: getSessionDir(directory, sessionId), onSessionEvent: (update) => machineServiceRef?.notifySessionEvent(update) }
16114
+ { messagesDir: getSessionDir(directory, sessionId), onSessionEvent: (update) => {
16115
+ machineServiceRef?.notifySessionEvent(update);
16116
+ routeSessionEventForIdle(update, directory);
16117
+ } }
15497
16118
  );
15498
16119
  let insideOnTurnEnd = false;
15499
16120
  const svampConfigChecker = createSvampConfigChecker(
@@ -15624,10 +16245,14 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
15624
16245
  return { ok: pending.length === 0, output: pending.length ? `${pending.length} pending: ${pending.map((i) => "#" + i.id).join(" ")}` : "No pending issues." };
15625
16246
  };
15626
16247
  try {
15627
- const budgetCheck = process.env.SVAMP_LOOP_BUDGET === "0" ? { exceeded: false } : checkLoopBudget(ledger, { max_iterations: ls.max_iterations, ...ls.budget || {} }, now, startedAt);
15628
- if (budgetCheck.exceeded) {
16248
+ const budgetCheck = process.env.SVAMP_LOOP_BUDGET === "0" ? { exceeded: false, kind: void 0 } : checkLoopBudget(ledger, { max_iterations: ls.max_iterations, ...ls.budget || {} }, now, startedAt);
16249
+ const acpPending = safeBacklogPendingCount(projectRoot, sessionId);
16250
+ const acpProg = updateLoopProgress(ls, acpPending, now);
16251
+ ls.progress_history = acpProg.progress_history;
16252
+ ls.auto_resumes = acpProg.auto_resumes;
16253
+ if (budgetCheck.exceeded && isCostCeiling(budgetCheck.kind)) {
15629
16254
  writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "gave_up", completed_at: now, gave_up_reason: `resource budget exhausted \u2014 ${budgetCheck.reason}`, ledger });
15630
- sessionService.pushMessage({ type: "message", message: `\u{1F6D1} Loop stopped \u2014 ${budgetCheck.reason}. Resume & extend with: svamp session loop-resume ${sessionId} --max <N>`, level: "warning" }, "event");
16255
+ sessionService.pushMessage({ type: "message", message: `\u{1F6D1} Loop stopped \u2014 ${budgetCheck.reason} (hard cost ceiling \u2014 will not auto-resume). Resume & extend with: svamp session loop-resume ${sessionId} --max <N>`, level: "warning" }, "event");
15631
16256
  checkSvampConfig?.();
15632
16257
  return;
15633
16258
  }
@@ -15659,8 +16284,26 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
15659
16284
  sessionService.pushMessage({ type: "message", message: `\u{1F501} Loop not done \u2014 independent review: ${result.reason}`, level: "warning" }, "event");
15660
16285
  dispatchAcpPrompt(`Continue toward the goal: ${ls.goal_task || ls.until || "the task"}. INDEPENDENT REVIEW FEEDBACK (resolve before declaring done): ${guidance}`, "\u{1F501} Re-verifying loop");
15661
16286
  } else if (result.action === "gave_up") {
15662
- writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "gave_up", completed_at: Date.now(), gave_up_reason: result.reason, holds, ledger });
15663
- checkSvampConfig?.();
16287
+ const decision = decideLoopContinuation({
16288
+ costCeilingBreached: false,
16289
+ progressHistory: ls.progress_history || [],
16290
+ stuckLimit: resolveStuckLimit(process.env.SVAMP_LOOP_STUCK_CHECKPOINTS),
16291
+ autoResumes: typeof ls.auto_resumes === "number" ? ls.auto_resumes : 0,
16292
+ maxAutoResumes: resolveMaxAutoResumes(process.env.SVAMP_LOOP_MAX_AUTORESUMES),
16293
+ stuckReason: result.reason
16294
+ });
16295
+ if (decision.action === "continue") {
16296
+ const nextAuto = (typeof ls.auto_resumes === "number" ? ls.auto_resumes : 0) + (decision.consumeAutoResume ? 1 : 0);
16297
+ writeGoalLoopState(directory, sessionId, { ...ls, active: true, phase: "continue", holds, started_at: startedAt, ledger, auto_resumes: nextAuto });
16298
+ const label = decision.kind === "autoresume" ? "\u{1F501} Auto-resuming stalled loop" : "\u{1F501} Loop still progressing";
16299
+ sessionService.pushMessage({ type: "message", message: `${label} \u2014 ${decision.reason}` }, "event");
16300
+ logger.log(`[Session ${sessionId}] [loop-persist] ${decision.kind}: ${decision.reason}`);
16301
+ dispatchAcpPrompt(`Continue toward the goal: ${ls.goal_task || ls.until || "the task"}. ${decision.kind === "autoresume" ? `AUTO-RESUME after an apparent stall (${decision.reason}) \u2014 re-verify the remaining work and PUSH IT TO COMPLETION.` : "Still making progress \u2014 KEEP GOING until genuinely done."} Prior reviewer note: ${result.reason}`, label);
16302
+ } else {
16303
+ writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "gave_up", completed_at: Date.now(), gave_up_reason: decision.reason, holds, ledger });
16304
+ logger.log(`[Session ${sessionId}] [loop-persist] stuck-stop: ${decision.reason}`);
16305
+ checkSvampConfig?.();
16306
+ }
15664
16307
  } else {
15665
16308
  writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "done", completed_at: Date.now(), holds, ledger });
15666
16309
  checkSvampConfig?.();
@@ -16058,6 +16701,7 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
16058
16701
  const tunnel = await createExposedTunnel(spec);
16059
16702
  tunnels.set(spec.name, tunnel);
16060
16703
  logger.log(`[exposed-tunnels] Restored: ${spec.name} \u2192 ports ${spec.ports.join(",")}`);
16704
+ if (spec.sessionId) await reconcileTunnelSessionLinks(spec.sessionId, spec.name, tunnel.getUrls());
16061
16705
  } catch (err) {
16062
16706
  logger.log(`[exposed-tunnels] Failed to restore ${spec.name}: ${err.message}`);
16063
16707
  }
@@ -16208,6 +16852,61 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
16208
16852
  );
16209
16853
  logger.log(`Restored ${persistedSessions.length} session(s) in ${Date.now() - t0}ms (concurrency ${restoreConcurrency})`);
16210
16854
  }
16855
+ {
16856
+ const anySessionBusy = () => {
16857
+ let n = 0;
16858
+ for (const s of pidToTrackedSession.values()) {
16859
+ if (s.stopped || !s.hyphaService) continue;
16860
+ try {
16861
+ if (s.hyphaService.getMetadata()?.lifecycleState === "running") n++;
16862
+ } catch {
16863
+ }
16864
+ }
16865
+ return { busy: n > 0, n };
16866
+ };
16867
+ const triggerSelfRestart = (why) => {
16868
+ logger.log(`[graceful-restart] ${why} \u2014 restarting daemon now`);
16869
+ clearPendingRestart();
16870
+ try {
16871
+ const supPidFile = join$1(SVAMP_HOME, "supervisor.pid");
16872
+ const supPid = existsSync$1(supPidFile) ? parseInt(readFileSync$1(supPidFile, "utf-8").trim(), 10) : NaN;
16873
+ if (supPid && !isNaN(supPid)) {
16874
+ process.kill(supPid, "SIGUSR2");
16875
+ return;
16876
+ }
16877
+ } catch {
16878
+ }
16879
+ logger.log("[graceful-restart] no supervisor to signal \u2014 exiting for KeepAlive respawn");
16880
+ process.exit(0);
16881
+ };
16882
+ let lastDeferLog = 0;
16883
+ const GRACEFUL_RESTART_POLL_MS = 15e3;
16884
+ const DEFAULT_MAX_WAIT_MS = 60 * 60 * 1e3;
16885
+ const gracefulTimer = setInterval(() => {
16886
+ const pending = readPendingRestart();
16887
+ if (!pending) return;
16888
+ if (pending.force) {
16889
+ triggerSelfRestart("force requested");
16890
+ return;
16891
+ }
16892
+ const { busy, n } = anySessionBusy();
16893
+ if (!busy) {
16894
+ triggerSelfRestart("all sessions idle");
16895
+ return;
16896
+ }
16897
+ const maxWait = typeof pending.maxWaitMs === "number" && pending.maxWaitMs > 0 ? pending.maxWaitMs : DEFAULT_MAX_WAIT_MS;
16898
+ if (Date.now() - pending.scheduledAt > maxWait) {
16899
+ triggerSelfRestart(`max-wait ${Math.round(maxWait / 6e4)}m elapsed (${n} still busy)`);
16900
+ return;
16901
+ }
16902
+ const now = Date.now();
16903
+ if (now - lastDeferLog > 6e4) {
16904
+ lastDeferLog = now;
16905
+ logger.log(`[graceful-restart] deferring \u2014 ${n} session(s) actively thinking`);
16906
+ }
16907
+ }, GRACEFUL_RESTART_POLL_MS);
16908
+ gracefulTimer.unref?.();
16909
+ }
16211
16910
  if (sessionsToAutoContinue.length > 0 && !options?.noAutoContinue) {
16212
16911
  logger.log(`Auto-continuing ${sessionsToAutoContinue.length} interrupted session(s)...`);
16213
16912
  for (const sessionId of sessionsToAutoContinue) {
@@ -16291,7 +16990,7 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
16291
16990
  const PING_TIMEOUT_MS = 15e3;
16292
16991
  const POST_RECONNECT_GRACE_MS = 2e4;
16293
16992
  const RECONNECT_JITTER_MS = 2500;
16294
- const { WorkflowScheduler } = await import('./scheduler-E1Bw4HUY.mjs');
16993
+ const { WorkflowScheduler } = await import('./scheduler-Csp21zzg.mjs');
16295
16994
  const workflowScheduler = new WorkflowScheduler({
16296
16995
  projectRoots: () => {
16297
16996
  const dirs = /* @__PURE__ */ new Set();
@@ -16861,13 +17560,18 @@ function daemonStatus() {
16861
17560
  var run = /*#__PURE__*/Object.freeze({
16862
17561
  __proto__: null,
16863
17562
  DAEMON_STOP_MARKER_FILE: DAEMON_STOP_MARKER_FILE,
17563
+ PENDING_RESTART_FILE: PENDING_RESTART_FILE,
17564
+ clearPendingRestart: clearPendingRestart,
16864
17565
  clearStopMarker: clearStopMarker,
16865
17566
  daemonStatus: daemonStatus,
17567
+ isDaemonAlive: isDaemonAlive,
17568
+ readPendingRestart: readPendingRestart,
16866
17569
  restartDaemon: restartDaemon,
16867
17570
  startDaemon: startDaemon,
16868
17571
  stopDaemon: stopDaemon,
16869
17572
  stopMarkerExists: stopMarkerExists,
17573
+ writePendingRestart: writePendingRestart,
16870
17574
  writeStopMarker: writeStopMarker
16871
17575
  });
16872
17576
 
16873
- export { buildMachineShareUrl as $, shortId as A, loadMachineContext as B, buildMachineInstructions as C, machineToolsForRole as D, buildMachineTools as E, parseFrontmatter as F, getSkillsServer as G, getSkillsWorkspaceName as H, getSkillsCollectionName as I, fetchWithTimeout as J, searchSkills as K, SKILLS_DIR as L, getSkillInfo as M, downloadSkillFile as N, listSkillFiles as O, resolveModel as P, formatHandle as Q, READ_ONLY_TOOLS as R, ServeAuth as S, normalizeAllowedUser as T, loadSecurityContextConfig as U, resolveSecurityContext as V, buildSecurityContextFromFlags as W, mergeSecurityContexts as X, buildSessionShareUrl as Y, computeOutboundHop as Z, registerAwaitingReply as _, createSessionStore as a, parseHandle as a0, handleMatchesMetadata as a1, describeMisconfiguration as a2, buildMachineDeps as a3, applyClaudeProxyEnv as a4, composeSessionId as a5, generateFriendlyName as a6, generateHookSettings as a7, claudeAuth as a8, projectInfo as a9, DefaultTransport$1 as aa, acpBackend as ab, acpAgentConfig as ac, codexMcpBackend as ad, GeminiTransport$1 as ae, instanceConfig as af, api as ag, run as ah, stopDaemon as b, connectToHypha as c, daemonStatus as d, clearStopMarker as e, stopMarkerExists as f, getHyphaServerUrl$1 as g, getFrpsSubdomainHost as h, getFrpsServerPort as i, getFrpsServerAddr as j, getHyphaServerUrl as k, hasCookieToken as l, resolveProjectRoot as m, getIssue as n, resumeIssue as o, pauseIssue as p, addComment as q, registerMachineService as r, startDaemon as s, addIssue as t, updateIssue as u, listIssues as v, searchIssues as w, isVisibleTo as x, cronMatches as y, summarize as z };
17577
+ export { downloadSkillFile as $, listRuns as A, getWorkflow as B, runWorkflow as C, setWorkflowEnabled as D, removeWorkflow as E, saveWorkflow as F, rawWorkflow as G, listWorkflows as H, isWorkflowEnabled as I, workflowCrons as J, cronMatches as K, summarize as L, workflowSteps as M, loadMachineContext as N, buildMachineInstructions as O, machineToolsForRole as P, buildMachineTools as Q, READ_ONLY_TOOLS as R, ServeAuth as S, parseFrontmatter as T, getSkillsServer as U, getSkillsWorkspaceName as V, getSkillsCollectionName as W, fetchWithTimeout as X, searchSkills as Y, SKILLS_DIR as Z, getSkillInfo as _, createSessionStore as a, listSkillFiles as a0, resolveModel as a1, formatHandle as a2, normalizeAllowedUser as a3, loadSecurityContextConfig as a4, resolveSecurityContext as a5, buildSecurityContextFromFlags as a6, mergeSecurityContexts as a7, buildSessionShareUrl as a8, computeOutboundHop as a9, registerAwaitingReply as aa, buildMachineShareUrl as ab, parseHandle as ac, handleMatchesMetadata as ad, describeMisconfiguration as ae, buildMachineDeps as af, applyClaudeProxyEnv as ag, composeSessionId as ah, generateFriendlyName as ai, generateHookSettings as aj, claudeAuth as ak, projectInfo as al, DefaultTransport$1 as am, acpBackend as an, acpAgentConfig as ao, codexMcpBackend as ap, GeminiTransport$1 as aq, instanceConfig as ar, api as as, run as at, stopDaemon as b, connectToHypha as c, daemonStatus as d, clearStopMarker as e, stopMarkerExists as f, getHyphaServerUrl$1 as g, getFrpsSubdomainHost as h, getFrpsServerPort as i, getFrpsServerAddr as j, shortId as k, getHyphaServerUrl as l, hasCookieToken as m, resolveProjectRoot as n, getIssue as o, resumeIssue as p, pauseIssue as q, registerMachineService as r, startDaemon as s, addComment as t, updateIssue as u, addIssue as v, listIssues as w, searchIssues as x, isVisibleTo as y, getRun as z };