svamp-cli 0.2.175 → 0.2.177

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 (33) hide show
  1. package/bin/skills/loop/SKILL.md +1 -1
  2. package/dist/{agentCommands-C4upUtMc.mjs → agentCommands-DWvXxX8p.mjs} +5 -5
  3. package/dist/{auth-BKN98KO4.mjs → auth-Dxka4cE6.mjs} +1 -1
  4. package/dist/cli.mjs +60 -64
  5. package/dist/{commands-D2lD-S4d.mjs → commands--dKRqMv8.mjs} +5 -5
  6. package/dist/{commands-BssfhM-2.mjs → commands-B4u6CUWH.mjs} +1 -1
  7. package/dist/{commands-CSK-jSnk.mjs → commands-Bf-TovMw.mjs} +1 -1
  8. package/dist/{commands-DICHik9Y.mjs → commands-BwBxmMxJ.mjs} +19 -13
  9. package/dist/{commands-BF_Wy2B4.mjs → commands-CwdYZQ-a.mjs} +1 -1
  10. package/dist/{commands-BbqjhR4z.mjs → commands-DVKFo1pt.mjs} +2 -2
  11. package/dist/{commands-D03sBXV4.mjs → commands-iXMwyExn.mjs} +2 -2
  12. package/dist/{fleet-qUmhLn41.mjs → fleet-plROHfB8.mjs} +1 -1
  13. package/dist/{frpc-BDZgTsi8.mjs → frpc-yc5-4EUe.mjs} +1 -1
  14. package/dist/{headlessCli-BG-PcYjM.mjs → headlessCli-D7vLrl7K.mjs} +2 -2
  15. package/dist/index.mjs +1 -1
  16. package/dist/{package-CMB-hfB0.mjs → package-Do-iBoUv.mjs} +2 -2
  17. package/dist/{rpc-CQTahguB.mjs → rpc-D1gr74eF.mjs} +11 -9
  18. package/dist/{rpc-gCEvLUNu.mjs → rpc-DUZqKPK4.mjs} +1 -1
  19. package/dist/{run-Buov6wz-.mjs → run-21-bMHCc.mjs} +37 -402
  20. package/dist/{run-DGs1hcvd.mjs → run-C__yViCA.mjs} +1 -1
  21. package/dist/scheduler-DweevMw_.mjs +93 -0
  22. package/dist/{serveCommands-C6l8yA9_.mjs → serveCommands-BZmiAjxi.mjs} +5 -5
  23. package/dist/{serveManager-B_Bj1AF4.mjs → serveManager-AbWv-Akt.mjs} +2 -2
  24. package/dist/{sideband-BYiyQgZR.mjs → sideband-CzzHtakf.mjs} +1 -1
  25. package/dist/store-BPL__e4D.mjs +125 -0
  26. package/package.json +2 -2
  27. package/bin/skills/loop/bin/routine-cli.mjs +0 -121
  28. package/bin/skills/loop/bin/routine-runner.mjs +0 -125
  29. package/bin/skills/loop/bin/routine-store.mjs +0 -49
  30. package/bin/skills/loop/routines.process.yaml +0 -20
  31. package/bin/skills/loop/test/test-routine-engine.mjs +0 -122
  32. package/dist/commands-CkygtYMZ.mjs +0 -223
  33. package/dist/store-D2HNe-Kv.mjs +0 -74
@@ -0,0 +1,93 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { m as resolveProjectRoot, v as cronMatches } from './run-21-bMHCc.mjs';
3
+ import { l as listWorkflows, b as workflowCrons, w as workflowSteps } from './store-BPL__e4D.mjs';
4
+ import 'os';
5
+ import 'fs/promises';
6
+ import 'fs';
7
+ import 'path';
8
+ import 'url';
9
+ import 'child_process';
10
+ import 'crypto';
11
+ import 'node:crypto';
12
+ import 'node:fs';
13
+ import 'util';
14
+ import 'node:path';
15
+ import 'node:events';
16
+ import 'node:os';
17
+ import '@agentclientprotocol/sdk';
18
+ import '@modelcontextprotocol/sdk/client/index.js';
19
+ import '@modelcontextprotocol/sdk/client/stdio.js';
20
+ import '@modelcontextprotocol/sdk/types.js';
21
+ import 'zod';
22
+ import 'node:fs/promises';
23
+ import 'node:util';
24
+ import 'yaml';
25
+
26
+ function defaultRunStep(root, command, log) {
27
+ try {
28
+ const child = spawn("sh", ["-c", command], { cwd: root, detached: true, stdio: "ignore" });
29
+ child.on("error", (e) => log?.(`[workflow] step spawn error in ${root}: ${e.message}`));
30
+ child.unref();
31
+ } catch (e) {
32
+ log?.(`[workflow] step spawn threw in ${root}: ${e?.message || e}`);
33
+ }
34
+ }
35
+ function cronMatchesSafe(expr, date) {
36
+ try {
37
+ return cronMatches(expr, date);
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+ class WorkflowScheduler {
43
+ constructor(deps) {
44
+ this.deps = deps;
45
+ }
46
+ // Wall-clock minute we last processed, so multiple sub-minute ticks fire a workflow at most once
47
+ // per minute (cron granularity is one minute).
48
+ lastMinuteKey = null;
49
+ static minuteKey(d) {
50
+ return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}-${d.getHours()}-${d.getMinutes()}`;
51
+ }
52
+ /** Fire all scheduled workflows whose cron matches `now`. Returns the names fired (for tests/logs). */
53
+ tick(now) {
54
+ const key = WorkflowScheduler.minuteKey(now);
55
+ if (key === this.lastMinuteKey) return [];
56
+ this.lastMinuteKey = key;
57
+ const fired = [];
58
+ const seen = /* @__PURE__ */ new Set();
59
+ for (const dir of this.deps.projectRoots()) {
60
+ let root;
61
+ try {
62
+ root = resolveProjectRoot(dir);
63
+ } catch {
64
+ continue;
65
+ }
66
+ if (seen.has(root)) continue;
67
+ seen.add(root);
68
+ let workflows;
69
+ try {
70
+ workflows = listWorkflows(root);
71
+ } catch {
72
+ continue;
73
+ }
74
+ for (const wf of workflows) {
75
+ const crons = workflowCrons(wf);
76
+ if (!crons.length) continue;
77
+ if (crons.some((c) => cronMatchesSafe(c, now))) {
78
+ this.fire(root, wf);
79
+ fired.push(wf.name);
80
+ }
81
+ }
82
+ }
83
+ return fired;
84
+ }
85
+ fire(root, wf) {
86
+ const steps = workflowSteps(wf);
87
+ this.deps.log?.(`[workflow] schedule fired "${wf.name}" (${steps.length} step${steps.length === 1 ? "" : "s"}) in ${root}`);
88
+ const run = this.deps.runStep || ((r, c) => defaultRunStep(r, c, this.deps.log));
89
+ for (const step of steps) run(root, step.run);
90
+ }
91
+ }
92
+
93
+ export { WorkflowScheduler, cronMatchesSafe };
@@ -54,7 +54,7 @@ async function handleServeCommand() {
54
54
  }
55
55
  }
56
56
  async function serveAdd(args, machineId) {
57
- const { connectAndGetMachine } = await import('./commands-BssfhM-2.mjs');
57
+ const { connectAndGetMachine } = await import('./commands-B4u6CUWH.mjs');
58
58
  const pos = positionalArgs(args);
59
59
  const name = pos[0];
60
60
  if (!name) {
@@ -93,7 +93,7 @@ async function serveAdd(args, machineId) {
93
93
  }
94
94
  }
95
95
  async function serveApply(args, machineId) {
96
- const { connectAndGetMachine } = await import('./commands-BssfhM-2.mjs');
96
+ const { connectAndGetMachine } = await import('./commands-B4u6CUWH.mjs');
97
97
  const fs = await import('fs');
98
98
  const yaml = await import('yaml');
99
99
  const file = positionalArgs(args)[0];
@@ -182,7 +182,7 @@ async function serveApply(args, machineId) {
182
182
  }
183
183
  }
184
184
  async function serveRemove(args, machineId) {
185
- const { connectAndGetMachine } = await import('./commands-BssfhM-2.mjs');
185
+ const { connectAndGetMachine } = await import('./commands-B4u6CUWH.mjs');
186
186
  const pos = positionalArgs(args);
187
187
  const name = pos[0];
188
188
  if (!name) {
@@ -202,7 +202,7 @@ async function serveRemove(args, machineId) {
202
202
  }
203
203
  }
204
204
  async function serveList(args, machineId) {
205
- const { connectAndGetMachine } = await import('./commands-BssfhM-2.mjs');
205
+ const { connectAndGetMachine } = await import('./commands-B4u6CUWH.mjs');
206
206
  const all = hasFlag(args, "--all", "-a");
207
207
  const json = hasFlag(args, "--json");
208
208
  const sessionId = getFlag(args, "--session");
@@ -235,7 +235,7 @@ async function serveList(args, machineId) {
235
235
  }
236
236
  }
237
237
  async function serveInfo(machineId) {
238
- const { connectAndGetMachine } = await import('./commands-BssfhM-2.mjs');
238
+ const { connectAndGetMachine } = await import('./commands-B4u6CUWH.mjs');
239
239
  const { machine, server } = await connectAndGetMachine(machineId);
240
240
  try {
241
241
  const info = await machine.serveInfo();
@@ -4,7 +4,7 @@ import * as fs from 'fs';
4
4
  import * as http from 'http';
5
5
  import * as net from 'net';
6
6
  import * as path from 'path';
7
- import { k as getHyphaServerUrl, S as ServeAuth, l as hasCookieToken } from './run-Buov6wz-.mjs';
7
+ import { k as getHyphaServerUrl, S as ServeAuth, l as hasCookieToken } from './run-21-bMHCc.mjs';
8
8
  import 'os';
9
9
  import 'fs/promises';
10
10
  import 'url';
@@ -733,7 +733,7 @@ class ServeManager {
733
733
  const mount = this.mounts.get(mountName);
734
734
  const subdomainOverride = mount?.access === "link" && mount.linkToken ? /* @__PURE__ */ new Map([[this.port, buildLinkSubdomain(subdomainSafe, mount.linkToken)]]) : void 0;
735
735
  try {
736
- const { FrpcTunnel } = await import('./frpc-BDZgTsi8.mjs');
736
+ const { FrpcTunnel } = await import('./frpc-yc5-4EUe.mjs');
737
737
  let tunnel;
738
738
  tunnel = new FrpcTunnel({
739
739
  name: tunnelName,
@@ -1,4 +1,4 @@
1
- import { y as READ_ONLY_TOOLS, z as loadMachineContext, A as buildMachineInstructions, B as machineToolsForRole, C as buildMachineTools } from './run-Buov6wz-.mjs';
1
+ import { R as READ_ONLY_TOOLS, y as loadMachineContext, z as buildMachineInstructions, A as machineToolsForRole, B as buildMachineTools } from './run-21-bMHCc.mjs';
2
2
  import 'node:child_process';
3
3
  import 'os';
4
4
  import 'fs/promises';
@@ -0,0 +1,125 @@
1
+ import { existsSync, unlinkSync, readFileSync, readdirSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parse, stringify } from 'yaml';
4
+
5
+ function workflowSteps(wf) {
6
+ return Object.values(wf.jobs || {}).flatMap((j) => j?.steps || []);
7
+ }
8
+ function workflowCrons(wf) {
9
+ return (wf.on?.schedule || []).map((s) => s?.cron).filter((c) => !!c);
10
+ }
11
+ function workflowsDir(projectRoot) {
12
+ return join(projectRoot, ".svamp", "workflows");
13
+ }
14
+ function workflowPath(projectRoot, name) {
15
+ return join(workflowsDir(projectRoot), `${name}.yaml`);
16
+ }
17
+ function normalizeOn(on) {
18
+ if (!on || typeof on !== "object") {
19
+ return void 0;
20
+ }
21
+ const out = {};
22
+ if (Array.isArray(on.schedule)) {
23
+ const sched = on.schedule.map((s) => ({ cron: String(s?.cron ?? s ?? "") })).filter((s) => s.cron);
24
+ if (sched.length) out.schedule = sched;
25
+ } else if (typeof on.schedule === "string" && on.schedule.trim()) {
26
+ out.schedule = [{ cron: on.schedule.trim() }];
27
+ }
28
+ if ("workflow_dispatch" in on || on.dispatch === true) out.workflow_dispatch = true;
29
+ if (typeof on.channel === "string" && on.channel) out.channel = on.channel;
30
+ if (Array.isArray(on.issue) && on.issue.length) out.issue = on.issue.map(String);
31
+ return Object.keys(out).length ? out : void 0;
32
+ }
33
+ function normalizeJobs(jobs) {
34
+ if (Array.isArray(jobs)) {
35
+ const steps = jobs.map((j) => ({ run: String(j?.run ?? "") })).filter((s) => s.run);
36
+ return steps.length ? { run: { steps } } : {};
37
+ }
38
+ if (jobs && typeof jobs === "object") {
39
+ const out = {};
40
+ for (const [id, job] of Object.entries(jobs)) {
41
+ const rawSteps = Array.isArray(job?.steps) ? job.steps : job?.run ? [{ run: job.run }] : [];
42
+ const steps = rawSteps.map((s) => ({ run: String(s?.run ?? ""), ...s?.name ? { name: String(s.name) } : {} })).filter((s) => s.run);
43
+ if (steps.length) out[String(id)] = { steps };
44
+ }
45
+ return out;
46
+ }
47
+ return {};
48
+ }
49
+ function parseWorkflow(content) {
50
+ try {
51
+ const o = parse(content);
52
+ if (!o || typeof o !== "object" || !o.name) return null;
53
+ return {
54
+ name: String(o.name),
55
+ on: normalizeOn(o.on),
56
+ jobs: normalizeJobs(o.jobs),
57
+ session: o.session ?? null
58
+ };
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+ function serializeWorkflow(wf) {
64
+ const clean = { name: wf.name };
65
+ if (wf.session) clean.session = wf.session;
66
+ const on = {};
67
+ if (wf.on?.schedule?.length) on.schedule = wf.on.schedule.map((s) => ({ cron: s.cron }));
68
+ if (wf.on?.workflow_dispatch) on.workflow_dispatch = {};
69
+ if (wf.on?.channel) on.channel = wf.on.channel;
70
+ if (wf.on?.issue?.length) on.issue = wf.on.issue;
71
+ if (Object.keys(on).length) clean.on = on;
72
+ const jobs = {};
73
+ for (const [id, job] of Object.entries(wf.jobs || {})) {
74
+ jobs[id] = { steps: (job.steps || []).map((s) => s.name ? { name: s.name, run: s.run } : { run: s.run }) };
75
+ }
76
+ clean.jobs = Object.keys(jobs).length ? jobs : { run: { steps: [] } };
77
+ return stringify(clean);
78
+ }
79
+ function listWorkflows(projectRoot) {
80
+ const dir = workflowsDir(projectRoot);
81
+ if (!existsSync(dir)) return [];
82
+ const out = [];
83
+ for (const name of readdirSync(dir)) {
84
+ if (!name.endsWith(".yaml") && !name.endsWith(".yml")) continue;
85
+ try {
86
+ const wf = parseWorkflow(readFileSync(join(dir, name), "utf-8"));
87
+ if (wf) out.push(wf);
88
+ } catch {
89
+ }
90
+ }
91
+ return out.sort((a, b) => a.name.localeCompare(b.name));
92
+ }
93
+ function getWorkflow(projectRoot, name) {
94
+ const p = workflowPath(projectRoot, name);
95
+ if (!existsSync(p)) return null;
96
+ try {
97
+ return parseWorkflow(readFileSync(p, "utf-8"));
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+ function rawWorkflow(projectRoot, name) {
103
+ const p = workflowPath(projectRoot, name);
104
+ return existsSync(p) ? readFileSync(p, "utf-8") : null;
105
+ }
106
+ function saveWorkflow(projectRoot, wf) {
107
+ const dir = workflowsDir(projectRoot);
108
+ mkdirSync(dir, { recursive: true });
109
+ const path = workflowPath(projectRoot, wf.name);
110
+ const tmp = `${path}.tmp-${process.pid}`;
111
+ writeFileSync(tmp, serializeWorkflow(wf));
112
+ renameSync(tmp, path);
113
+ }
114
+ function removeWorkflow(projectRoot, name) {
115
+ const p = workflowPath(projectRoot, name);
116
+ if (!existsSync(p)) return false;
117
+ try {
118
+ unlinkSync(p);
119
+ return true;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ export { rawWorkflow as a, workflowCrons as b, getWorkflow as g, listWorkflows as l, removeWorkflow as r, saveWorkflow as s, workflowSteps as w };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svamp-cli",
3
- "version": "0.2.175",
3
+ "version": "0.2.177",
4
4
  "description": "Svamp CLI — AI workspace daemon on Hypha Cloud",
5
5
  "author": "Amun AI AB",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -20,7 +20,7 @@
20
20
  "scripts": {
21
21
  "build": "rm -rf dist bin/skills && mkdir -p bin/skills && cp -r ../../skills/artifact bin/skills/artifact && cp -r ../../skills/loop bin/skills/loop && cp -r ../../skills/crew bin/skills/crew && tsc --noEmit && pkgroll",
22
22
  "typecheck": "tsc --noEmit",
23
- "test": "npx tsx test/test-context-window.mjs && npx tsx test/test-ratelimit-retry.mjs && npx tsx test/test-instance-config.mjs && npx tsx test/test-authorize.mjs && npx tsx test/test-normalize-allowed-user.mjs && npx tsx test/test-share-url.mjs && npx tsx test/test-update-sharing-normalization.mjs && npx tsx test/test-sharing-notify-sync.mjs && npx tsx test/test-staged-homes-sweep.mjs && npx tsx test/test-session-helpers.mjs && npx tsx test/test-cli-routing.mjs && npx tsx test/test-security-context.mjs && npx tsx test/test-isolation-decision.mjs && npx tsx test/test-loop-activation.mjs && npx tsx test/test-message-helpers.mjs && npx tsx test/test-agent-config.mjs && npx tsx test/test-wrap-command.mjs && npx tsx test/test-credential-staging.mjs && npx tsx test/test-claude-auth.mjs && npx tsx test/test-output-formatters.mjs && npx tsx test/test-inbox-guard.mjs && npx tsx test/test-auto-topic.mjs && npx tsx test/test-project-info.mjs && npx tsx test/test-agent-types.mjs && npx tsx test/test-transport.mjs && npx tsx test/test-session-update-handlers.mjs && npx tsx test/test-session-scanner.mjs && npx tsx test/test-hypha-client.mjs && npx tsx test/test-hook-settings.mjs && npx tsx test/test-session-service-logic.mjs && npx tsx test/test-daemon-persistence.mjs && npx tsx test/test-detect-isolation.mjs && npx tsx test/test-machine-service-logic.mjs && npx tsx test/test-interactive-helpers.mjs && npx tsx test/test-codex-backend.mjs && npx tsx test/test-acp-backend.mjs && npx tsx test/test-acp-bridge.mjs && npx tsx test/test-hook-server.mjs && npx tsx test/test-session-commands.mjs && npx tsx test/test-interactive-console.mjs && npx tsx test/test-session-messages.mjs && npx tsx test/test-session-send-query.mjs && npx tsx test/test-skills.mjs && npx tsx test/test-agent-grouping.mjs && npx tsx test/test-machine-list-directory.mjs && npx tsx test/test-service-commands.mjs && npx tsx test/test-supervisor.mjs && npx tsx test/test-supervisor-lock.mjs && node test/test-supervisor-restart.mjs && npx tsx test/test-clear-detection.mjs && npx tsx test/test-session-consolidation.mjs && npx tsx test/test-inbox.mjs && npx tsx test/test-inbox-cross-machine.mjs && npx tsx test/test-issue-store.mjs && npx tsx test/test-issue-close-gate.mjs && npx tsx test/test-issue-rpc.mjs && npx tsx test/test-workflow-store.mjs && npx tsx test/test-workflow-rpc.mjs && npx tsx test/test-serve-link-subdomain.mjs && npx tsx test/test-short-id.mjs && npx tsx test/test-transcript-edit.mjs && npx tsx test/test-edit-history.mjs && npx tsx test/test-friendly-name.mjs && npx tsx test/test-session-rpc-dispatch.mjs && npx tsx test/test-sandbox-cli.mjs && npx tsx test/test-serve-manager.mjs && npx tsx test/test-serve-stability.mjs && npx tsx test/test-frpc-e2e.mjs --unit-only && npx tsx test/test-frpc-status.mjs && node test/pinnedClaudeCode.test.mjs && node test/fleet.test.mjs && npx tsx test/test-routine.mjs && npx tsx test/test-routine-rpc.mjs && npx tsx test/test-session-file.mjs && npx tsx test/test-channel-rpc.mjs && npx tsx test/test-wise-agent.mjs && npx tsx test/test-channel-agent.mjs && npx tsx test/test-channels-service.mjs && npx tsx test/test-channel-async-reply.mjs && npx tsx test/test-channel-binding.mjs && npx tsx test/test-channel-identity.mjs && npx tsx test/test-shared-session-identity.mjs && npx tsx test/test-wise-agent-auth.mjs && npx tsx test/test-channel-http.mjs && npx tsx test/test-wise-voice.mjs && npx tsx test/test-wise-headless.mjs && npx tsx test/test-wise-machine.mjs && npx tsx test/test-crew-merge.mjs && npx tsx test/test-crew-verdict-routing.mjs",
23
+ "test": "npx tsx test/test-context-window.mjs && npx tsx test/test-ratelimit-retry.mjs && npx tsx test/test-instance-config.mjs && npx tsx test/test-authorize.mjs && npx tsx test/test-normalize-allowed-user.mjs && npx tsx test/test-share-url.mjs && npx tsx test/test-update-sharing-normalization.mjs && npx tsx test/test-sharing-notify-sync.mjs && npx tsx test/test-staged-homes-sweep.mjs && npx tsx test/test-session-helpers.mjs && npx tsx test/test-cli-routing.mjs && npx tsx test/test-security-context.mjs && npx tsx test/test-isolation-decision.mjs && npx tsx test/test-loop-activation.mjs && npx tsx test/test-message-helpers.mjs && npx tsx test/test-agent-config.mjs && npx tsx test/test-wrap-command.mjs && npx tsx test/test-credential-staging.mjs && npx tsx test/test-claude-auth.mjs && npx tsx test/test-output-formatters.mjs && npx tsx test/test-inbox-guard.mjs && npx tsx test/test-auto-topic.mjs && npx tsx test/test-project-info.mjs && npx tsx test/test-agent-types.mjs && npx tsx test/test-transport.mjs && npx tsx test/test-session-update-handlers.mjs && npx tsx test/test-session-scanner.mjs && npx tsx test/test-hypha-client.mjs && npx tsx test/test-hook-settings.mjs && npx tsx test/test-session-service-logic.mjs && npx tsx test/test-daemon-persistence.mjs && npx tsx test/test-detect-isolation.mjs && npx tsx test/test-machine-service-logic.mjs && npx tsx test/test-interactive-helpers.mjs && npx tsx test/test-codex-backend.mjs && npx tsx test/test-acp-backend.mjs && npx tsx test/test-acp-bridge.mjs && npx tsx test/test-hook-server.mjs && npx tsx test/test-session-commands.mjs && npx tsx test/test-interactive-console.mjs && npx tsx test/test-session-messages.mjs && npx tsx test/test-session-send-query.mjs && npx tsx test/test-skills.mjs && npx tsx test/test-agent-grouping.mjs && npx tsx test/test-machine-list-directory.mjs && npx tsx test/test-service-commands.mjs && npx tsx test/test-supervisor.mjs && npx tsx test/test-supervisor-lock.mjs && node test/test-supervisor-restart.mjs && npx tsx test/test-clear-detection.mjs && npx tsx test/test-session-consolidation.mjs && npx tsx test/test-inbox.mjs && npx tsx test/test-inbox-cross-machine.mjs && npx tsx test/test-issue-store.mjs && npx tsx test/test-issue-close-gate.mjs && npx tsx test/test-issue-rpc.mjs && npx tsx test/test-workflow-store.mjs && npx tsx test/test-workflow-rpc.mjs && npx tsx test/test-workflow-scheduler.mjs && npx tsx test/test-serve-link-subdomain.mjs && npx tsx test/test-short-id.mjs && npx tsx test/test-transcript-edit.mjs && npx tsx test/test-edit-history.mjs && npx tsx test/test-friendly-name.mjs && npx tsx test/test-session-rpc-dispatch.mjs && npx tsx test/test-sandbox-cli.mjs && npx tsx test/test-serve-manager.mjs && npx tsx test/test-serve-stability.mjs && npx tsx test/test-frpc-e2e.mjs --unit-only && npx tsx test/test-frpc-status.mjs && node test/pinnedClaudeCode.test.mjs && node test/fleet.test.mjs && npx tsx test/test-session-file.mjs && npx tsx test/test-channel-rpc.mjs && npx tsx test/test-wise-agent.mjs && npx tsx test/test-channel-agent.mjs && npx tsx test/test-channels-service.mjs && npx tsx test/test-channel-async-reply.mjs && npx tsx test/test-channel-binding.mjs && npx tsx test/test-channel-identity.mjs && npx tsx test/test-shared-session-identity.mjs && npx tsx test/test-wise-agent-auth.mjs && npx tsx test/test-channel-http.mjs && npx tsx test/test-wise-voice.mjs && npx tsx test/test-wise-headless.mjs && npx tsx test/test-wise-machine.mjs && npx tsx test/test-crew-merge.mjs && npx tsx test/test-crew-verdict-routing.mjs",
24
24
  "test:hypha": "node --no-warnings test/test-hypha-service.mjs",
25
25
  "dev": "tsx src/cli.ts",
26
26
  "dev:daemon": "tsx src/cli.ts daemon start-sync",
@@ -1,121 +0,0 @@
1
- #!/usr/bin/env node
2
- // routine-cli.mjs — manage + run routines.
3
- // add/list/remove/enable/disable/run-now — CRUD + manual fire
4
- // serve [--port N] — scheduler tick + webhook HTTP server
5
- //
6
- // Delivery uses the svamp CLI: a fired routine becomes a message (or loop
7
- // kickoff) into the bound session via `svamp session send`.
8
- import { execFileSync } from 'node:child_process';
9
- import { createServer } from 'node:http';
10
- import { dirname, join, resolve } from 'node:path';
11
- import { fileURLToPath } from 'node:url';
12
- import { RoutineStore } from './routine-store.mjs';
13
- import { RoutineRunner } from './routine-runner.mjs';
14
-
15
- const HERE = dirname(fileURLToPath(import.meta.url));
16
- const LOOP_INIT = join(HERE, 'loop-init.mjs');
17
-
18
- function parseArgs(argv) {
19
- const a = { _: [] };
20
- for (let i = 0; i < argv.length; i++) {
21
- const t = argv[i];
22
- if (t.startsWith('--')) a[t.slice(2)] = (argv[i + 1] && !argv[i + 1].startsWith('--')) ? argv[++i] : true;
23
- else a._.push(t);
24
- }
25
- return a;
26
- }
27
-
28
- // ---- real delivery via svamp CLI ---------------------------------------
29
- async function cliDeliver({ routine, resolved }) {
30
- const sid = routine.session_id;
31
- if (resolved.kind === 'message') {
32
- execFileSync('svamp', ['session', 'send', sid, resolved.text], { stdio: 'pipe' });
33
- return;
34
- }
35
- // loop action: set up the loop in the project dir, then kick the session.
36
- const dir = routine.action.loop?.dir || routine.dir;
37
- if (dir) {
38
- const initArgs = [LOOP_INIT, resolve(dir), '--task', resolved.task || routine.action.loop?.task || '(task)'];
39
- if (routine.action.loop?.oracle) initArgs.push('--oracle', routine.action.loop.oracle);
40
- initArgs.push('--evaluator', routine.action.loop?.evaluator || 'off');
41
- execFileSync(process.execPath, initArgs, { stdio: 'pipe' });
42
- }
43
- execFileSync('svamp', ['session', 'send', sid,
44
- `Begin the loop (LOOP MODE). Task: ${resolved.task}. Iterate until the gate lets you stop.`], { stdio: 'pipe' });
45
- }
46
-
47
- function buildRoutineFromArgs(a) {
48
- const r = { session_id: a.session, name: a.name || 'routine', overlap: a.overlap || 'queue' };
49
- if (a.schedule) r.trigger = { type: 'schedule', cron: a.schedule, missed: a.missed || 'skip', tz: a.tz };
50
- else if (a.webhook || a.api) r.trigger = { type: a.api ? 'api' : 'webhook', methods: a.get ? ['GET'] : ['GET', 'POST'], public: !!a.public };
51
- else r.trigger = { type: 'manual' };
52
- if (a.loop) r.action = { kind: 'loop', task_template: a.task || '', loop: { dir: a.dir, oracle: a.oracle, evaluator: a.evaluator || 'off' } };
53
- else r.action = { kind: 'message', template: a.message || a.task || 'run' };
54
- return r;
55
- }
56
-
57
- async function main() {
58
- const a = parseArgs(process.argv.slice(2));
59
- const cmd = a._[0];
60
- const store = new RoutineStore();
61
- const runner = new RoutineRunner({ store, deliver: cliDeliver, log: console.error });
62
-
63
- switch (cmd) {
64
- case 'add': {
65
- if (!a.session) { console.error('--session <id> required'); process.exit(1); }
66
- const r = store.save(buildRoutineFromArgs(a));
67
- console.log(`✅ added routine ${r.id} (${r.name}) trigger=${r.trigger.type} action=${r.action.kind}`);
68
- if (r.trigger.key) console.log(` webhook key: ${r.trigger.key}`);
69
- break;
70
- }
71
- case 'list': {
72
- const rs = store.list();
73
- if (a.json) { console.log(JSON.stringify(rs, null, 2)); break; }
74
- if (!rs.length) { console.log('(no routines)'); break; }
75
- for (const r of rs) console.log(`${r.enabled ? '●' : '○'} ${r.id} ${r.name} [${r.trigger.type}${r.trigger.cron ? ' ' + r.trigger.cron : ''}] -> ${r.action.kind} session=${r.session_id}`);
76
- break;
77
- }
78
- case 'remove': ok(store.remove(a._[1]), `removed ${a._[1]}`); break;
79
- case 'enable': store.setEnabled(a._[1], true); console.log(`enabled ${a._[1]}`); break;
80
- case 'disable': store.setEnabled(a._[1], false); console.log(`disabled ${a._[1]}`); break;
81
- case 'run-now': console.log(JSON.stringify(await runner.runNow(a._[1], {}), null, 2)); break;
82
- case 'serve': await serve(runner, store, Number(a.port) || 8722); break;
83
- default:
84
- console.log(`usage: routine-cli <add|list|remove|enable|disable|run-now|serve>
85
- add --session <id> --name <n> [--schedule "*/5 * * * *" [--missed catchup]] [--webhook|--api [--get] [--public]] (--message "..." | --loop --dir <path> --task "..." [--oracle "cmd"])
86
- serve [--port 8722]`);
87
- }
88
- function ok(c, m) { console.log(c ? '✅ ' + m : '✗ ' + m); }
89
- }
90
-
91
- async function serve(runner, store, port) {
92
- // catch up missed schedules, then tick every 20s (per-minute dedupe inside).
93
- await runner.catchUp(new Date(Date.now() - 24 * 3600 * 1000), new Date());
94
- const timer = setInterval(() => { runner.tick(new Date()).catch((e) => console.error('tick error', e)); }, 20000);
95
-
96
- const server = createServer((req, res) => {
97
- const u = new URL(req.url, `http://localhost:${port}`);
98
- if (u.pathname === '/' || u.pathname === '/health') { res.writeHead(200).end('routines ok'); return; }
99
- const m = u.pathname.match(/^\/routine\/([\w.-]+)$/);
100
- if (!m) { res.writeHead(404).end('not found'); return; }
101
- const id = m[1];
102
- const key = u.searchParams.get('key');
103
- const query = Object.fromEntries(u.searchParams.entries());
104
- let chunks = '';
105
- req.on('data', (c) => { chunks += c; if (chunks.length > 1e6) req.destroy(); });
106
- req.on('end', async () => {
107
- let body = {}; try { body = chunks ? JSON.parse(chunks) : {}; } catch {}
108
- const result = await runner.webhook(id, { key, method: req.method, body, query });
109
- res.writeHead(result.status || 200, { 'content-type': 'application/json' }).end(JSON.stringify(result));
110
- });
111
- });
112
- server.listen(port, () => {
113
- console.log(`🪝 routine server on http://localhost:${port}`);
114
- console.log(` webhook URL pattern: http://localhost:${port}/routine/<id>?key=<key>`);
115
- console.log(` expose publicly: svamp service expose routines --port ${port}`);
116
- console.log(` scheduler: ticking every 20s`);
117
- });
118
- process.on('SIGINT', () => { clearInterval(timer); server.close(); process.exit(0); });
119
- }
120
-
121
- main().catch((e) => { console.error(e); process.exit(1); });
@@ -1,125 +0,0 @@
1
- #!/usr/bin/env node
2
- // routine-runner.mjs — fires routines (schedule tick / webhook / manual),
3
- // applies overlap + missed-run policies, and delivers the resolved action
4
- // (message | loop) via an injectable `deliver` fn. Pure logic; the actual
5
- // CLI delivery + timer + HTTP server live in routine-cli.mjs.
6
- import { cronMatches, renderTemplate, nextFire, inZone } from './routine-core.mjs';
7
-
8
- const minuteKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}-${d.getHours()}-${d.getMinutes()}`;
9
-
10
- export class RoutineRunner {
11
- /** @param {{store, deliver:(ctx)=>Promise<any>, now?:()=>Date, onReplace?:(id)=>void, log?:Function}} o */
12
- constructor({ store, deliver, now = () => new Date(), onReplace, log = () => {} }) {
13
- this.store = store; this.deliver = deliver; this.now = now; this.onReplace = onReplace; this.log = log;
14
- this.active = new Set(); // routine ids with an in-flight loop run
15
- this._firedMinute = new Map(); // id -> minuteKey (in-memory dedupe within a process)
16
- }
17
-
18
- resolveAction(routine, payload = {}) {
19
- const ctx = { ...payload, now: this.now().toISOString(), routine: { id: routine.id, name: routine.name } };
20
- const a = routine.action;
21
- if (a.kind === 'message') return { kind: 'message', text: renderTemplate(a.template, ctx) };
22
- const task = renderTemplate(a.task_template || a.loop?.task || '', ctx);
23
- return { kind: 'loop', task, loop: a.loop };
24
- }
25
-
26
- markDone(id) { this.active.delete(id); }
27
-
28
- /** Deliveries counted today via a dedicated counter (NOT derived from the
29
- * capped last_runs audit, which would undercount past 20 runs/day). */
30
- _deliveredToday(routine) {
31
- const today = this.now().toDateString();
32
- return routine.daily && routine.daily.date === today ? routine.daily.n : 0;
33
- }
34
-
35
- /** Core fire path with overlap + daily-cap policy. via = 'schedule'|'webhook'|'api'|'manual'.
36
- * NOTE: `active` guards genuinely CONCURRENT in-flight deliveries only — the runner
37
- * cannot observe a spawned loop's full duration (delivery is fire-and-forget), so it
38
- * is cleared once delivery returns. True loop-duration overlap needs a loop-state
39
- * signal (future). `replace` calls onReplace (best-effort) then proceeds. */
40
- async fire(routine, payload = {}, via = 'manual') {
41
- if (!routine.enabled) return { skipped: 'disabled' };
42
- // Defense-in-depth (also enforced at validate time): never run a loop action
43
- // from a public/keyless webhook (unauthenticated task injection).
44
- if (routine.action?.kind === 'loop' && (routine.trigger?.type === 'webhook' || routine.trigger?.type === 'api') && routine.trigger?.public)
45
- return { skipped: 'forbidden (public webhook + loop)' };
46
- const today = this.now().toDateString();
47
- // Reserve a daily slot SYNCHRONOUSLY (before any await) so concurrent fires
48
- // serialize on the JS event loop and cannot all read n=0 and over-deliver.
49
- const r0 = this.store.get(routine.id) || routine;
50
- const usedToday = (r0.daily && r0.daily.date === today) ? r0.daily.n : 0;
51
- if (routine.daily_cap && usedToday >= routine.daily_cap) {
52
- this.store.recordRun(routine.id, { via, delivered: routine.action.kind, outcome: 'skipped (daily cap)' });
53
- return { skipped: 'daily_cap' };
54
- }
55
- if (this.active.has(routine.id)) {
56
- if (routine.overlap === 'skip') { this.store.recordRun(routine.id, { via, delivered: routine.action.kind, outcome: 'skipped (busy)' }); return { skipped: 'busy' }; }
57
- if (routine.overlap === 'replace') { try { this.onReplace?.(routine.id); } catch {} }
58
- // 'queue'/'replace' -> proceed
59
- }
60
- this.active.add(routine.id);
61
- if (routine.daily_cap) { r0.daily = { date: today, n: usedToday + 1 }; this.store.save(r0); } // reserve
62
- const resolved = this.resolveAction(routine, payload);
63
- let outcome = 'delivered';
64
- try {
65
- await this.deliver({ routine, action: routine.action, resolved, payload, via });
66
- } catch (e) {
67
- outcome = 'error: ' + (e?.message || e);
68
- if (routine.daily_cap) { const rb = this.store.get(routine.id); if (rb?.daily?.date === today && rb.daily.n > 0) { rb.daily.n -= 1; this.store.save(rb); } } // roll back reservation
69
- } finally { this.active.delete(routine.id); }
70
- const r = this.store.get(routine.id) || routine;
71
- r.last_fired_at = this.now().toISOString();
72
- r.last_runs = [{ firedAt: r.last_fired_at, via, delivered: resolved.kind, outcome }, ...(r.last_runs || [])].slice(0, 20);
73
- this.store.save(r);
74
- return { fired: true, via, resolved, outcome };
75
- }
76
-
77
- /** Schedule tick — fire any matching schedule routine once per minute. */
78
- async tick(date = this.now()) {
79
- const results = [];
80
- for (const r of this.store.list()) {
81
- if (!r.enabled || r.trigger?.type !== 'schedule') continue;
82
- if (!cronMatches(r.trigger.cron, inZone(date, r.trigger.tz))) continue;
83
- const mk = minuteKey(date);
84
- if (this._firedMinute.get(r.id) === mk) continue; // already fired this minute
85
- this._firedMinute.set(r.id, mk);
86
- results.push({ id: r.id, ...(await this.fire(r, { tick: date.toISOString() }, 'schedule')) });
87
- }
88
- return results;
89
- }
90
-
91
- /** Webhook/api fire: validate key + method, then fire with payload. */
92
- async webhook(id, { key, method = 'POST', body = {}, query = {} } = {}) {
93
- const r = this.store.get(id);
94
- if (!r) return { status: 404, error: 'routine not found' };
95
- if (r.trigger?.type !== 'webhook' && r.trigger?.type !== 'api') return { status: 400, error: 'not a webhook routine' };
96
- if (!r.enabled) return { status: 409, error: 'routine disabled' };
97
- if (!r.trigger.public && r.trigger.key && key !== r.trigger.key) return { status: 401, error: 'bad key' };
98
- const methods = r.trigger.methods || ['GET', 'POST'];
99
- if (!methods.includes(method)) return { status: 405, error: 'method not allowed' };
100
- const res = await this.fire(r, { body, query }, r.trigger.type);
101
- return { status: 200, ...res };
102
- }
103
-
104
- async runNow(id, payload = {}) {
105
- const r = this.store.get(id);
106
- if (!r) return { error: 'not found' };
107
- return this.fire(r, payload, 'manual');
108
- }
109
-
110
- /** On startup, fire schedule routines whose time was missed while down (catchup). */
111
- async catchUp(sinceDate, nowDate = this.now()) {
112
- const results = [];
113
- for (const r of this.store.list()) {
114
- if (!r.enabled || r.trigger?.type !== 'schedule' || r.trigger.missed !== 'catchup') continue;
115
- const deadlineMs = (r.trigger.deadline_sec || 3600) * 1000;
116
- const since = new Date(Math.max(sinceDate.getTime(), nowDate.getTime() - deadlineMs));
117
- const due = nextFire(r.trigger.cron, since, r.trigger.tz); // first scheduled time after `since` (in the routine's tz)
118
- if (due && due <= nowDate) {
119
- const last = r.last_fired_at ? new Date(r.last_fired_at) : new Date(0);
120
- if (last < due) results.push({ id: r.id, ...(await this.fire(r, { catchup: due.toISOString() }, 'schedule')) });
121
- }
122
- }
123
- return results;
124
- }
125
- }
@@ -1,49 +0,0 @@
1
- #!/usr/bin/env node
2
- // routine-store.mjs — persistence + CRUD for routine specs.
3
- // Specs live as JSON files under a routines dir (default ~/.svamp/routines/),
4
- // one file per routine, atomic writes, restored on restart.
5
- import { mkdirSync, writeFileSync, renameSync, readdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
6
- import { homedir } from 'node:os';
7
- import { join } from 'node:path';
8
- import { randomBytes } from 'node:crypto';
9
- import { validateRoutine } from './routine-core.mjs';
10
-
11
- export function defaultRoutinesDir() {
12
- return process.env.SVAMP_ROUTINES_DIR || join(homedir(), '.svamp', 'routines');
13
- }
14
- const genId = () => 'rt_' + randomBytes(5).toString('hex');
15
- const genKey = () => randomBytes(18).toString('base64url');
16
-
17
- export class RoutineStore {
18
- constructor(dir = defaultRoutinesDir()) { this.dir = dir; mkdirSync(dir, { recursive: true }); }
19
- _path(id) { return join(this.dir, `${id}.json`); }
20
-
21
- list() {
22
- return readdirSync(this.dir).filter((f) => f.endsWith('.json')).map((f) => {
23
- try { return JSON.parse(readFileSync(join(this.dir, f), 'utf8')); } catch { return null; }
24
- }).filter(Boolean);
25
- }
26
- get(id) { try { return JSON.parse(readFileSync(this._path(id), 'utf8')); } catch { return null; } }
27
-
28
- /** Create/update. Assigns id, key (for webhook/api), defaults; validates. */
29
- save(routine) {
30
- const r = { overlap: 'queue', enabled: true, last_runs: [], ...routine };
31
- if (!r.id) r.id = genId();
32
- if ((r.trigger?.type === 'webhook' || r.trigger?.type === 'api') && !r.trigger.key) r.trigger.key = genKey();
33
- const errs = validateRoutine(r);
34
- if (errs.length) throw new Error('invalid routine: ' + errs.join('; '));
35
- const tmp = this._path(r.id) + '.tmp';
36
- writeFileSync(tmp, JSON.stringify(r, null, 2));
37
- renameSync(tmp, this._path(r.id));
38
- return r;
39
- }
40
- remove(id) { const p = this._path(id); if (existsSync(p)) { rmSync(p); return true; } return false; }
41
- setEnabled(id, enabled) { const r = this.get(id); if (!r) return null; r.enabled = enabled; return this.save(r); }
42
-
43
- /** Append a capped run-history entry. */
44
- recordRun(id, entry) {
45
- const r = this.get(id); if (!r) return null;
46
- r.last_runs = [{ firedAt: new Date().toISOString(), ...entry }, ...(r.last_runs || [])].slice(0, 20);
47
- return this.save(r);
48
- }
49
- }
@@ -1,20 +0,0 @@
1
- # Supervised routine server — run the scheduler + webhook dispatcher persistently.
2
- # svamp process apply skills/loop/routines.process.yaml
3
- # svamp service expose routines --port 8722 # public capability URLs
4
- # Adjust the absolute path to wherever the loop skill is installed.
5
- id: routines
6
- name: routines
7
- command: node
8
- args:
9
- - "/Users/weio/workspace/hypha-cloud/skills/loop/bin/routine-cli.mjs"
10
- - "serve"
11
- - "--port"
12
- - "8722"
13
- workdir: /Users/weio/workspace/hypha-cloud
14
- env:
15
- SVAMP_ROUTINES_DIR: "/Users/weio/.svamp/routines"
16
- keepAlive: true
17
- probe:
18
- type: http
19
- port: 8722
20
- path: /health