@hasna/loops 0.3.11 → 0.3.13

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.
package/dist/sdk/index.js CHANGED
@@ -368,6 +368,35 @@ function validateTarget(value, label) {
368
368
  if (value.provider !== "codewith")
369
369
  throw new Error(`${label}.authProfile is currently supported only for provider codewith`);
370
370
  }
371
+ if (value.variant !== undefined)
372
+ assertString(value.variant, `${label}.variant`);
373
+ if (value.permissionMode !== undefined) {
374
+ assertString(value.permissionMode, `${label}.permissionMode`);
375
+ const permissionModes = ["default", "plan", "auto", "bypass"];
376
+ if (!permissionModes.includes(value.permissionMode)) {
377
+ throw new Error(`${label}.permissionMode must be one of ${permissionModes.join(", ")}`);
378
+ }
379
+ if (value.permissionMode === "plan" && !["claude", "cursor"].includes(value.provider)) {
380
+ throw new Error(`${label}.permissionMode plan is currently supported only for provider claude or cursor`);
381
+ }
382
+ if (value.permissionMode === "auto" && value.provider !== "claude") {
383
+ throw new Error(`${label}.permissionMode auto is currently supported only for provider claude`);
384
+ }
385
+ }
386
+ if (value.sandbox !== undefined) {
387
+ assertString(value.sandbox, `${label}.sandbox`);
388
+ const codexLike = ["read-only", "workspace-write", "danger-full-access"];
389
+ const cursorLike = ["enabled", "disabled"];
390
+ if (["codewith", "codex"].includes(value.provider)) {
391
+ if (!codexLike.includes(value.sandbox))
392
+ throw new Error(`${label}.sandbox must be one of ${codexLike.join(", ")}`);
393
+ } else if (value.provider === "cursor") {
394
+ if (!cursorLike.includes(value.sandbox))
395
+ throw new Error(`${label}.sandbox must be one of ${cursorLike.join(", ")}`);
396
+ } else {
397
+ throw new Error(`${label}.sandbox is currently supported only for provider codewith, codex, or cursor`);
398
+ }
399
+ }
371
400
  return value;
372
401
  }
373
402
  throw new Error(`${label}.type must be command or agent`);
@@ -2353,7 +2382,7 @@ function providerCommand(provider) {
2353
2382
  case "claude":
2354
2383
  return "claude";
2355
2384
  case "cursor":
2356
- return "cursor-agent";
2385
+ return "sh";
2357
2386
  case "codewith":
2358
2387
  return "codewith";
2359
2388
  case "aicopilot":
@@ -2364,22 +2393,107 @@ function providerCommand(provider) {
2364
2393
  return "codex";
2365
2394
  }
2366
2395
  }
2396
+ function codewithLikeSandbox(target) {
2397
+ const sandbox = target.sandbox ?? (target.permissionMode === "bypass" ? "danger-full-access" : "workspace-write");
2398
+ if (sandbox !== "read-only" && sandbox !== "workspace-write" && sandbox !== "danger-full-access") {
2399
+ throw new Error(`${target.provider} sandbox must be read-only, workspace-write, or danger-full-access`);
2400
+ }
2401
+ return sandbox;
2402
+ }
2403
+ function configStringValue(value) {
2404
+ return JSON.stringify(value);
2405
+ }
2406
+ function assertStringOption(value, label) {
2407
+ if (value !== undefined && typeof value !== "string")
2408
+ throw new Error(`${label} must be a string`);
2409
+ }
2410
+ function assertSupportedAgentOptions(target) {
2411
+ assertStringOption(target.variant, `${target.provider}.variant`);
2412
+ assertStringOption(target.model, `${target.provider}.model`);
2413
+ assertStringOption(target.agent, `${target.provider}.agent`);
2414
+ assertStringOption(target.authProfile, `${target.provider}.authProfile`);
2415
+ if (target.authProfile !== undefined && target.provider !== "codewith") {
2416
+ throw new Error(`${target.provider}.authProfile is supported only for codewith`);
2417
+ }
2418
+ if (target.permissionMode && !["default", "plan", "auto", "bypass"].includes(target.permissionMode)) {
2419
+ throw new Error(`${target.provider}.permissionMode must be default, plan, auto, or bypass`);
2420
+ }
2421
+ if (target.sandbox && !["read-only", "workspace-write", "danger-full-access", "enabled", "disabled"].includes(target.sandbox)) {
2422
+ throw new Error(`${target.provider}.sandbox is not supported: ${target.sandbox}`);
2423
+ }
2424
+ if (["codewith", "codex"].includes(target.provider)) {
2425
+ if (target.permissionMode && !["default", "bypass"].includes(target.permissionMode)) {
2426
+ throw new Error(`${target.provider}.permissionMode supports only default or bypass`);
2427
+ }
2428
+ if (target.sandbox)
2429
+ codewithLikeSandbox(target);
2430
+ return;
2431
+ }
2432
+ if (target.provider === "claude") {
2433
+ if (target.sandbox !== undefined)
2434
+ throw new Error("claude.sandbox is not supported");
2435
+ return;
2436
+ }
2437
+ if (target.provider === "cursor") {
2438
+ if (target.permissionMode === "auto")
2439
+ throw new Error("cursor.permissionMode auto is not supported; use provider-specific extraArgs for Cursor auto-review");
2440
+ if (target.sandbox !== undefined && target.sandbox !== "enabled" && target.sandbox !== "disabled") {
2441
+ throw new Error("cursor.sandbox must be enabled or disabled");
2442
+ }
2443
+ return;
2444
+ }
2445
+ if (target.permissionMode && !["default", "bypass"].includes(target.permissionMode)) {
2446
+ throw new Error(`${target.provider}.permissionMode supports only default or bypass`);
2447
+ }
2448
+ if (target.sandbox !== undefined)
2449
+ throw new Error(`${target.provider}.sandbox is not supported`);
2450
+ }
2367
2451
  function agentArgs(target) {
2452
+ assertSupportedAgentOptions(target);
2368
2453
  const isolation = target.configIsolation ?? "safe";
2454
+ const permissionMode = target.permissionMode ?? "default";
2369
2455
  const args = [];
2370
2456
  switch (target.provider) {
2371
2457
  case "claude":
2372
2458
  if (isolation === "safe")
2373
2459
  args.push("--safe-mode", "--setting-sources", "local", "--no-session-persistence");
2460
+ if (permissionMode !== "default") {
2461
+ const mode = permissionMode === "bypass" ? "bypassPermissions" : permissionMode === "plan" || permissionMode === "auto" ? permissionMode : undefined;
2462
+ if (mode)
2463
+ args.push("--permission-mode", mode);
2464
+ }
2374
2465
  args.push("-p", "--output-format", "json");
2375
2466
  if (target.model)
2376
2467
  args.push("--model", target.model);
2468
+ if (target.variant)
2469
+ args.push("--effort", target.variant);
2377
2470
  if (target.agent)
2378
2471
  args.push("--agent", target.agent);
2379
2472
  args.push(...target.extraArgs ?? []);
2380
2473
  return args;
2381
2474
  case "cursor":
2382
- args.push("-p");
2475
+ args.push("-c", [
2476
+ "set -eu",
2477
+ "if command -v cursor >/dev/null 2>&1; then",
2478
+ ' exec cursor agent "$@"',
2479
+ "elif command -v agent >/dev/null 2>&1; then",
2480
+ ' exec agent "$@"',
2481
+ "else",
2482
+ " echo 'Executable not found in PATH: cursor agent or agent' >&2",
2483
+ " exit 127",
2484
+ "fi"
2485
+ ].join(`
2486
+ `), "openloops-cursor", "-p");
2487
+ if (permissionMode === "plan")
2488
+ args.push("--mode", "plan");
2489
+ if (permissionMode === "bypass")
2490
+ args.push("--force");
2491
+ const cursorSandbox = target.sandbox ?? (isolation === "safe" ? "enabled" : undefined);
2492
+ if (cursorSandbox) {
2493
+ if (cursorSandbox !== "enabled" && cursorSandbox !== "disabled")
2494
+ throw new Error("cursor sandbox must be enabled or disabled");
2495
+ args.push("--sandbox", cursorSandbox);
2496
+ }
2383
2497
  if (target.model)
2384
2498
  args.push("--model", target.model);
2385
2499
  if (target.agent)
@@ -2387,7 +2501,10 @@ function agentArgs(target) {
2387
2501
  args.push(...target.extraArgs ?? []);
2388
2502
  return args;
2389
2503
  case "codewith":
2390
- args.push(...target.authProfile ? ["--auth-profile", target.authProfile] : [], "--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
2504
+ args.push(...target.authProfile ? ["--auth-profile", target.authProfile] : []);
2505
+ if (target.variant)
2506
+ args.push("-c", `model_reasoning_effort=${configStringValue(target.variant)}`);
2507
+ args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
2391
2508
  if (isolation === "safe")
2392
2509
  args.push("--ignore-rules");
2393
2510
  if (target.cwd)
@@ -2399,7 +2516,9 @@ function agentArgs(target) {
2399
2516
  args.push(...target.extraArgs ?? []);
2400
2517
  return args;
2401
2518
  case "codex":
2402
- args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
2519
+ if (target.variant)
2520
+ args.push("-c", `model_reasoning_effort=${configStringValue(target.variant)}`);
2521
+ args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", codewithLikeSandbox(target));
2403
2522
  if (isolation === "safe")
2404
2523
  args.push("--ignore-rules");
2405
2524
  if (target.cwd)
@@ -2412,10 +2531,14 @@ function agentArgs(target) {
2412
2531
  args.push("run", "--format", "json");
2413
2532
  if (isolation === "safe")
2414
2533
  args.push("--pure");
2534
+ if (permissionMode === "bypass")
2535
+ args.push("--dangerously-skip-permissions");
2415
2536
  if (target.cwd)
2416
2537
  args.push("--dir", target.cwd);
2417
2538
  if (target.model)
2418
2539
  args.push("--model", target.model);
2540
+ if (target.variant)
2541
+ args.push("--variant", target.variant);
2419
2542
  if (target.agent)
2420
2543
  args.push("--agent", target.agent);
2421
2544
  args.push(...target.extraArgs ?? []);
@@ -2424,10 +2547,14 @@ function agentArgs(target) {
2424
2547
  args.push("run", "--format", "json");
2425
2548
  if (isolation === "safe")
2426
2549
  args.push("--pure");
2550
+ if (permissionMode === "bypass")
2551
+ args.push("--dangerously-skip-permissions");
2427
2552
  if (target.cwd)
2428
2553
  args.push("--dir", target.cwd);
2429
2554
  if (target.model)
2430
2555
  args.push("--model", target.model);
2556
+ if (target.variant)
2557
+ args.push("--variant", target.variant);
2431
2558
  if (target.agent)
2432
2559
  args.push("--agent", target.agent);
2433
2560
  args.push(...target.extraArgs ?? []);
@@ -2456,6 +2583,7 @@ function commandSpec(target) {
2456
2583
  timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
2457
2584
  account: agentTarget.account,
2458
2585
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2586
+ preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
2459
2587
  stdin: agentTarget.prompt
2460
2588
  };
2461
2589
  }
@@ -2523,11 +2651,15 @@ function remoteScript(spec, metadata) {
2523
2651
  `;
2524
2652
  }
2525
2653
  function remotePreflightScript(spec, metadata) {
2526
- return [
2654
+ const lines = [
2527
2655
  ...remoteBootstrapLines(spec, metadata),
2528
2656
  "command -v bash >/dev/null 2>&1",
2529
2657
  `command -v ${shellQuote(spec.shell ? "sh" : spec.command)} >/dev/null 2>&1`
2530
- ].join(`
2658
+ ];
2659
+ if (spec.preflightAnyOf?.length) {
2660
+ lines.push(`if ! ${spec.preflightAnyOf.map((command) => `command -v ${shellQuote(command)} >/dev/null 2>&1`).join(" && ! ")}; then`, ` echo 'none of required executables found: ${spec.preflightAnyOf.join(", ")}' >&2`, " exit 127", "fi");
2661
+ }
2662
+ return lines.join(`
2531
2663
  `);
2532
2664
  }
2533
2665
  function transportEnv(opts) {
@@ -2680,6 +2812,9 @@ function preflightTarget(target, metadata = {}, opts = {}) {
2680
2812
  if (!spec.shell && !executableExists(spec.command, env)) {
2681
2813
  throw new Error(commandNotFoundMessage(spec.command, env));
2682
2814
  }
2815
+ if (spec.preflightAnyOf?.length && !spec.preflightAnyOf.some((command) => executableExists(command, env))) {
2816
+ throw new Error(`none of required executables found: ${spec.preflightAnyOf.join(", ")}`);
2817
+ }
2683
2818
  return {
2684
2819
  command: spec.command,
2685
2820
  accountProfile: spec.account?.profile,
@@ -2710,6 +2845,17 @@ async function executeTarget(target, metadata = {}, opts = {}) {
2710
2845
  durationMs: 0
2711
2846
  };
2712
2847
  }
2848
+ if (spec.preflightAnyOf?.length && !spec.preflightAnyOf.some((command) => executableExists(command, env))) {
2849
+ return {
2850
+ status: "failed",
2851
+ stdout,
2852
+ stderr,
2853
+ error: `none of required executables found: ${spec.preflightAnyOf.join(", ")}`,
2854
+ startedAt,
2855
+ finishedAt: nowIso(),
2856
+ durationMs: 0
2857
+ };
2858
+ }
2713
2859
  const child = spawn(spec.command, spec.args, {
2714
2860
  cwd: spec.cwd,
2715
2861
  env,
package/dist/types.d.ts CHANGED
@@ -52,17 +52,22 @@ export interface CommandTarget {
52
52
  }
53
53
  export type AgentProvider = "claude" | "cursor" | "codewith" | "aicopilot" | "opencode" | "codex";
54
54
  export type AgentConfigIsolation = "safe" | "none";
55
+ export type AgentPermissionMode = "default" | "plan" | "auto" | "bypass";
56
+ export type AgentSandbox = "read-only" | "workspace-write" | "danger-full-access" | "enabled" | "disabled";
55
57
  export interface AgentTarget {
56
58
  type: "agent";
57
59
  provider: AgentProvider;
58
60
  prompt: string;
59
61
  cwd?: string;
60
62
  model?: string;
63
+ variant?: string;
61
64
  agent?: string;
62
65
  authProfile?: string;
63
66
  extraArgs?: string[];
64
67
  timeoutMs?: number;
65
68
  configIsolation?: AgentConfigIsolation;
69
+ permissionMode?: AgentPermissionMode;
70
+ sandbox?: AgentSandbox;
66
71
  account?: AccountRef;
67
72
  }
68
73
  export interface WorkflowTarget {
@@ -105,6 +110,20 @@ export interface CreateWorkflowInput {
105
110
  steps: WorkflowStep[];
106
111
  version?: number;
107
112
  }
113
+ export type LoopTemplateKind = "workflow" | "loop";
114
+ export interface LoopTemplateVariable {
115
+ name: string;
116
+ description?: string;
117
+ required?: boolean;
118
+ default?: string;
119
+ }
120
+ export interface LoopTemplateSummary {
121
+ id: string;
122
+ name: string;
123
+ description: string;
124
+ kind: LoopTemplateKind;
125
+ variables: LoopTemplateVariable[];
126
+ }
108
127
  export interface WorkflowRun {
109
128
  id: string;
110
129
  workflowId: string;
package/docs/USAGE.md CHANGED
@@ -5,7 +5,7 @@ OpenLoops is a local CLI and daemon for persistent loops and workflows: schedule
5
5
  It supports deterministic command loops, JSON-defined workflows, and guarded CLI adapters for headless coding agents:
6
6
 
7
7
  - `claude`
8
- - `cursor-agent`
8
+ - `cursor agent` or `agent`
9
9
  - `codewith exec`
10
10
  - `aicopilot run`
11
11
  - `opencode run`
@@ -78,6 +78,7 @@ loops create agent supply-chain-watch \
78
78
  --provider codewith \
79
79
  --every 15m \
80
80
  --cwd /path/to/repo \
81
+ --sandbox danger-full-access \
81
82
  --prompt "Check for suspicious dependency or supply-chain changes. Report only concrete findings."
82
83
  ```
83
84
 
@@ -89,6 +90,7 @@ loops create agent supply-chain-watch \
89
90
  --auth-profile account001 \
90
91
  --every 15m \
91
92
  --cwd /path/to/repo \
93
+ --sandbox danger-full-access \
92
94
  --prompt "Check for suspicious dependency or supply-chain changes. Report only concrete findings."
93
95
  ```
94
96
 
@@ -160,6 +162,63 @@ Use `shell: true` only when you intentionally want shell parsing:
160
162
  { "type": "command", "command": "git status --short", "shell": true }
161
163
  ```
162
164
 
165
+ ## Templates And Task Events
166
+
167
+ Built-in templates turn common orchestration flows into reusable workflow JSON.
168
+ `todos-task-worker-verifier` performs one todos task and then verifies it.
169
+ `event-worker-verifier` handles any Hasna event envelope and then verifies the
170
+ handling.
171
+
172
+ ```bash
173
+ loops templates list
174
+ loops templates render todos-task-worker-verifier \
175
+ --var taskId=<task-id> \
176
+ --var taskTitle="Fix parser" \
177
+ --var projectPath=/path/to/repo \
178
+ --var provider=codewith \
179
+ --var authProfile=account005 \
180
+ --var sandbox=danger-full-access
181
+ loops templates create-workflow todos-task-worker-verifier \
182
+ --var taskId=<task-id> \
183
+ --var projectPath=/path/to/repo
184
+ loops templates render event-worker-verifier \
185
+ --var eventId=<event-id> \
186
+ --var eventType=knowledge.record.created \
187
+ --var eventSource=knowledge \
188
+ --var eventJson='{"id":"<event-id>"}' \
189
+ --var projectPath=/path/to/repo
190
+ ```
191
+
192
+ For event-driven task automation, `loops events handle todos-task` reads a
193
+ Hasna event envelope from stdin or `HASNA_EVENT_JSON`, renders the template, and
194
+ schedules a deduped one-shot workflow loop:
195
+
196
+ ```bash
197
+ cat task-created-event.json | loops events handle todos-task \
198
+ --provider codewith \
199
+ --auth-profile account005 \
200
+ --permission-mode bypass \
201
+ --sandbox danger-full-access
202
+ ```
203
+
204
+ For other Hasna apps that expose `@hasna/events` webhooks, use the generic
205
+ handler:
206
+
207
+ ```bash
208
+ cat event.json | loops events handle generic \
209
+ --provider codewith \
210
+ --auth-profile account005 \
211
+ --permission-mode bypass \
212
+ --sandbox danger-full-access \
213
+ --project-path /path/to/repo
214
+ ```
215
+
216
+ This is the intended deterministic-to-agentic path: a producer creates a todos
217
+ task, `@hasna/events` delivers `task.created`, OpenLoops creates a worker and a
218
+ verifier workflow, and the workflow updates todos with evidence. Use
219
+ `--dry-run` to inspect the rendered workflow and loop input without storing
220
+ anything.
221
+
163
222
  ## Transcript-Driven Loops
164
223
 
165
224
  OpenLoops can turn long-form media or meeting transcripts into recurring workflow work when paired with `iapp-transcriber`. The template at `docs/workflows/transcript-feedback-to-loops.json` transcribes an authorized media URL, asks an agent to extract recurring loop candidates, authors workflow specs, and validates generated workflows before scheduling. Copy it into the target repo, replace `/path/to/repo` with that repo's absolute path, and provide `TRANSCRIBER_SOURCE_URL` through the runner environment or a private, uncommitted workflow copy before storing or scheduling it. Do not commit private or signed media URLs.
@@ -240,11 +299,14 @@ The adapters intentionally use provider command surfaces instead of pretending e
240
299
  - Claude uses `claude -p --output-format json` and safe-mode/local setting sources by default.
241
300
  - Codewith uses `codewith --ask-for-approval never exec --json --ephemeral --skip-git-repo-check`.
242
301
  - AI Copilot and OpenCode use `run --format json --pure`.
243
- - Cursor is CLI-first for now via `cursor-agent -p`; treat output as less stable until a stronger public SDK contract is selected.
302
+ - Cursor is CLI-first for now via `cursor agent -p`, with `agent -p` as the fallback launcher on machines that expose the standalone Cursor Agent binary; treat output as less stable until a stronger public SDK contract is selected.
244
303
  - Codex uses `codex exec --json --ephemeral --ask-for-approval never`.
245
304
  - Agent prompts are sent through child stdin instead of argv so prompt bodies do not appear in process listings.
246
305
  - When `--account` or a step `account` is set, OpenLoops resolves `accounts env <profile> --tool <tool>` before spawning the target, strips inherited tool home/API-key variables, and applies the selected profile only to that process. Missing account profiles fail before the provider binary receives the prompt.
247
306
  - `--auth-profile` and step `authProfile` are provider-native auth selectors. They currently apply to Codewith and are passed to Codewith as `--auth-profile <name>` before `exec`; they do not call OpenAccounts.
307
+ - `--sandbox` maps to provider-native sandbox flags. Codewith/Codex accept `read-only`, `workspace-write`, or `danger-full-access`; Cursor accepts `enabled` or `disabled`.
308
+ - `--permission-mode` maps `plan`, `auto`, and `bypass` where the provider supports it. Claude uses native permission modes, Cursor maps bypass to `--force`, and OpenCode/AICopilot map bypass to `--dangerously-skip-permissions`.
309
+ - `--variant` is provider-specific reasoning/model effort. Claude maps it to `--effort`, Codewith/Codex map it to `model_reasoning_effort`, and OpenCode/AICopilot pass `--variant`.
248
310
  - Daemon and scheduled runs prepend common user executable directories such as `~/.local/bin` and `~/.bun/bin` before resolving provider CLIs.
249
311
 
250
312
  For production loops that can mutate repos, prefer disposable worktrees and explicit prompts that name allowed write scope.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -64,6 +64,7 @@
64
64
  "bun": ">=1.0.0"
65
65
  },
66
66
  "dependencies": {
67
+ "@hasna/events": "^0.1.8",
67
68
  "@hasna/machines": "0.0.49",
68
69
  "@openrouter/ai-sdk-provider": "2.9.1",
69
70
  "ai": "6.0.204",