loopable-cli 0.2.0 → 0.3.0

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 (138) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/_connectorId-CDCuSgpN.js +1 -0
  3. package/.output/public/assets/_loopId-DOnIsVTW.js +1 -0
  4. package/.output/public/assets/_taskId-BO5MZnlZ.js +1 -0
  5. package/.output/public/assets/agents-CBozi7oi.js +1 -0
  6. package/.output/public/assets/alert-Bn_wjgTX.js +1 -0
  7. package/.output/public/assets/button-K3ypIhAZ.js +1 -0
  8. package/.output/public/assets/card-DiVnaTIE.js +1 -0
  9. package/.output/public/assets/chevron-right-BrctLtFK.js +1 -0
  10. package/.output/public/assets/connection-status-DKWelvLO.js +1 -0
  11. package/.output/public/assets/connector-icon-CEbdE71G.js +1 -0
  12. package/.output/public/assets/connectors-JyX3IzJj.js +1 -0
  13. package/.output/public/assets/gaps-PjmcbaWo.js +1 -0
  14. package/.output/public/assets/inbox-CujRR0Cz.js +1 -0
  15. package/.output/public/assets/index-C9SQ_Baq.js +16 -0
  16. package/.output/public/assets/loop-form-Dw3cuTzw.js +1 -0
  17. package/.output/public/assets/loops-DcEfZa2_.js +1 -0
  18. package/.output/public/assets/manifests-yJpevyye.js +1 -0
  19. package/.output/public/assets/new-CwsnNuRW.js +1 -0
  20. package/.output/public/assets/page-bvnfRMum.js +1 -0
  21. package/.output/public/assets/play-yHLs4DjK.js +1 -0
  22. package/.output/public/assets/plus-Ber25lrU.js +1 -0
  23. package/.output/public/assets/preload-helper-BoU_7EQC.js +3 -0
  24. package/.output/public/assets/refresh-cw-BzSY7M8I.js +1 -0
  25. package/.output/public/assets/rotate-ccw-DLv9EfCX.js +1 -0
  26. package/.output/public/assets/runners-DzX-RU3i.js +2 -0
  27. package/.output/public/assets/select-C9grIFwG.js +1 -0
  28. package/.output/public/assets/setting-fields-DUc3npgh.js +3 -0
  29. package/.output/public/assets/styles-B9B98zLk.css +2 -0
  30. package/.output/public/assets/switch-DwABVI6d.js +1 -0
  31. package/.output/public/assets/task-list-Ddk8-qt7.js +1 -0
  32. package/.output/public/assets/textarea-Ds-gIYUQ.js +1 -0
  33. package/.output/public/assets/time-goBWd-5J.js +1 -0
  34. package/.output/public/assets/use-live-tasks-DZNNuECy.js +1 -0
  35. package/.output/public/assets/useCompositeListItem-P4Nmmn7j.js +1 -0
  36. package/.output/public/assets/usePositioner-B7i9Ejzh.js +1 -0
  37. package/.output/server/_chunks/ssr-renderer.mjs +1 -1
  38. package/.output/server/_connectorId-COuQN02a.mjs +512 -0
  39. package/.output/server/_libs/@base-ui/react+[...].mjs +1410 -804
  40. package/.output/server/_libs/@tanstack/react-router+[...].mjs +1 -1
  41. package/.output/server/_libs/lucide-react.mjs +93 -84
  42. package/.output/server/_libs/next-themes.mjs +1 -1
  43. package/.output/server/_libs/sonner.mjs +1 -1
  44. package/.output/server/_loopId-CZh6_Yhr.mjs +310 -0
  45. package/.output/server/_ssr/{callback-D20cVD2w.mjs → access-DTttr563.mjs} +37 -20
  46. package/.output/server/_ssr/{agents-CrUmIQpi.mjs → agents-B_qo1gpL.mjs} +196 -7
  47. package/.output/server/_ssr/{agents-Dms_6TBF.mjs → agents-Bz10gz_m.mjs} +8 -8
  48. package/.output/server/_ssr/agents-CjbOUp3Z.mjs +206 -0
  49. package/.output/server/_ssr/{alert-DQvzE7bu.mjs → alert-C2WBWfzo.mjs} +2 -2
  50. package/.output/server/_ssr/card-r1cV-PCN.mjs +36 -0
  51. package/.output/server/_ssr/{client-BtVRVhQ2.mjs → client-Dk3SD8Il.mjs} +5 -1
  52. package/.output/server/_ssr/connection-status-BogAL-Fj.mjs +32 -0
  53. package/.output/server/_ssr/{connections-ypex3qUd.mjs → connections-Cqvb6Gw2.mjs} +1109 -263
  54. package/.output/server/_ssr/connector-icon-DrhVbhs6.mjs +190 -0
  55. package/.output/server/_ssr/connectors-BfS5tQKr.mjs +81 -0
  56. package/.output/server/_ssr/{connectors-BZly0yGF.mjs → connectors-DJI-rzPU.mjs} +21 -10
  57. package/.output/server/_ssr/{createServerRpc-DPDrnFdH.mjs → createServerRpc-DDrp2nLT.mjs} +2 -2
  58. package/.output/server/_ssr/{gaps-D2Zh8sEo.mjs → gaps-CcvHiLcj.mjs} +18 -10
  59. package/.output/server/_ssr/inbox-D1ef761v.mjs +178 -0
  60. package/.output/server/_ssr/loop-form-DWwOR05R.mjs +346 -0
  61. package/.output/server/_ssr/{loops-Og_G3KyZ.mjs → loops-6WhUe_lc.mjs} +228 -25
  62. package/.output/server/_ssr/loops-DHPjzG-t.mjs +217 -0
  63. package/.output/server/_ssr/new-ChKBdcXJ.mjs +245 -0
  64. package/.output/server/_ssr/page-DZVxKvdn.mjs +120 -0
  65. package/.output/server/_ssr/{router-BbrBz36P.mjs → router-DdwREiOf.mjs} +167 -489
  66. package/.output/server/_ssr/runners-CK9Cx9fS.mjs +217 -0
  67. package/.output/server/_ssr/runners-CrznFPHt.mjs +140 -0
  68. package/.output/server/_ssr/runners-W-pgo5yo.mjs +55 -0
  69. package/.output/server/_ssr/{secrets-CRnpwGBh.mjs → secrets-CV5scFlt.mjs} +2 -2
  70. package/.output/server/_ssr/select-BtndHhHb.mjs +76 -0
  71. package/.output/server/_ssr/{setting-fields-mpXfGftI.mjs → setting-fields-LNgywAW7.mjs} +25 -20
  72. package/.output/server/_ssr/ssr.mjs +49 -40
  73. package/.output/server/_ssr/{start-BgTj-h_C.mjs → start-C_j-e4vF.mjs} +10 -6
  74. package/.output/server/_ssr/{switch-1pnd_bPJ.mjs → switch-odxNO_S4.mjs} +3 -3
  75. package/.output/server/_ssr/task-list-Dnmq_WeE.mjs +150 -0
  76. package/.output/server/_ssr/{tasks-LIi-W4cy.mjs → tasks-D-kXLgVP.mjs} +13 -13
  77. package/.output/server/_ssr/{tasks-B_QmDFVs.mjs → tasks-D7aUvA33.mjs} +55 -17
  78. package/.output/server/_ssr/{textarea-CHJTKtsJ.mjs → textarea-D2c_vTIC.mjs} +2 -2
  79. package/.output/server/_ssr/time-Cy8TZP0Z.mjs +13 -0
  80. package/.output/server/_ssr/use-live-tasks-BzhTkEOL.mjs +40 -0
  81. package/.output/server/_tanstack-start-manifest_v-2HUdn-2E.mjs +166 -0
  82. package/.output/server/_taskId-D3KkNJW1.mjs +263 -0
  83. package/.output/server/index.mjs +204 -162
  84. package/README.md +54 -19
  85. package/dist/cli.mjs +185 -67
  86. package/dist/dispatcher.mjs +1443 -277
  87. package/dist/runner.mjs +333 -46
  88. package/drizzle/0025_prompt_tasks_done.sql +2 -0
  89. package/drizzle/0026_guidance_into_prompt.sql +3 -0
  90. package/drizzle/0027_relative_folders.sql +5 -0
  91. package/drizzle/0028_runner_version.sql +5 -0
  92. package/drizzle/meta/_journal.json +29 -1
  93. package/package.json +23 -17
  94. package/.output/public/assets/_connectorId-m67WbiJ8.js +0 -1
  95. package/.output/public/assets/_loopId-Bf4K5sdd.js +0 -1
  96. package/.output/public/assets/_taskId-eCKkcVOx.js +0 -1
  97. package/.output/public/assets/alert-BT2EVMJP.js +0 -1
  98. package/.output/public/assets/arrow-left-CWUZt6k2.js +0 -1
  99. package/.output/public/assets/arrow-right-dy_oOg_c.js +0 -1
  100. package/.output/public/assets/connection-status-DCqqmY_6.js +0 -1
  101. package/.output/public/assets/connector-icon-qGpNsPgH.js +0 -1
  102. package/.output/public/assets/connectors-DxXlc9zF.js +0 -1
  103. package/.output/public/assets/external-link-DC11orFA.js +0 -1
  104. package/.output/public/assets/gaps-CoTA-PzA.js +0 -1
  105. package/.output/public/assets/inbox-CLEXUXCI.js +0 -1
  106. package/.output/public/assets/index-Cndsw84p.js +0 -16
  107. package/.output/public/assets/loader-B4Epchm_.js +0 -1
  108. package/.output/public/assets/loop-form-BvizlZdR.js +0 -1
  109. package/.output/public/assets/loops-Dbe_mf5T.js +0 -1
  110. package/.output/public/assets/new-DPEaXKAY.js +0 -1
  111. package/.output/public/assets/play-DD4xQgvi.js +0 -1
  112. package/.output/public/assets/preload-helper-CfdAYKD7.js +0 -3
  113. package/.output/public/assets/rolldown-runtime-CbXtAM7H.js +0 -1
  114. package/.output/public/assets/runners-BVBggS5B.js +0 -1
  115. package/.output/public/assets/select-Cppu-hti.js +0 -1
  116. package/.output/public/assets/setting-fields-BT1naVJX.js +0 -3
  117. package/.output/public/assets/styles-D3sonCfX.css +0 -2
  118. package/.output/public/assets/switch-hRIbNGOj.js +0 -1
  119. package/.output/public/assets/task-list-CrptnI1N.js +0 -1
  120. package/.output/public/assets/textarea-Fyuzi6Vp.js +0 -1
  121. package/.output/public/assets/trash-2-BXZzBsoV.js +0 -1
  122. package/.output/public/assets/useValueChanged-VVyq4hb9.js +0 -1
  123. package/.output/server/_connectorId-DcSnoRjk.mjs +0 -421
  124. package/.output/server/_loopId-G_TAkYtS.mjs +0 -272
  125. package/.output/server/_ssr/access-B7TcOZ7F.mjs +0 -42
  126. package/.output/server/_ssr/connection-status-BZr3Y-IC.mjs +0 -31
  127. package/.output/server/_ssr/connector-icon-CFrrHjzR.mjs +0 -37
  128. package/.output/server/_ssr/connectors-4yzKb0KG.mjs +0 -106
  129. package/.output/server/_ssr/inbox-C2VBGLa5.mjs +0 -114
  130. package/.output/server/_ssr/loop-form-D-U-kU4k.mjs +0 -413
  131. package/.output/server/_ssr/loops-D0XJ8eBb.mjs +0 -222
  132. package/.output/server/_ssr/new-CajvgIog.mjs +0 -169
  133. package/.output/server/_ssr/runners-B_WuF8UJ.mjs +0 -32
  134. package/.output/server/_ssr/runners-D-0_lfiq.mjs +0 -106
  135. package/.output/server/_ssr/runners-zqF_zbEc.mjs +0 -116
  136. package/.output/server/_ssr/task-list-BnQJ94Dk.mjs +0 -187
  137. package/.output/server/_tanstack-start-manifest_v-BCHAazE8.mjs +0 -129
  138. package/.output/server/_taskId-CIv0ajIx.mjs +0 -256
@@ -55,9 +55,32 @@ var init_define = __esm({
55
55
  }
56
56
  });
57
57
 
58
+ // src/agents/claude/manifest.ts
59
+ var claudeManifest;
60
+ var init_manifest = __esm({
61
+ "src/agents/claude/manifest.ts"() {
62
+ "use strict";
63
+ init_define();
64
+ claudeManifest = defineAgentManifest({
65
+ id: "claude",
66
+ name: "Claude Code",
67
+ tagline: "Anthropic's coding agent, driven through its print mode.",
68
+ docsUrl: "https://docs.claude.com/en/docs/claude-code/cli-reference",
69
+ icon: "ClaudeCode",
70
+ accent: "bg-[#d97757] text-white",
71
+ binaries: ["claude"],
72
+ installHint: "Install with curl -fsSL https://claude.ai/install.sh | bash, then run claude auth login.",
73
+ permissionModes: ["read_only", "workspace_write"],
74
+ supportsModel: true,
75
+ modelPlaceholder: "e.g. sonnet or opus; leave empty for the default",
76
+ defaults: { permissionMode: "read_only", model: null, timeoutMs: 3e5 }
77
+ });
78
+ }
79
+ });
80
+
58
81
  // src/agents/codex/manifest.ts
59
82
  var codexManifest;
60
- var init_manifest = __esm({
83
+ var init_manifest2 = __esm({
61
84
  "src/agents/codex/manifest.ts"() {
62
85
  "use strict";
63
86
  init_define();
@@ -66,7 +89,7 @@ var init_manifest = __esm({
66
89
  name: "Codex",
67
90
  tagline: "OpenAI's coding agent, driven non-interactively through codex exec.",
68
91
  docsUrl: "https://developers.openai.com/codex/cli",
69
- icon: "Terminal",
92
+ icon: "Codex",
70
93
  accent: "bg-neutral-900 text-white",
71
94
  binaries: ["codex"],
72
95
  installHint: "Install with npm i -g @openai/codex, then run codex login.",
@@ -80,7 +103,7 @@ var init_manifest = __esm({
80
103
 
81
104
  // src/agents/cursor-agent/manifest.ts
82
105
  var cursorAgentManifest;
83
- var init_manifest2 = __esm({
106
+ var init_manifest3 = __esm({
84
107
  "src/agents/cursor-agent/manifest.ts"() {
85
108
  "use strict";
86
109
  init_define();
@@ -89,7 +112,7 @@ var init_manifest2 = __esm({
89
112
  name: "Cursor Agent",
90
113
  tagline: "Cursor's CLI agent, driven through its print mode.",
91
114
  docsUrl: "https://cursor.com/docs/cli",
92
- icon: "MousePointerClick",
115
+ icon: "Cursor",
93
116
  accent: "bg-blue-600 text-white",
94
117
  binaries: ["cursor-agent"],
95
118
  installHint: "Install with curl https://cursor.com/install -fsS | bash, then run cursor-agent login.",
@@ -101,11 +124,148 @@ var init_manifest2 = __esm({
101
124
  }
102
125
  });
103
126
 
127
+ // package.json
128
+ var package_default;
129
+ var init_package = __esm({
130
+ "package.json"() {
131
+ package_default = {
132
+ name: "loopable-cli",
133
+ version: "0.2.1",
134
+ description: "CLI to run Loopable: App, Dispatcher, and Runner.",
135
+ license: "MIT",
136
+ type: "module",
137
+ bin: {
138
+ loopable: "./dist/cli.mjs"
139
+ },
140
+ files: [
141
+ ".output",
142
+ "dist",
143
+ "drizzle"
144
+ ],
145
+ engines: {
146
+ node: ">=22"
147
+ },
148
+ packageManager: "pnpm@11.22.0",
149
+ repository: {
150
+ type: "git",
151
+ url: "git+https://github.com/imsobear/loopable.git"
152
+ },
153
+ bugs: {
154
+ url: "https://github.com/imsobear/loopable/issues"
155
+ },
156
+ homepage: "https://github.com/imsobear/loopable#readme",
157
+ publishConfig: {
158
+ access: "public"
159
+ },
160
+ imports: {
161
+ "#/*": "./src/*"
162
+ },
163
+ scripts: {
164
+ dev: "node --experimental-strip-types scripts/dev.ts",
165
+ start: "node dist/cli.mjs start",
166
+ dispatcher: "LOOPABLE_DEV=1 node --experimental-strip-types src/cli.ts dispatcher",
167
+ runner: "LOOPABLE_DEV=1 node --experimental-strip-types src/cli.ts runner",
168
+ "generate-routes": "tsr generate",
169
+ build: "vite build && node scripts/build-cli.mjs",
170
+ preview: "vite preview",
171
+ test: "vitest run",
172
+ "test:watch": "vitest",
173
+ "db:generate": "drizzle-kit generate",
174
+ "db:studio": "LOOPABLE_DEV=1 drizzle-kit studio",
175
+ typecheck: "tsc --noEmit",
176
+ release: "node scripts/release.mjs",
177
+ deploy: "LOOPABLE_CF=1 vite build && wrangler deploy",
178
+ "site:dev": "pnpm --filter loopable-site dev",
179
+ "site:build": "pnpm --filter loopable-site build",
180
+ "site:deploy": "pnpm --filter loopable-site deploy",
181
+ prepublishOnly: "pnpm typecheck && pnpm test && pnpm build"
182
+ },
183
+ dependencies: {
184
+ "@libsql/client": "^0.18.0"
185
+ },
186
+ devDependencies: {
187
+ "@base-ui/react": "^1.7.0",
188
+ "@cloudflare/containers": "^0.3.7",
189
+ "@cloudflare/vite-plugin": "^1.54.10",
190
+ "@fontsource-variable/geist": "^5.3.0",
191
+ "@tailwindcss/vite": "4.1.18",
192
+ "@tanstack/devtools-vite": "0.8.5",
193
+ "@tanstack/react-devtools": "0.10.12",
194
+ "@tanstack/react-router": "1.170.32",
195
+ "@tanstack/react-router-devtools": "1.167.1",
196
+ "@tanstack/react-start": "1.168.49",
197
+ "@tanstack/router-cli": "1.167.33",
198
+ "@types/node": "^22.10.2",
199
+ "@types/qrcode": "^1.5.6",
200
+ "@types/react": "^19.2.0",
201
+ "@types/react-dom": "^19.2.0",
202
+ "@vitejs/plugin-react": "^6.0.1",
203
+ "class-variance-authority": "^0.7.1",
204
+ clsx: "^2.1.1",
205
+ "drizzle-kit": "^0.31.10",
206
+ "drizzle-orm": "^0.45.2",
207
+ esbuild: "^0.28.2",
208
+ "lucide-react": "^1.39.0",
209
+ "next-themes": "^0.4.6",
210
+ nitro: "3.0.260610-beta",
211
+ qrcode: "^1.5.4",
212
+ react: "19.2.0",
213
+ "react-dom": "19.2.0",
214
+ shadcn: "^4.19.1",
215
+ sonner: "^2.0.8",
216
+ "tailwind-merge": "^3.6.0",
217
+ tailwindcss: "4.1.18",
218
+ "tw-animate-css": "^1.4.0",
219
+ typescript: "^6.0.2",
220
+ vite: "^8.0.0",
221
+ vitest: "^4.1.11",
222
+ wrangler: "^4.132.0"
223
+ }
224
+ };
225
+ }
226
+ });
227
+
228
+ // src/lib/version.ts
229
+ function olderThan(a, b) {
230
+ if (!a) return true;
231
+ const pa = a.split(".").map((part) => Number.parseInt(part, 10));
232
+ const pb = b.split(".").map((part) => Number.parseInt(part, 10));
233
+ for (let i = 0; i < 3; i++) {
234
+ const x = pa[i] ?? 0;
235
+ const y = pb[i] ?? 0;
236
+ if (Number.isNaN(x)) return true;
237
+ if (x !== y) return x < y;
238
+ }
239
+ return false;
240
+ }
241
+ var VERSION, RUNNER_PROTOCOL, LEGACY_RUNNER_PROTOCOL, FOLDER_JOB_PROTOCOL;
242
+ var init_version = __esm({
243
+ "src/lib/version.ts"() {
244
+ init_package();
245
+ VERSION = package_default.version;
246
+ RUNNER_PROTOCOL = 2;
247
+ LEGACY_RUNNER_PROTOCOL = 1;
248
+ FOLDER_JOB_PROTOCOL = 2;
249
+ }
250
+ });
251
+
252
+ // src/server/agent-job.ts
253
+ import { isAbsolute, join as join5 } from "node:path";
254
+ function folderIsLegacy(folder) {
255
+ return isAbsolute(folder.trim());
256
+ }
257
+ var init_agent_job = __esm({
258
+ "src/server/agent-job.ts"() {
259
+ "use strict";
260
+ init_version();
261
+ }
262
+ });
263
+
104
264
  // src/agents/discover.ts
105
265
  import { execFile } from "node:child_process";
106
266
  import { accessSync, constants } from "node:fs";
107
267
  import { homedir as homedir2 } from "node:os";
108
- import { join as join5 } from "node:path";
268
+ import { join as join6 } from "node:path";
109
269
  import { promisify } from "node:util";
110
270
  function unique(values) {
111
271
  return [...new Set(values.filter(Boolean))];
@@ -114,7 +274,7 @@ function inheritedDirs() {
114
274
  const home = homedir2();
115
275
  return unique([
116
276
  ...(process.env.PATH ?? "").split(":"),
117
- ...HOME_DIRS.map((dir) => join5(home, dir)),
277
+ ...HOME_DIRS.map((dir) => join6(home, dir)),
118
278
  ...SYSTEM_DIRS
119
279
  ]);
120
280
  }
@@ -132,7 +292,7 @@ async function loginShellDirs() {
132
292
  function executableIn(dirs, names) {
133
293
  for (const dir of dirs) {
134
294
  for (const name of names) {
135
- const candidate = join5(dir, name);
295
+ const candidate = join6(dir, name);
136
296
  try {
137
297
  accessSync(candidate, constants.X_OK);
138
298
  return candidate;
@@ -148,9 +308,9 @@ async function resolveBinary(names) {
148
308
  function clean(text3) {
149
309
  return text3.replace(ANSI, "").trim();
150
310
  }
151
- async function probe(bin, args3, timeoutMs = 1e4) {
311
+ async function probe(bin, args4, timeoutMs = 1e4) {
152
312
  try {
153
- const { stdout, stderr } = await exec(bin, args3, { timeout: timeoutMs });
313
+ const { stdout, stderr } = await exec(bin, args4, { timeout: timeoutMs });
154
314
  return { ok: true, text: clean(stdout) || clean(stderr) };
155
315
  } catch (error) {
156
316
  const failure = error;
@@ -298,14 +458,171 @@ var init_process = __esm({
298
458
  }
299
459
  });
300
460
 
461
+ // src/agents/claude/events.ts
462
+ function parse(line) {
463
+ const trimmed = line.trim();
464
+ if (!trimmed.startsWith("{")) return null;
465
+ try {
466
+ return JSON.parse(trimmed);
467
+ } catch {
468
+ return null;
469
+ }
470
+ }
471
+ function blocks(event) {
472
+ return event?.type === "assistant" ? event.message?.content ?? [] : [];
473
+ }
474
+ function describeTool(block, cwd) {
475
+ const verb = (block.name ?? "a tool").toLowerCase();
476
+ const input = block.input ?? {};
477
+ const subject = input.file_path ?? input.path ?? input.command ?? input.pattern ?? input.url ?? input.query;
478
+ if (typeof subject !== "string") return verb;
479
+ const short = cwd && subject.startsWith(`${cwd}/`) ? subject.slice(cwd.length + 1) : subject;
480
+ return `${verb} ${short}`;
481
+ }
482
+ function describeEvent(line, cwd) {
483
+ const event = parse(line);
484
+ if (!event) return line.trim() === "" ? null : line;
485
+ switch (event.type) {
486
+ case "system":
487
+ if (event.subtype !== "init") return null;
488
+ return event.model ? `Started with ${event.model}.` : "Started.";
489
+ case "assistant": {
490
+ const tools = blocks(event).filter((block) => block.type === "tool_use").map((block) => `Running ${describeTool(block, cwd)}`);
491
+ return tools.length > 0 ? tools.join("\n") : null;
492
+ }
493
+ case "result": {
494
+ const seconds = event.duration_ms !== void 0 ? (event.duration_ms / 1e3).toFixed(0) : "?";
495
+ const tokens = event.usage?.output_tokens;
496
+ const spent = tokens === void 0 ? "" : `, ${tokens} tokens out`;
497
+ return event.is_error ? `Failed after ${seconds}s${spent}: ${event.result ?? event.subtype ?? "unknown error"}` : `Answered after ${seconds}s${spent}.`;
498
+ }
499
+ default:
500
+ return null;
501
+ }
502
+ }
503
+ function answerFrom(stdout) {
504
+ const events = stdout.split("\n").map(parse);
505
+ const result = events.filter((event) => event?.type === "result").at(-1);
506
+ const failed = result?.is_error === true;
507
+ if (typeof result?.result === "string" && result.result.trim() !== "") {
508
+ return { text: result.result.trim(), failed };
509
+ }
510
+ const said = events.flatMap(blocks);
511
+ const lastTool = said.findLastIndex((block) => block.type === "tool_use");
512
+ const text3 = said.slice(lastTool + 1).filter((block) => block.type === "text").map((block) => block.text ?? "").join("\n").trim();
513
+ return { text: text3, failed };
514
+ }
515
+ var init_events = __esm({
516
+ "src/agents/claude/events.ts"() {
517
+ "use strict";
518
+ }
519
+ });
520
+
521
+ // src/agents/claude/runtime.ts
522
+ async function binary() {
523
+ const found = await resolveBinary(claudeManifest.binaries);
524
+ if (!found) throw new Error("The claude binary was not found on this host.");
525
+ return found;
526
+ }
527
+ function args(input) {
528
+ const list = [
529
+ "-p",
530
+ // A stream of events rather than one block at the end, so a run can be
531
+ // watched. Print mode refuses stream-json without --verbose.
532
+ "--output-format",
533
+ "stream-json",
534
+ "--verbose",
535
+ // Session files would outlive a run that is meant to leave nothing behind.
536
+ "--no-session-persistence"
537
+ ];
538
+ if (input.settings.permissionMode === "read_only") {
539
+ list.push("--permission-mode", "dontAsk", "--tools", "Read,Grep,Glob");
540
+ } else {
541
+ list.push("--permission-mode", "acceptEdits", "--allowedTools", "Bash");
542
+ }
543
+ if (input.settings.model) list.push("--model", input.settings.model);
544
+ list.push("--", input.prompt);
545
+ return list;
546
+ }
547
+ var claudeRuntime;
548
+ var init_runtime = __esm({
549
+ "src/agents/claude/runtime.ts"() {
550
+ "use strict";
551
+ init_define();
552
+ init_discover();
553
+ init_process();
554
+ init_events();
555
+ init_manifest();
556
+ claudeRuntime = defineAgentRuntime({
557
+ async detect() {
558
+ const found = await resolveBinary(claudeManifest.binaries);
559
+ if (!found) return { installed: false };
560
+ const version2 = await probe(found, ["--version"]);
561
+ return { installed: true, binaryPath: found, version: firstLine(version2.text) };
562
+ },
563
+ async authStatus() {
564
+ const found = await resolveBinary(claudeManifest.binaries);
565
+ if (!found) return { signedIn: false, detail: "Not installed." };
566
+ const result = await probe(found, ["auth", "status", "--json"]);
567
+ try {
568
+ const status = JSON.parse(result.text);
569
+ return status.loggedIn ? { signedIn: true, detail: `Signed in (${status.authMethod ?? "unknown method"}).` } : { signedIn: false, detail: "Run claude auth login." };
570
+ } catch {
571
+ return {
572
+ signedIn: result.ok,
573
+ detail: firstLine(result.text) || (result.ok ? "Signed in." : "Run claude auth login.")
574
+ };
575
+ }
576
+ },
577
+ invocation(input) {
578
+ return { bin: claudeManifest.binaries[0], args: args(input) };
579
+ },
580
+ async run(input) {
581
+ const bin = await binary();
582
+ const argv = args(input);
583
+ const outcome = await runProcess({
584
+ bin,
585
+ args: argv,
586
+ cwd: input.cwd,
587
+ timeoutMs: input.settings.timeoutMs,
588
+ signal: input.signal,
589
+ logPath: input.logFile,
590
+ logLine: (line) => describeEvent(line, input.cwd)
591
+ });
592
+ const command = `${bin} ${argv.join(" ")}`;
593
+ const answer = answerFrom(outcome.stdout);
594
+ if (outcome.aborted) {
595
+ return { ok: false, aborted: true, output: answer.text, durationMs: outcome.durationMs, command };
596
+ }
597
+ if (outcome.timedOut) {
598
+ return {
599
+ ok: false,
600
+ output: answer.text,
601
+ detail: `Claude Code did not finish within ${Math.round(input.settings.timeoutMs / 1e3)}s.`,
602
+ durationMs: outcome.durationMs,
603
+ command
604
+ };
605
+ }
606
+ return {
607
+ ok: outcome.code === 0 && !answer.failed,
608
+ output: answer.text,
609
+ detail: outcome.code === 0 && !answer.failed ? void 0 : answer.failed ? answer.text || "The agent reported an error." : outcome.stderr || `Exited with code ${outcome.code}.`,
610
+ durationMs: outcome.durationMs,
611
+ command
612
+ };
613
+ }
614
+ });
615
+ }
616
+ });
617
+
301
618
  // src/agents/codex/runtime.ts
302
619
  import { readFileSync as readFileSync3, rmSync as rmSync2 } from "node:fs";
303
- async function binary() {
620
+ async function binary2() {
304
621
  const found = await resolveBinary(codexManifest.binaries);
305
622
  if (!found) throw new Error("The codex binary was not found on this host.");
306
623
  return found;
307
624
  }
308
- function args(input) {
625
+ function args2(input) {
309
626
  const list = [
310
627
  "exec",
311
628
  "--sandbox",
@@ -325,13 +642,13 @@ function args(input) {
325
642
  return list;
326
643
  }
327
644
  var codexRuntime;
328
- var init_runtime = __esm({
645
+ var init_runtime2 = __esm({
329
646
  "src/agents/codex/runtime.ts"() {
330
647
  "use strict";
331
648
  init_define();
332
649
  init_discover();
333
650
  init_process();
334
- init_manifest();
651
+ init_manifest2();
335
652
  codexRuntime = defineAgentRuntime({
336
653
  async detect() {
337
654
  const found = await resolveBinary(codexManifest.binaries);
@@ -349,11 +666,11 @@ var init_runtime = __esm({
349
666
  };
350
667
  },
351
668
  invocation(input) {
352
- return { bin: codexManifest.binaries[0], args: args(input) };
669
+ return { bin: codexManifest.binaries[0], args: args2(input) };
353
670
  },
354
671
  async run(input) {
355
- const bin = await binary();
356
- const argv = args(input);
672
+ const bin = await binary2();
673
+ const argv = args2(input);
357
674
  const command = `${bin} ${argv.join(" ")}`;
358
675
  const outcome = await runProcess({
359
676
  bin,
@@ -398,7 +715,7 @@ var init_runtime = __esm({
398
715
  });
399
716
 
400
717
  // src/agents/cursor-agent/events.ts
401
- function parse(line) {
718
+ function parse2(line) {
402
719
  const trimmed = line.trim();
403
720
  if (!trimmed.startsWith("{")) return null;
404
721
  try {
@@ -407,27 +724,27 @@ function parse(line) {
407
724
  return null;
408
725
  }
409
726
  }
410
- function textOf2(event) {
727
+ function textOf3(event) {
411
728
  return (event.message?.content ?? []).filter((part) => part.type === "text").map((part) => part.text ?? "").join("");
412
729
  }
413
- function describeTool(call2, cwd) {
414
- const [name, body] = Object.entries(call2 ?? {})[0] ?? [];
730
+ function describeTool2(call3, cwd) {
731
+ const [name, body] = Object.entries(call3 ?? {})[0] ?? [];
415
732
  if (!name) return "a tool";
416
733
  const verb = name.replace(/ToolCall$/, "").replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase();
417
- const args3 = body?.args ?? {};
418
- const subject = args3.path ?? args3.command ?? args3.query ?? args3.pattern;
734
+ const args4 = body?.args ?? {};
735
+ const subject = args4.path ?? args4.command ?? args4.query ?? args4.pattern;
419
736
  if (typeof subject !== "string") return verb;
420
737
  const short = cwd && subject.startsWith(`${cwd}/`) ? subject.slice(cwd.length + 1) : subject;
421
738
  return `${verb} ${short}`;
422
739
  }
423
- function describeEvent(line, cwd) {
424
- const event = parse(line);
740
+ function describeEvent2(line, cwd) {
741
+ const event = parse2(line);
425
742
  if (!event) return line.trim() === "" ? null : line;
426
743
  switch (event.type) {
427
744
  case "system":
428
745
  return event.model ? `Started with ${event.model}.` : "Started.";
429
746
  case "tool_call":
430
- return event.subtype === "started" ? `Running ${describeTool(event.tool_call, cwd)}` : null;
747
+ return event.subtype === "started" ? `Running ${describeTool2(event.tool_call, cwd)}` : null;
431
748
  case "assistant":
432
749
  return null;
433
750
  case "result": {
@@ -440,32 +757,32 @@ function describeEvent(line, cwd) {
440
757
  return null;
441
758
  }
442
759
  }
443
- function answerFrom(stdout) {
444
- const events = stdout.split("\n").map(parse);
760
+ function answerFrom2(stdout) {
761
+ const events = stdout.split("\n").map(parse2);
445
762
  const result = events.filter((event) => event?.type === "result").at(-1);
446
763
  const failed = result?.is_error === true;
447
764
  const whole = events.findLastIndex(
448
765
  (event) => event?.type === "assistant" && event.timestamp_ms === void 0
449
766
  );
450
- if (whole >= 0) return { text: textOf2(events[whole]).trim(), failed };
767
+ if (whole >= 0) return { text: textOf3(events[whole]).trim(), failed };
451
768
  const lastTool = events.findLastIndex((event) => event?.type === "tool_call");
452
- const partial = events.slice(lastTool + 1).filter((event) => event?.type === "assistant").map((event) => textOf2(event));
769
+ const partial = events.slice(lastTool + 1).filter((event) => event?.type === "assistant").map((event) => textOf3(event));
453
770
  if (partial.length > 0) return { text: partial.join("").trim(), failed };
454
771
  return { text: (result?.result ?? "").trim(), failed };
455
772
  }
456
- var init_events = __esm({
773
+ var init_events2 = __esm({
457
774
  "src/agents/cursor-agent/events.ts"() {
458
775
  "use strict";
459
776
  }
460
777
  });
461
778
 
462
779
  // src/agents/cursor-agent/runtime.ts
463
- async function binary2() {
780
+ async function binary3() {
464
781
  const found = await resolveBinary(cursorAgentManifest.binaries);
465
782
  if (!found) throw new Error("The cursor-agent binary was not found on this host.");
466
783
  return found;
467
784
  }
468
- function args2(input) {
785
+ function args3(input) {
469
786
  const list = [
470
787
  "-p",
471
788
  // A stream of events rather than one block at the end: it can be watched
@@ -490,14 +807,14 @@ function args2(input) {
490
807
  return list;
491
808
  }
492
809
  var cursorAgentRuntime;
493
- var init_runtime2 = __esm({
810
+ var init_runtime3 = __esm({
494
811
  "src/agents/cursor-agent/runtime.ts"() {
495
812
  "use strict";
496
813
  init_define();
497
814
  init_discover();
498
815
  init_process();
499
- init_events();
500
- init_manifest2();
816
+ init_events2();
817
+ init_manifest3();
501
818
  cursorAgentRuntime = defineAgentRuntime({
502
819
  async detect() {
503
820
  const found = await resolveBinary(cursorAgentManifest.binaries);
@@ -516,11 +833,11 @@ var init_runtime2 = __esm({
516
833
  };
517
834
  },
518
835
  invocation(input) {
519
- return { bin: cursorAgentManifest.binaries[0], args: args2(input) };
836
+ return { bin: cursorAgentManifest.binaries[0], args: args3(input) };
520
837
  },
521
838
  async run(input) {
522
- const bin = await binary2();
523
- const argv = args2(input);
839
+ const bin = await binary3();
840
+ const argv = args3(input);
524
841
  const outcome = await runProcess({
525
842
  bin,
526
843
  args: argv,
@@ -528,10 +845,10 @@ var init_runtime2 = __esm({
528
845
  timeoutMs: input.settings.timeoutMs,
529
846
  signal: input.signal,
530
847
  logPath: input.logFile,
531
- logLine: (line) => describeEvent(line, input.cwd)
848
+ logLine: (line) => describeEvent2(line, input.cwd)
532
849
  });
533
850
  const command = `${bin} ${argv.join(" ")}`;
534
- const answer = answerFrom(outcome.stdout);
851
+ const answer = answerFrom2(outcome.stdout);
535
852
  if (outcome.aborted) {
536
853
  return { ok: false, aborted: true, output: answer.text, durationMs: outcome.durationMs, command };
537
854
  }
@@ -567,8 +884,10 @@ var init_runtimes = __esm({
567
884
  "src/agents/runtimes.ts"() {
568
885
  init_runtime();
569
886
  init_runtime2();
887
+ init_runtime3();
570
888
  RUNTIMES2 = {
571
889
  codex: codexRuntime,
890
+ claude: claudeRuntime,
572
891
  "cursor-agent": cursorAgentRuntime
573
892
  };
574
893
  }
@@ -580,7 +899,7 @@ import { createServer } from "node:http";
580
899
  // src/server/signals.ts
581
900
  import { mkdtempSync } from "node:fs";
582
901
  import { tmpdir } from "node:os";
583
- import { join as join7 } from "node:path";
902
+ import { join as join8 } from "node:path";
584
903
 
585
904
  // node_modules/.pnpm/drizzle-orm@0.45.2_@libsql+client@0.18.0_@types+better-sqlite3@9.6.0_better-sqlite3@13.0.3/node_modules/drizzle-orm/entity.js
586
905
  var entityKind = /* @__PURE__ */ Symbol.for("drizzle:entityKind");
@@ -823,8 +1142,8 @@ var ForeignKey = class {
823
1142
  };
824
1143
 
825
1144
  // node_modules/.pnpm/drizzle-orm@0.45.2_@libsql+client@0.18.0_@types+better-sqlite3@9.6.0_better-sqlite3@13.0.3/node_modules/drizzle-orm/tracing-utils.js
826
- function iife(fn, ...args3) {
827
- return fn(...args3);
1145
+ function iife(fn, ...args4) {
1146
+ return fn(...args4);
828
1147
  }
829
1148
 
830
1149
  // node_modules/.pnpm/drizzle-orm@0.45.2_@libsql+client@0.18.0_@types+better-sqlite3@9.6.0_better-sqlite3@13.0.3/node_modules/drizzle-orm/pg-core/unique-constraint.js
@@ -1612,7 +1931,7 @@ function sql(strings, ...params) {
1612
1931
  return new SQL([new StringChunk(str)]);
1613
1932
  }
1614
1933
  sql2.raw = raw;
1615
- function join8(chunks, separator) {
1934
+ function join9(chunks, separator) {
1616
1935
  const result = [];
1617
1936
  for (const [i, chunk] of chunks.entries()) {
1618
1937
  if (i > 0 && separator !== void 0) {
@@ -1622,7 +1941,7 @@ function sql(strings, ...params) {
1622
1941
  }
1623
1942
  return new SQL(result);
1624
1943
  }
1625
- sql2.join = join8;
1944
+ sql2.join = join9;
1626
1945
  function identifier(value) {
1627
1946
  return new Name(value);
1628
1947
  }
@@ -2490,7 +2809,7 @@ var repositories = {
2490
2809
  key: "repositories",
2491
2810
  kind: "string_list",
2492
2811
  label: "Repositories",
2493
- help: "owner/name, one per line. Wildcards work: acme/* or */*. Leave empty for every repository you can see.",
2812
+ help: "owner/name, one per line. acme/* works. Empty means all.",
2494
2813
  placeholder: "acme/web"
2495
2814
  };
2496
2815
  var ignoreDrafts = {
@@ -2503,7 +2822,7 @@ var ignoreBots = {
2503
2822
  key: "ignoreBots",
2504
2823
  kind: "boolean",
2505
2824
  label: "Skip pull requests opened by bots",
2506
- help: "Dependabot and friends open a great many, and each one costs a full run.",
2825
+ help: "Such as Dependabot.",
2507
2826
  default: true
2508
2827
  };
2509
2828
  var sizeLimit = {
@@ -2522,7 +2841,7 @@ var issueTarget = {
2522
2841
  key: "issue",
2523
2842
  kind: "text",
2524
2843
  label: "Issue or pull request",
2525
- help: "A GitHub URL. Leave empty to write back to whatever triggered the loop.",
2844
+ help: "Empty replies where it was asked. Or a GitHub issue URL.",
2526
2845
  placeholder: "https://github.com/acme/web/issues/12"
2527
2846
  };
2528
2847
  var REVIEW_PROMPT = [
@@ -2540,94 +2859,77 @@ var REVIEW_PROMPT = [
2540
2859
  "Where you cannot see enough of the code to judge something, say so instead of",
2541
2860
  "guessing."
2542
2861
  ].join("\n");
2543
- var PLAN_PROMPT = [
2544
- "Write a short implementation plan for this issue.",
2862
+ var REPLY_PROMPT = [
2863
+ "Answer this issue in a comment, the way a teammate who knows this codebase would.",
2545
2864
  "",
2546
- "Say what should change, in which files where you can tell, and what to watch",
2547
- "out for. Stay inside what the issue actually supports: if it is too vague to",
2548
- "plan, say what is missing rather than inventing the requirements.",
2865
+ "Work out what the issue is asking for. If it is a question, answer it from the",
2866
+ "code rather than from memory. If it reports a bug, find the likely cause and",
2867
+ "point at the files and lines involved. If it asks for a change, say how you",
2868
+ "would make it, where, and what to watch out for.",
2549
2869
  "",
2550
- "Do not write the implementation."
2551
- ].join("\n");
2552
- var IMPLEMENT_PROMPT = [
2553
- "Implement this issue in the checkout you are in.",
2554
- "",
2555
- "Read enough of the surrounding code first that what you write looks like it",
2556
- "belongs: the same patterns, the same names, the same way of handling errors.",
2557
- "Where the project has tests for work like this, add one.",
2558
- "",
2559
- "Stay inside what the issue asks for. A change that also tidies three other",
2560
- "things is a change nobody can review. If the issue is too vague to implement,",
2561
- "or doing it properly needs a decision that is not yours to make, reply with",
2562
- "exactly NOTHING_TO_DO rather than guessing: an unwanted pull request costs",
2563
- "more attention than a missing one.",
2564
- "",
2565
- "Run the project's tests if you can work out how, and say in your description",
2566
- "whether you did and what happened."
2870
+ "Read the code; do not change it. If the issue is too vague to answer, say what",
2871
+ "is missing rather than inventing it."
2567
2872
  ].join("\n");
2568
2873
  var githubManifest = defineManifest({
2569
2874
  id: "github",
2570
2875
  name: "GitHub",
2571
- tagline: "Pick up review requests and assigned issues.",
2876
+ tagline: "Pick up reviews and issues sent to your team's shared GitHub account.",
2572
2877
  docsUrl: "https://docs.github.com/rest",
2573
2878
  icon: "Github",
2574
2879
  accent: "bg-neutral-900 text-white",
2575
2880
  auth: {
2576
2881
  kind: "oauth_redirect",
2577
2882
  scopes: GITHUB_SCOPES,
2578
- needsAppRegistration: true
2883
+ needsAppRegistration: true,
2884
+ // The Connection is the team's agent on GitHub: whatever it writes appears
2885
+ // under its name, and the work it picks up is whatever the team sends to
2886
+ // it. An account made for this is cleanest, but a teammate's own works too
2887
+ // if they are happy for it to be shared.
2888
+ note: "Connect the account the team will send work to. An account made for this is cleanest, but your own works too if you are happy to share it. Reviews and comments appear under its name, and it sees only the repositories it can access. Sign in to GitHub as that account first; GitHub authorizes whichever account the browser is signed in as."
2579
2889
  },
2580
- workflows: [
2890
+ triggers: [
2581
2891
  {
2582
2892
  id: "github.review_requested",
2583
- name: "Review pull requests I am asked to review",
2584
- summary: "Reads the change and posts a review whenever someone asks for yours.",
2893
+ when: "A GitHub review is requested",
2585
2894
  // Team requests matter more than they sound: in most repositories with a
2586
2895
  // CODEOWNERS file, review arrives addressed to a team rather than a person.
2587
- trigger: "your review is requested, either directly or through a team you belong to",
2896
+ trigger: "someone requests a review from the connected account, directly or through its team",
2588
2897
  // review-requested covers teams; user-review-requested would not.
2589
2898
  watches: "is:open is:pr review-requested:@me",
2590
- writes: "a review on the pull request, as a comment rather than an approval",
2591
2899
  settings: [repositories, ignoreDrafts, ignoreBots, sizeLimit],
2592
- prompt: REVIEW_PROMPT,
2593
- guidancePlaceholder: "Anything specific to your team. For example: we require a test for every new endpoint.",
2594
- answer: "review",
2595
2900
  runsIn: "checkout",
2901
+ answer: "review",
2596
2902
  actionId: "github.submit_review"
2597
2903
  },
2598
2904
  {
2599
2905
  id: "github.issue_assigned",
2600
- name: "Plan issues assigned to me",
2601
- summary: "Posts a short implementation plan when an issue lands on you.",
2602
- trigger: "an issue is assigned to you",
2603
- // is:issue matters: without it this would pick up your own pull requests.
2906
+ when: "A GitHub issue is assigned",
2907
+ trigger: "an issue is assigned to the connected account",
2908
+ // is:issue matters: without it this would pick up pull requests too.
2604
2909
  watches: "is:open is:issue assignee:@me",
2605
- writes: "a comment on the issue",
2606
2910
  settings: [repositories],
2607
- prompt: PLAN_PROMPT,
2608
- guidancePlaceholder: "Anything specific to this codebase worth knowing before planning.",
2609
- answer: "text",
2610
2911
  runsIn: "checkout",
2912
+ answer: "text",
2611
2913
  actionId: "github.post_issue_comment"
2914
+ }
2915
+ ],
2916
+ workflows: [
2917
+ {
2918
+ id: "github.review_requested",
2919
+ name: "Review pull requests",
2920
+ summary: "Posts a review when someone requests one from the connected account.",
2921
+ triggerId: "github.review_requested",
2922
+ prompt: REVIEW_PROMPT
2612
2923
  },
2613
2924
  {
2614
- id: "github.issue_implement",
2615
- name: "Implement issues assigned to me",
2616
- summary: "Writes the change and opens a draft pull request.",
2617
- trigger: "an issue is assigned to you",
2618
- watches: "is:open is:issue assignee:@me",
2619
- writes: "a draft pull request",
2620
- // The only workflow that writes code, and so the only one whose agent
2621
- // gets somewhere to write. Kept apart from "Plan issues assigned to me"
2622
- // rather than replacing it: asking for a plan and asking for the change
2623
- // are different jobs, and which one an issue deserves is a judgement
2624
- // about the issue.
2625
- runsIn: "checkout",
2626
- settings: [repositories],
2627
- prompt: IMPLEMENT_PROMPT,
2628
- guidancePlaceholder: "How work is done here. For example: every new endpoint needs a test, and we do not add dependencies without asking.",
2629
- answer: "code",
2630
- actionId: "github.open_pull_request"
2925
+ // The id is the one "Plan issues assigned to me" had. Planning was one
2926
+ // kind of answer to an issue; replying covers it and the rest, and loops
2927
+ // made for planning keep the prompt they copied.
2928
+ id: "github.issue_assigned",
2929
+ name: "Reply to issues",
2930
+ summary: "Answers an issue in a comment when it is assigned to the connected account.",
2931
+ triggerId: "github.issue_assigned",
2932
+ prompt: REPLY_PROMPT
2631
2933
  }
2632
2934
  ],
2633
2935
  actions: [
@@ -2650,80 +2952,59 @@ var githubManifest = defineManifest({
2650
2952
  // A comment is one body of markdown with nowhere to attach anything, so
2651
2953
  // a review arrives here with its findings written into the prose.
2652
2954
  accepts: ["text"]
2653
- },
2654
- {
2655
- id: "github.open_pull_request",
2656
- name: "Open a draft pull request",
2657
- summary: "Open a draft pull request for the branch the agent already pushed.",
2658
- // The agent cloned and pushed with the machine's git. This action only
2659
- // names the pull request through the Connection.
2660
- target: [],
2661
- accepts: ["code"]
2662
2955
  }
2663
2956
  ],
2664
2957
  // Nothing to configure per account: what the account can see is what GitHub
2665
2958
  // decides, and every narrowing choice belongs to a loop.
2666
2959
  settings: [],
2667
- // The redirect authorizes whichever account the browser is already signed in
2668
- // as, so a second account is out of reach without signing out of GitHub
2669
- // first. Connections are still rows, so this is a product decision only.
2960
+ // One shared account per team. The redirect also authorizes whichever account
2961
+ // the browser is already signed in as, so a second one is out of reach
2962
+ // without signing out of GitHub first. Connections are still rows, so this
2963
+ // is a product decision only.
2670
2964
  allowsMultipleAccounts: false,
2671
2965
  byHand: { kind: "link", placeholder: "https://github.com/acme/web/pull/123" }
2672
2966
  });
2673
2967
 
2674
2968
  // src/connectors/gmail/manifest.ts
2675
- var GMAIL_SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"];
2969
+ var GMAIL_SCOPES = [
2970
+ "https://www.googleapis.com/auth/gmail.readonly",
2971
+ "https://www.googleapis.com/auth/gmail.send"
2972
+ ];
2676
2973
  var extraQuery = {
2677
2974
  key: "extraQuery",
2678
2975
  kind: "text",
2679
- label: "Narrow it down",
2680
- help: "Gmail search terms, added to the query above. For example: from:bank.example.com, or subject:invoice, or -from:notifications@github.com.",
2681
- placeholder: "from:boss@example.com"
2976
+ label: "Only mail matching",
2977
+ help: "Gmail search terms, such as from:billing@vendor.com or subject:invoice.",
2978
+ placeholder: "from:alerts@example.com"
2979
+ };
2980
+ var needsMeOnly = {
2981
+ key: "needsMeOnly",
2982
+ kind: "boolean",
2983
+ label: "Skip mail that needs nothing",
2984
+ help: "A quick look first decides which mail asks for something. The rest is kept, not run.",
2985
+ default: true
2682
2986
  };
2683
2987
  var perPoll = {
2684
2988
  key: "maxPerPoll",
2685
2989
  kind: "select",
2686
- label: "At most, per look",
2687
- help: "Each mail costs a full agent run. A quiet mailbox never reaches this; a busy one should be narrowed above rather than raised here.",
2990
+ label: "At most, per check",
2688
2991
  options: [
2689
- { value: "3", label: "3 messages" },
2690
- { value: "10", label: "10 messages" },
2691
- { value: "25", label: "25 messages" }
2992
+ { value: "3", label: "3 emails" },
2993
+ { value: "10", label: "10 emails" },
2994
+ { value: "25", label: "25 emails" }
2692
2995
  ],
2693
2996
  default: "10"
2694
2997
  };
2695
- var needsMeOnly = {
2696
- key: "needsMeOnly",
2697
- kind: "boolean",
2698
- label: "Only tell me when it needs me",
2699
- help: "One cheap look at everything that arrived decides which mails are actually asking something of you, from the sender, subject and first line. The rest wait in the backlog with a reason, so you can still run one if this got it wrong.",
2700
- default: true
2701
- };
2702
2998
  var skipOwn = {
2703
2999
  key: "skipOwn",
2704
3000
  kind: "boolean",
2705
- label: "Skip mail I sent",
2706
- help: "Your own messages show up in threads you are part of, and being told about what you just sent is not useful.",
3001
+ label: "Skip mail sent from this account",
2707
3002
  default: true
2708
3003
  };
2709
- var TELL_ME_PROMPT = [
2710
- "Read the email in EMAIL.md and tell me what it says.",
2711
- "",
2712
- "You are writing a phone notification, not a summary document. Two or three",
2713
- 'sentences. No headings, no bullet lists, no preamble like "this email is".',
2714
- "Lead with who it is from and what they want.",
2715
- "",
2716
- "Say plainly if it needs something from me and by when. If there is a",
2717
- "deadline, an amount of money, a date, or a link I have to act on, include it",
2718
- "exactly as written rather than describing it.",
2719
- "",
2720
- "If it is an automated notification, a newsletter, or otherwise needs nothing",
2721
- "from me, say so in one line and stop."
2722
- ].join("\n");
2723
3004
  var gmailManifest = defineManifest({
2724
3005
  id: "gmail",
2725
3006
  name: "Gmail",
2726
- tagline: "Read new mail and have an agent tell you what matters.",
3007
+ tagline: "Start a loop when an email arrives, or email an answer to the connected account.",
2727
3008
  docsUrl: "https://developers.google.com/gmail/api/guides",
2728
3009
  icon: "Gmail",
2729
3010
  // Gmail's own red, now that the mark is Gmail's own too. A near miss on a
@@ -2734,45 +3015,40 @@ var gmailManifest = defineManifest({
2734
3015
  scopes: GMAIL_SCOPES,
2735
3016
  needsAppRegistration: true
2736
3017
  },
2737
- workflows: [
3018
+ triggers: [
2738
3019
  {
2739
3020
  id: "gmail.new_mail",
2740
- name: "Tell me about new mail",
2741
- summary: "Reads mail as it arrives and sends you a short account of it somewhere you look.",
2742
- trigger: "mail arrives in your inbox",
3021
+ when: "An email arrives",
3022
+ trigger: "an email arrives in the connected inbox",
2743
3023
  // A day's window rather than "unread": reading a mail on your phone
2744
3024
  // should not decide whether the loop ever saw it. What stops a mail
2745
- // being handled twice is its id, not its state, so re-seeing yesterday's
2746
- // costs nothing and a poller that was off for an hour misses nothing.
3025
+ // being handled twice is its id, not its state.
2747
3026
  watches: "in:inbox newer_than:1d",
2748
- writes: "a message telling you what arrived",
2749
- // Nothing to check out: the mail is the whole of the work, and it is
2750
- // handed over as a file.
3027
+ // The mail is the whole of the work, handed over as a file.
2751
3028
  runsIn: "temp",
2752
3029
  settings: [extraQuery, needsMeOnly, perPoll, skipOwn],
2753
- prompt: TELL_ME_PROMPT,
2754
- guidancePlaceholder: "What you care about in your mail. For example: I only need to know about anything involving money or a deadline.",
2755
3030
  answer: "text",
2756
- // Gmail cannot write, so this answers on WeChat, which is the point of
2757
- // the workflow rather than an accident of it: mail you are already
2758
- // reading in Gmail does not need telling about. `source` would mean
2759
- // nothing here, since no chat started this.
2760
- actionConnectorId: "wechat",
2761
- actionId: "wechat.reply",
2762
- actionTarget: { to: "me" },
2763
- // Half an hour, because mail turns up one piece at a time and the whole
2764
- // of deciding what is worth a run is having several to compare. Looking
2765
- // every two minutes meant almost always finding exactly one, which is
2766
- // not a batch, so every newsletter got an agent to itself.
2767
- //
2768
- // The cost of waiting is being told half an hour late about something
2769
- // that sat unread for hours before it was sent.
3031
+ // Kept in Inbox until a loop says where it should go.
3032
+ actionConnectorId: "schedule",
3033
+ actionId: "schedule.record",
3034
+ // Mail turns up one piece at a time, and deciding what deserves a run
3035
+ // needs several to compare.
2770
3036
  pollEveryMs: 30 * 6e4
2771
3037
  }
2772
3038
  ],
2773
- // Read-only, so there is nothing to offer. A loop built on Gmail writes
2774
- // through whichever connector it picks.
2775
- actions: [],
3039
+ // No ready-made job: what to do with mail is the team's to say, in a
3040
+ // custom loop.
3041
+ workflows: [],
3042
+ actions: [
3043
+ {
3044
+ id: "gmail.send",
3045
+ name: "Email this account",
3046
+ summary: "Send the answer to the connected account's own inbox, in the same thread when it started from an email there.",
3047
+ target: [],
3048
+ // A review sent here reads as prose, with file and line written in.
3049
+ accepts: ["text"]
3050
+ }
3051
+ ],
2776
3052
  settings: [],
2777
3053
  // The redirect authorizes whichever account the browser is signed in as, so
2778
3054
  // reaching a second one means signing out of Google first.
@@ -2868,24 +3144,17 @@ var at = {
2868
3144
  key: "at",
2869
3145
  kind: "text",
2870
3146
  label: "At",
2871
- help: "On a 24-hour clock, in this host's own time zone. Only the minutes are used by an hourly loop.",
3147
+ help: "24-hour time, in this host's time zone.",
2872
3148
  placeholder: "09:00"
2873
3149
  };
2874
3150
  var weekday = {
2875
3151
  key: "weekday",
2876
3152
  kind: "select",
2877
3153
  label: "On",
2878
- help: "Only used by a loop that runs once a week.",
3154
+ help: "For weekly loops.",
2879
3155
  options: WEEKDAY_NAMES.map((name, index2) => ({ value: String(index2), label: name })),
2880
3156
  default: "1"
2881
3157
  };
2882
- var folder = {
2883
- key: "folder",
2884
- kind: "text",
2885
- label: "Folder to work in",
2886
- help: "An absolute path. Nothing arrives to be looked at on a schedule, so this is the whole of what the agent is given.",
2887
- placeholder: "/Users/you/code/web"
2888
- };
2889
3158
  var RECURRING_PROMPT = [
2890
3159
  "Nothing has arrived: this is running because the time came round.",
2891
3160
  "",
@@ -2907,42 +3176,203 @@ var scheduleManifest = defineManifest({
2907
3176
  allowsMultipleAccounts: false,
2908
3177
  byHand: { kind: "now" },
2909
3178
  settings: [],
3179
+ triggers: [
3180
+ {
3181
+ id: "schedule.recurring",
3182
+ when: "On a schedule",
3183
+ trigger: "the time you set comes round",
3184
+ // No query to show. A clock is not something one can be written for,
3185
+ // and an invented one shown as if it were real is worse than nothing.
3186
+ runsIn: "folder",
3187
+ settings: [every, at, weekday],
3188
+ answer: "text",
3189
+ actionId: "schedule.record"
3190
+ }
3191
+ ],
3192
+ workflows: [
3193
+ {
3194
+ id: "schedule.recurring",
3195
+ name: "Do something on a schedule",
3196
+ summary: "Runs the agent at the times you set, in a folder you name. The answer stays in Inbox, or goes to Slack, Lark, or GitHub.",
3197
+ triggerId: "schedule.recurring",
3198
+ prompt: RECURRING_PROMPT
3199
+ }
3200
+ ],
3201
+ actions: [
3202
+ {
3203
+ id: "schedule.record",
3204
+ name: "Keep in Inbox",
3205
+ summary: "Keeps the answer in Inbox and sends it nowhere.",
3206
+ target: [],
3207
+ accepts: ["text", "review"]
3208
+ }
3209
+ ]
3210
+ });
3211
+
3212
+ // src/connectors/slack/manifest.ts
3213
+ var channel = {
3214
+ key: "channel",
3215
+ kind: "text",
3216
+ label: "Channel",
3217
+ help: "Empty replies where it was asked. Or a channel ID.",
3218
+ placeholder: "C0123456789"
3219
+ };
3220
+ var ASK_PROMPT = [
3221
+ "Someone has sent you a message in Slack. Do what it asks, working in this directory,",
3222
+ "and then answer them.",
3223
+ "",
3224
+ "You are answering into Slack. Be brief: a couple of short paragraphs at most,",
3225
+ "no headings, no bullet lists unless you are genuinely listing things. Plain sentences.",
3226
+ "",
3227
+ "If the message asks a question about this code, read enough of it to answer",
3228
+ "properly rather than guessing. If it asks for a change, make the change and",
3229
+ "say in one line what you did. If the message is too vague to act on, say what",
3230
+ "you need to know instead of assuming."
3231
+ ].join("\n");
3232
+ var slackManifest = defineManifest({
3233
+ id: "slack",
3234
+ name: "Slack bot",
3235
+ tagline: "Paste a bot token from a Slack app \u2014 not a Slack login. @mention or DM the bot, and it answers in the thread.",
3236
+ docsUrl: "https://api.slack.com/apps",
3237
+ icon: "Slack",
3238
+ accent: "bg-[#4a154b] text-white",
3239
+ auth: {
3240
+ kind: "token",
3241
+ helpUrl: "https://api.slack.com/apps",
3242
+ note: "This is a Slack app in the workspace, not a Slack account sign-in. Create an app at api.slack.com/apps, add bot scopes (channels:history, channels:read, groups:history, groups:read, im:history, im:read, mpim:history, mpim:read, chat:write), Install to Workspace, then paste the Bot User OAuth Token. Invite the bot to any channel it should watch.",
3243
+ fields: [
3244
+ {
3245
+ key: "botToken",
3246
+ label: "Bot User OAuth Token",
3247
+ placeholder: "xoxb-...",
3248
+ secret: true
3249
+ }
3250
+ ]
3251
+ },
3252
+ triggers: [
3253
+ {
3254
+ id: "slack.ask",
3255
+ when: "The Slack bot is mentioned",
3256
+ trigger: "someone @mentions the Slack bot in a channel it is in, or DMs it",
3257
+ runsIn: "folder",
3258
+ settings: [],
3259
+ answer: "text",
3260
+ actionId: "slack.reply",
3261
+ pollEveryMs: 6e4
3262
+ }
3263
+ ],
2910
3264
  workflows: [
2911
3265
  {
2912
- id: "schedule.recurring",
2913
- name: "Do something on a schedule",
2914
- summary: "Runs an agent in a folder you name, and keeps what it says.",
2915
- trigger: "the time you set comes round",
2916
- // No query to show. A clock is not something one can be written for,
2917
- // and an invented one shown as if it were real is worse than nothing.
2918
- writes: "a note in the log",
3266
+ id: "slack.ask",
3267
+ name: "Do what I ask the Slack bot",
3268
+ summary: "Runs the agent when someone @mentions the Slack bot or DMs it, and answers in the thread.",
3269
+ triggerId: "slack.ask",
3270
+ prompt: ASK_PROMPT
3271
+ }
3272
+ ],
3273
+ actions: [
3274
+ {
3275
+ id: "slack.reply",
3276
+ name: "Reply in Slack",
3277
+ summary: "Post a message back to the thread that asked, or to a channel you name.",
3278
+ target: [channel],
3279
+ accepts: ["text"]
3280
+ }
3281
+ ],
3282
+ settings: [],
3283
+ allowsMultipleAccounts: false,
3284
+ byHand: { kind: "none" }
3285
+ });
3286
+
3287
+ // src/connectors/feishu/manifest.ts
3288
+ var chat = {
3289
+ key: "chat",
3290
+ kind: "text",
3291
+ label: "Chat ID",
3292
+ help: "Empty replies where it was asked. Or a chat ID (oc_\u2026).",
3293
+ placeholder: "oc_..."
3294
+ };
3295
+ var ASK_PROMPT2 = [
3296
+ "Someone has sent you a message in Feishu. Do what it asks, working in this directory,",
3297
+ "and then answer them.",
3298
+ "",
3299
+ "You are answering into Feishu. Be brief: a couple of short paragraphs at most,",
3300
+ "no headings, no bullet lists unless you are genuinely listing things. Plain sentences.",
3301
+ "",
3302
+ "If the message asks a question about this code, read enough of it to answer",
3303
+ "properly rather than guessing. If it asks for a change, make the change and",
3304
+ "say in one line what you did. If the message is too vague to act on, say what",
3305
+ "you need to know instead of assuming."
3306
+ ].join("\n");
3307
+ var feishuManifest = defineManifest({
3308
+ id: "feishu",
3309
+ name: "Feishu / Lark bot",
3310
+ tagline: "Paste App ID and App Secret from a Feishu or Lark app \u2014 not a Feishu login. @mention the bot in a group, and it answers in the thread.",
3311
+ docsUrl: "https://open.feishu.cn/app",
3312
+ icon: "Feishu",
3313
+ accent: "bg-[#3370ff] text-white",
3314
+ auth: {
3315
+ kind: "token",
3316
+ helpUrl: "https://open.feishu.cn/app",
3317
+ note: "This is a custom Feishu or Lark app, not a Feishu account sign-in. Create an app, enable Bot, add permissions (im:chat:readonly, im:message, im:message.group_msg, im:message:send_as_bot), create a version and publish it, then paste App ID and App Secret. Invite the bot to any group it should watch. Put lark in Open platform for Lark (international).",
3318
+ fields: [
3319
+ {
3320
+ key: "appId",
3321
+ label: "App ID",
3322
+ placeholder: "cli_...",
3323
+ secret: false
3324
+ },
3325
+ {
3326
+ key: "appSecret",
3327
+ label: "App Secret",
3328
+ placeholder: "App Secret",
3329
+ secret: true
3330
+ },
3331
+ {
3332
+ key: "host",
3333
+ label: "Open platform",
3334
+ placeholder: "feishu or lark",
3335
+ secret: false,
3336
+ optional: true
3337
+ }
3338
+ ]
3339
+ },
3340
+ triggers: [
3341
+ {
3342
+ id: "feishu.ask",
3343
+ when: "The Feishu / Lark bot is mentioned",
3344
+ trigger: "someone @mentions the Feishu / Lark bot in a group it is in",
2919
3345
  runsIn: "folder",
2920
- settings: [every, at, weekday, folder],
2921
- prompt: RECURRING_PROMPT,
2922
- guidancePlaceholder: "Anything that holds every time this runs. For example: this repository releases on Thursdays, so say if anything is unmerged by Wednesday evening.",
3346
+ settings: [],
2923
3347
  answer: "text",
2924
- actionId: "schedule.record"
3348
+ actionId: "feishu.reply",
3349
+ pollEveryMs: 6e4
3350
+ }
3351
+ ],
3352
+ workflows: [
3353
+ {
3354
+ id: "feishu.ask",
3355
+ name: "Do what I ask the Feishu / Lark bot",
3356
+ summary: "Runs the agent when someone @mentions the Feishu / Lark bot in a group, and answers in the thread.",
3357
+ triggerId: "feishu.ask",
3358
+ prompt: ASK_PROMPT2
2925
3359
  }
2926
3360
  ],
2927
3361
  actions: [
2928
3362
  {
2929
- id: "schedule.record",
2930
- name: "Keep it in the log",
2931
- summary: "Leaves the answer here and sends it nowhere. Every run is recorded anyway, so this is what to use until it is worth pushing somewhere.",
2932
- target: [],
2933
- accepts: ["text", "review"]
3363
+ id: "feishu.reply",
3364
+ name: "Reply in Feishu / Lark",
3365
+ summary: "Post a message back to the thread that asked, or to a chat you name.",
3366
+ target: [chat],
3367
+ accepts: ["text"]
2934
3368
  }
2935
- ]
3369
+ ],
3370
+ settings: [],
3371
+ allowsMultipleAccounts: false,
3372
+ byHand: { kind: "none" }
2936
3373
  });
2937
3374
 
2938
3375
  // src/connectors/wechat/manifest.ts
2939
- var folder2 = {
2940
- key: "folder",
2941
- kind: "text",
2942
- label: "Folder to work in",
2943
- help: "An absolute path to a checkout on this host. The agent runs here, so it can read the code you are asking about.",
2944
- placeholder: "/Users/you/code/your-project"
2945
- };
2946
3376
  var askers = {
2947
3377
  key: "askers",
2948
3378
  kind: "select",
@@ -2957,14 +3387,14 @@ var recipient = {
2957
3387
  key: "to",
2958
3388
  kind: "select",
2959
3389
  label: "Who to send it to",
2960
- help: "WeChat only reliably delivers a reply to a conversation you started. Sending to yourself out of the blue is refused unless you have written to the bot in the last half day or so, which makes this a poor fit for anything that has to arrive overnight.",
3390
+ help: '"Me" only arrives if you messaged the bot in the last half day.',
2961
3391
  options: [
2962
3392
  { value: "source", label: "Whoever asked" },
2963
3393
  { value: "me", label: "Me" }
2964
3394
  ],
2965
3395
  default: "source"
2966
3396
  };
2967
- var ASK_PROMPT = [
3397
+ var ASK_PROMPT3 = [
2968
3398
  "Someone has sent you a message. Do what it asks, working in this directory,",
2969
3399
  "and then answer them.",
2970
3400
  "",
@@ -2988,23 +3418,28 @@ var wechatManifest = {
2988
3418
  kind: "qr_scan",
2989
3419
  note: "Scanning binds a bot to your WeChat account. Loopable can then read what people send that bot and answer as it, and nothing else on your account."
2990
3420
  },
2991
- workflows: [
3421
+ triggers: [
2992
3422
  {
2993
3423
  id: "wechat.ask",
2994
- name: "Do what I ask the bot",
2995
- summary: "Runs your agent on a runner when you message the bot, and answers in the chat.",
2996
- trigger: "you send the bot a message",
3424
+ when: "The WeChat bot gets a message",
3425
+ trigger: "someone sends the WeChat bot a message",
2997
3426
  // No query to show: messages arrive on a stream rather than being found
2998
3427
  // by asking, so there is nothing here that could be checked.
2999
- writes: "a reply in the chat",
3000
3428
  runsIn: "folder",
3001
- settings: [folder2, askers],
3002
- prompt: ASK_PROMPT,
3003
- guidancePlaceholder: "Anything the agent should know about this project before it starts. For example: run pnpm test before saying a change works.",
3429
+ settings: [askers],
3004
3430
  answer: "text",
3005
3431
  actionId: "wechat.reply"
3006
3432
  }
3007
3433
  ],
3434
+ workflows: [
3435
+ {
3436
+ id: "wechat.ask",
3437
+ name: "Do what I ask the WeChat bot",
3438
+ summary: "Runs the agent when someone messages the WeChat bot, and answers in the chat.",
3439
+ triggerId: "wechat.ask",
3440
+ prompt: ASK_PROMPT3
3441
+ }
3442
+ ],
3008
3443
  actions: [
3009
3444
  {
3010
3445
  id: "wechat.reply",
@@ -3027,6 +3462,8 @@ var wechatManifest = {
3027
3462
  // src/connectors/manifests.ts
3028
3463
  var CONNECTOR_MANIFESTS = [
3029
3464
  githubManifest,
3465
+ slackManifest,
3466
+ feishuManifest,
3030
3467
  gmailManifest,
3031
3468
  wechatManifest,
3032
3469
  scheduleManifest
@@ -3037,8 +3474,36 @@ function connectorManifest(id) {
3037
3474
  function needsAccount(id) {
3038
3475
  return connectorManifest(id)?.auth.kind !== "none";
3039
3476
  }
3477
+ function customOn(trigger) {
3478
+ return {
3479
+ ...trigger,
3480
+ triggerId: trigger.id,
3481
+ name: trigger.when,
3482
+ summary: `When ${trigger.trigger}.`,
3483
+ prompt: "",
3484
+ custom: true
3485
+ };
3486
+ }
3040
3487
  function connectorWorkflow(connectorId, workflowId) {
3041
- return connectorManifest(connectorId)?.workflows.find((entry) => entry.id === workflowId);
3488
+ const manifest = connectorManifest(connectorId);
3489
+ if (!manifest) return void 0;
3490
+ const defined = manifest.workflows.find((entry) => entry.id === workflowId);
3491
+ if (defined) {
3492
+ const trigger2 = manifest.triggers.find((entry) => entry.id === defined.triggerId);
3493
+ if (!trigger2) return void 0;
3494
+ return {
3495
+ ...trigger2,
3496
+ ...defined,
3497
+ actionId: defined.actionId ?? trigger2.actionId,
3498
+ actionConnectorId: defined.actionConnectorId ?? trigger2.actionConnectorId,
3499
+ actionTarget: defined.actionTarget ?? trigger2.actionTarget,
3500
+ pollEveryMs: defined.pollEveryMs ?? trigger2.pollEveryMs,
3501
+ triggerId: trigger2.id,
3502
+ custom: false
3503
+ };
3504
+ }
3505
+ const trigger = manifest.triggers.find((entry) => entry.id === workflowId);
3506
+ return trigger ? customOn(trigger) : void 0;
3042
3507
  }
3043
3508
  function connectorAction(connectorId, actionId) {
3044
3509
  return connectorManifest(connectorId)?.actions.find((entry) => entry.id === actionId);
@@ -3445,10 +3910,10 @@ function candidates(items, repositories2) {
3445
3910
  return repo && repoMatches(repo, repositories2) ? [{ repo, item }] : [];
3446
3911
  });
3447
3912
  }
3448
- function query(workflowId) {
3449
- const workflow = githubManifest.workflows.find((entry) => entry.id === workflowId);
3450
- if (!workflow?.watches) throw new Error(`GitHub cannot watch for ${workflowId}.`);
3451
- return workflow.watches;
3913
+ function query(triggerId) {
3914
+ const trigger = githubManifest.triggers.find((entry) => entry.id === triggerId);
3915
+ if (!trigger?.watches) throw new Error(`GitHub cannot watch for ${triggerId}.`);
3916
+ return trigger.watches;
3452
3917
  }
3453
3918
  async function pollGithub(input) {
3454
3919
  const repositories2 = stringList(input.settings.repositories);
@@ -3724,11 +4189,17 @@ var githubRuntime = defineRuntime({
3724
4189
 
3725
4190
  // src/connectors/gmail/api.ts
3726
4191
  var BASE = "https://gmail.googleapis.com/gmail/v1/users/me";
3727
- async function request2(accessToken, path) {
4192
+ async function request2(accessToken, path, init = {}) {
3728
4193
  let res;
3729
4194
  try {
3730
4195
  res = await fetch(`${BASE}${path}`, {
3731
- headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" }
4196
+ method: init.method ?? "GET",
4197
+ headers: {
4198
+ Authorization: `Bearer ${accessToken}`,
4199
+ Accept: "application/json",
4200
+ ...init.body === void 0 ? {} : { "Content-Type": "application/json" }
4201
+ },
4202
+ ...init.body === void 0 ? {} : { body: JSON.stringify(init.body) }
3732
4203
  });
3733
4204
  } catch (error) {
3734
4205
  throw new TransientError(
@@ -3740,6 +4211,9 @@ async function request2(accessToken, path) {
3740
4211
  const message = `Gmail ${res.status} on ${path}: ${detail}`;
3741
4212
  const backoff = res.status === 403 && /rateLimitExceeded|userRateLimitExceeded/i.test(detail);
3742
4213
  if (res.status === 429 || res.status >= 500 || backoff) throw new TransientError(message);
4214
+ if (res.status === 403 && /insufficient.*scope|ACCESS_TOKEN_SCOPE_INSUFFICIENT/i.test(detail)) {
4215
+ throw new Error("This Gmail connection cannot send mail. Reconnect Gmail to allow it.");
4216
+ }
3743
4217
  throw new Error(message);
3744
4218
  }
3745
4219
  return await res.json();
@@ -3759,6 +4233,12 @@ function getMessageHeaders(accessToken, id) {
3759
4233
  function getMessage(accessToken, id) {
3760
4234
  return request2(accessToken, `/messages/${id}?format=full`);
3761
4235
  }
4236
+ function sendMessage(accessToken, raw, threadId) {
4237
+ return request2(accessToken, "/messages/send", {
4238
+ method: "POST",
4239
+ body: threadId ? { raw, threadId } : { raw }
4240
+ });
4241
+ }
3762
4242
 
3763
4243
  // src/connectors/gmail/mail.ts
3764
4244
  function mailRef(id) {
@@ -3848,12 +4328,31 @@ function mailContext(message) {
3848
4328
  ].join("\n")
3849
4329
  };
3850
4330
  }
4331
+ function base64url(text3) {
4332
+ return Buffer.from(text3, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
4333
+ }
4334
+ function encodedWord(text3) {
4335
+ return /^[\x20-\x7e]*$/.test(text3) ? text3 : `=?UTF-8?B?${Buffer.from(text3, "utf8").toString("base64")}?=`;
4336
+ }
4337
+ function composeMail(input) {
4338
+ const lines = [
4339
+ `To: ${input.to}`,
4340
+ `Subject: ${encodedWord(input.subject)}`,
4341
+ ...input.inReplyTo ? [`In-Reply-To: ${input.inReplyTo}`, `References: ${input.inReplyTo}`] : [],
4342
+ "MIME-Version: 1.0",
4343
+ 'Content-Type: text/plain; charset="UTF-8"',
4344
+ "Content-Transfer-Encoding: base64",
4345
+ "",
4346
+ Buffer.from(input.body, "utf8").toString("base64").replace(/.{76}/g, "$&\r\n")
4347
+ ];
4348
+ return base64url(lines.join("\r\n"));
4349
+ }
3851
4350
 
3852
4351
  // src/connectors/gmail/poll.ts
3853
4352
  function baseQuery(workflowId) {
3854
- const workflow = gmailManifest.workflows.find((entry) => entry.id === workflowId);
3855
- if (!workflow?.watches) throw new Error(`Gmail cannot watch for ${workflowId}.`);
3856
- return workflow.watches;
4353
+ const trigger = gmailManifest.triggers.find((entry) => entry.id === workflowId);
4354
+ if (!trigger?.watches) throw new Error(`Gmail cannot watch for ${workflowId}.`);
4355
+ return trigger.watches;
3857
4356
  }
3858
4357
  function boolean2(value, fallback) {
3859
4358
  return typeof value === "boolean" ? value : fallback;
@@ -4068,8 +4567,33 @@ var gmailRuntime = defineRuntime({
4068
4567
  })
4069
4568
  };
4070
4569
  },
4071
- // No applyAction: this connector reads. A loop built on it answers through
4072
- // whichever connector it is pointed at.
4570
+ /**
4571
+ * Answers go to the connected account itself, the way a bot answers
4572
+ * whoever asked it. When the run started from an email in this inbox, the
4573
+ * answer joins that thread.
4574
+ */
4575
+ async applyAction({ actionId, source, body, credential }) {
4576
+ if (actionId !== "gmail.send") throw new Error(`Gmail cannot ${actionId}.`);
4577
+ const { accessToken } = await usableToken2(credential);
4578
+ const profile = await getProfile(accessToken);
4579
+ let subject = source.title || "From Loopable";
4580
+ let threadId;
4581
+ let inReplyTo;
4582
+ const sourceId = source.connectorId === "gmail" ? source.ref.replace(/^mail#/, "") : "";
4583
+ if (sourceId) {
4584
+ const original = await getMessage(accessToken, sourceId);
4585
+ threadId = original.threadId;
4586
+ inReplyTo = headerOf(original, "Message-ID") || void 0;
4587
+ const said = headerOf(original, "Subject");
4588
+ if (said) subject = /^re:/i.test(said) ? said : `Re: ${said}`;
4589
+ }
4590
+ const sent = await sendMessage(
4591
+ accessToken,
4592
+ composeMail({ to: profile.emailAddress, subject, body, inReplyTo }),
4593
+ threadId
4594
+ );
4595
+ return { url: permalink(sent.id) };
4596
+ },
4073
4597
  auth: {
4074
4598
  async startAuthorization({ redirectUri, state }) {
4075
4599
  const registration = requireRegistration2();
@@ -4203,6 +4727,618 @@ var scheduleRuntime = {
4203
4727
  }
4204
4728
  };
4205
4729
 
4730
+ // src/connectors/slack/api.ts
4731
+ var API = "https://slack.com/api";
4732
+ async function slack(path, token, init = {}) {
4733
+ const url = new URL(`${API}/${path}`);
4734
+ for (const [key, value] of Object.entries(init.query ?? {})) {
4735
+ url.searchParams.set(key, value);
4736
+ }
4737
+ let response;
4738
+ try {
4739
+ response = await fetch(url.href, {
4740
+ method: init.method ?? "GET",
4741
+ headers: {
4742
+ Authorization: `Bearer ${token}`,
4743
+ ...init.body ? { "Content-Type": "application/json" } : {}
4744
+ },
4745
+ body: init.body ? JSON.stringify(init.body) : void 0
4746
+ });
4747
+ } catch (error) {
4748
+ throw new TransientError(`Slack is unreachable: ${error.message}`);
4749
+ }
4750
+ if (response.status >= 500 || response.status === 429) {
4751
+ throw new TransientError(`Slack answered ${response.status}`);
4752
+ }
4753
+ if (!response.ok) throw new Error(`Slack answered ${response.status}`);
4754
+ return await response.json();
4755
+ }
4756
+ function refuse(error) {
4757
+ if (error === "invalid_auth" || error === "not_authed" || error === "token_revoked" || error === "account_inactive") {
4758
+ throw new Error("Slack did not accept that token.");
4759
+ }
4760
+ throw new Error(error ? `Slack refused: ${error}` : "Slack refused the request.");
4761
+ }
4762
+ async function authTest(botToken) {
4763
+ const body = await slack("auth.test", botToken);
4764
+ if (!body.ok) refuse(body.error);
4765
+ const teamUrl = typeof body.url === "string" ? body.url : "";
4766
+ const team = typeof body.team === "string" ? body.team : "";
4767
+ const teamId = typeof body.team_id === "string" ? body.team_id : "";
4768
+ const botUserId = typeof body.user_id === "string" ? body.user_id : "";
4769
+ const botId = typeof body.bot_id === "string" ? body.bot_id : "";
4770
+ const botName = typeof body.user === "string" ? body.user : "";
4771
+ if (!teamId || !botUserId) throw new Error("Slack did not return which workspace this bot belongs to.");
4772
+ return { botToken, teamId, team, teamUrl, botUserId, botId, botName };
4773
+ }
4774
+ async function listConversations(credential) {
4775
+ const body = await slack("conversations.list", credential.botToken, {
4776
+ query: {
4777
+ types: "public_channel,private_channel,im,mpim",
4778
+ exclude_archived: "true",
4779
+ limit: "200"
4780
+ }
4781
+ });
4782
+ if (!body.ok) refuse(body.error);
4783
+ const channels = Array.isArray(body.channels) ? body.channels : [];
4784
+ return channels.map((raw) => {
4785
+ const channel2 = raw;
4786
+ if (!channel2.id) return null;
4787
+ return { id: channel2.id, name: channel2.name ?? "", isIm: channel2.is_im === true };
4788
+ }).filter((channel2) => channel2 !== null);
4789
+ }
4790
+ async function history(credential, channelId, oldest) {
4791
+ const query2 = {
4792
+ channel: channelId,
4793
+ limit: "100",
4794
+ inclusive: "false"
4795
+ };
4796
+ if (oldest) query2.oldest = oldest;
4797
+ const body = await slack("conversations.history", credential.botToken, { query: query2 });
4798
+ if (!body.ok) {
4799
+ if (body.error === "not_in_channel" || body.error === "channel_not_found") return [];
4800
+ refuse(body.error);
4801
+ }
4802
+ const messages = Array.isArray(body.messages) ? body.messages : [];
4803
+ const parsed = [];
4804
+ for (const raw of messages) {
4805
+ const message = raw;
4806
+ if (!message.ts) continue;
4807
+ parsed.push({
4808
+ ts: message.ts,
4809
+ ...message.user ? { user: message.user } : {},
4810
+ ...message.bot_id ? { botId: message.bot_id } : {},
4811
+ text: message.text ?? "",
4812
+ ...message.thread_ts ? { threadTs: message.thread_ts } : {}
4813
+ });
4814
+ }
4815
+ return parsed.sort((a, b) => Number(a.ts) - Number(b.ts));
4816
+ }
4817
+ async function postMessage(credential, input) {
4818
+ const body = await slack("chat.postMessage", credential.botToken, {
4819
+ method: "POST",
4820
+ body: {
4821
+ channel: input.channel,
4822
+ text: input.text,
4823
+ ...input.threadTs ? { thread_ts: input.threadTs } : {}
4824
+ }
4825
+ });
4826
+ if (!body.ok) refuse(body.error);
4827
+ const ts = typeof body.ts === "string" ? body.ts : "";
4828
+ const channel2 = typeof body.channel === "string" ? body.channel : input.channel;
4829
+ if (!ts) throw new Error("Slack accepted the message but did not say where it landed.");
4830
+ return { ts, channel: channel2 };
4831
+ }
4832
+ function permalink2(credential, channel2, ts) {
4833
+ const host = credential.teamUrl.replace(/\/$/, "");
4834
+ return `${host}/archives/${channel2}/p${ts.replace(".", "")}`;
4835
+ }
4836
+
4837
+ // src/connectors/slack/runtime.ts
4838
+ function asAsked(value) {
4839
+ const record = value;
4840
+ if (!record || typeof record.channel !== "string" || typeof record.ts !== "string") {
4841
+ throw new Error("This task has lost the message it was about.");
4842
+ }
4843
+ return {
4844
+ channel: record.channel,
4845
+ ts: record.ts,
4846
+ user: typeof record.user === "string" ? record.user : void 0,
4847
+ text: typeof record.text === "string" ? record.text : "",
4848
+ threadTs: typeof record.threadTs === "string" ? record.threadTs : record.ts
4849
+ };
4850
+ }
4851
+ function asCursor(value) {
4852
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
4853
+ const out = {};
4854
+ for (const [key, entry] of Object.entries(value)) {
4855
+ if (typeof entry === "string") out[key] = entry;
4856
+ }
4857
+ return out;
4858
+ }
4859
+ function summarise2(text3) {
4860
+ const line = text3.replace(/<@[^>]+>/g, "").replace(/\s+/g, " ").trim();
4861
+ if (!line) return "Slack message";
4862
+ return line.length > 80 ? `${line.slice(0, 79)}\u2026` : line;
4863
+ }
4864
+ function isAsk(input) {
4865
+ if (input.user === input.botUserId || input.messageBotId && input.messageBotId === input.botId) {
4866
+ return false;
4867
+ }
4868
+ if (input.isIm) return Boolean(input.text.trim());
4869
+ return input.text.includes(`<@${input.botUserId}>`);
4870
+ }
4871
+ function destination(target, source) {
4872
+ if (typeof target.channel === "string" && target.channel.trim()) {
4873
+ return { channel: target.channel.trim() };
4874
+ }
4875
+ if (source.connectorId !== "slack") {
4876
+ throw new Error("There is no Slack channel behind this task. Choose one on the loop.");
4877
+ }
4878
+ const carry = source.carry ?? {};
4879
+ if (!carry.channel) throw new Error("There is no Slack channel behind this task. Choose one on the loop.");
4880
+ return { channel: carry.channel, threadTs: carry.ts };
4881
+ }
4882
+ function accountOf2(credential) {
4883
+ return {
4884
+ id: credential.teamId,
4885
+ // The bot, then the workspace it is in: "@loopable · Acme". The workspace
4886
+ // alone read as if it were the bot's name.
4887
+ label: [credential.botName ? `@${credential.botName}` : null, credential.team || credential.teamId].filter(Boolean).join(" \xB7 "),
4888
+ url: credential.teamUrl || void 0
4889
+ };
4890
+ }
4891
+ var slackRuntime = {
4892
+ async readiness() {
4893
+ return { ready: true };
4894
+ },
4895
+ async poll({ workflowId, credential, cursor }) {
4896
+ if (workflowId !== "slack.ask") {
4897
+ throw new Error(`Slack cannot watch for ${workflowId}.`);
4898
+ }
4899
+ const account2 = credential;
4900
+ const previous = asCursor(cursor);
4901
+ const channels = await listConversations(account2);
4902
+ const next = { ...previous };
4903
+ const signals2 = [];
4904
+ for (const channel2 of channels) {
4905
+ const oldest = previous[channel2.id];
4906
+ const messages = await history(account2, channel2.id, oldest);
4907
+ if (messages.length === 0) {
4908
+ if (!next[channel2.id]) next[channel2.id] = String(Date.now() / 1e3);
4909
+ continue;
4910
+ }
4911
+ next[channel2.id] = messages[messages.length - 1].ts;
4912
+ if (!oldest) continue;
4913
+ for (const message of messages) {
4914
+ if (!isAsk({
4915
+ text: message.text,
4916
+ user: message.user,
4917
+ messageBotId: message.botId,
4918
+ isIm: channel2.isIm,
4919
+ botUserId: account2.botUserId,
4920
+ botId: account2.botId
4921
+ })) {
4922
+ continue;
4923
+ }
4924
+ const asked = {
4925
+ channel: channel2.id,
4926
+ ts: message.ts,
4927
+ user: message.user,
4928
+ text: message.text,
4929
+ threadTs: message.threadTs ?? message.ts
4930
+ };
4931
+ signals2.push({
4932
+ key: `${channel2.id}:${message.ts}`,
4933
+ kind: "message",
4934
+ ref: `${channel2.id}:${message.ts}`,
4935
+ title: summarise2(message.text),
4936
+ url: permalink2(account2, channel2.id, message.ts),
4937
+ payload: asked
4938
+ });
4939
+ }
4940
+ }
4941
+ return { signals: signals2, cursor: next };
4942
+ },
4943
+ async resolveWorkItem({ payload }) {
4944
+ const asked = asAsked(payload);
4945
+ return {
4946
+ kind: "message",
4947
+ ref: `${asked.channel}:${asked.ts}`,
4948
+ title: summarise2(asked.text),
4949
+ url: "",
4950
+ carry: { channel: asked.channel, ts: asked.threadTs },
4951
+ context: [
4952
+ {
4953
+ name: "MESSAGE.md",
4954
+ body: [`# What you were asked`, ``, asked.text, ``].join("\n")
4955
+ }
4956
+ ]
4957
+ };
4958
+ },
4959
+ async applyAction({ actionId, target, source, body, credential }) {
4960
+ if (actionId !== "slack.reply") throw new Error(`Slack cannot ${actionId}.`);
4961
+ const account2 = credential;
4962
+ const to = destination(target, source);
4963
+ const posted = await postMessage(account2, {
4964
+ channel: to.channel,
4965
+ text: body.trim(),
4966
+ threadTs: to.threadTs
4967
+ });
4968
+ return { url: permalink2(account2, posted.channel, posted.ts) };
4969
+ },
4970
+ auth: {
4971
+ async connectWithFields(fields) {
4972
+ const botToken = fields.botToken?.trim() ?? "";
4973
+ if (!botToken.startsWith("xoxb-")) {
4974
+ throw new Error("Paste the Bot User OAuth Token from your Slack app. It starts with xoxb-.");
4975
+ }
4976
+ const credential = await authTest(botToken);
4977
+ return { credential, account: accountOf2(credential) };
4978
+ },
4979
+ async identity(credential) {
4980
+ const stored = credential;
4981
+ const fresh = await authTest(stored.botToken);
4982
+ return { account: accountOf2(fresh) };
4983
+ }
4984
+ }
4985
+ };
4986
+
4987
+ // src/connectors/feishu/api.ts
4988
+ var API2 = {
4989
+ feishu: "https://open.feishu.cn/open-apis",
4990
+ lark: "https://open.larksuite.com/open-apis"
4991
+ };
4992
+ var APPLINK = {
4993
+ feishu: "https://applink.feishu.cn/client/chat/open",
4994
+ lark: "https://applink.larksuite.com/client/chat/open"
4995
+ };
4996
+ function parseHost(value) {
4997
+ const raw = (value ?? "feishu").trim().toLowerCase();
4998
+ if (!raw || raw === "feishu" || raw === "feishu.cn" || raw.includes("feishu.cn")) return "feishu";
4999
+ if (raw === "lark" || raw === "larksuite" || raw.includes("larksuite.com") || raw.includes("larkoffice.com")) {
5000
+ return "lark";
5001
+ }
5002
+ throw new Error("Open platform must be feishu (China) or lark (international).");
5003
+ }
5004
+ function refuse2(code, msg) {
5005
+ if (code === 10003 || code === 10012 || code === 10014) {
5006
+ throw new Error("Feishu did not accept those app credentials.");
5007
+ }
5008
+ throw new Error(msg ? `Feishu refused: ${msg}` : "Feishu refused the request.");
5009
+ }
5010
+ async function call(host, path, init = {}) {
5011
+ const url = new URL(`${API2[host]}${path}`);
5012
+ for (const [key, value] of Object.entries(init.query ?? {})) {
5013
+ url.searchParams.set(key, value);
5014
+ }
5015
+ let response;
5016
+ try {
5017
+ response = await fetch(url.href, {
5018
+ method: init.method ?? "GET",
5019
+ headers: {
5020
+ "Content-Type": "application/json; charset=utf-8",
5021
+ ...init.token ? { Authorization: `Bearer ${init.token}` } : {}
5022
+ },
5023
+ body: init.body ? JSON.stringify(init.body) : void 0
5024
+ });
5025
+ } catch (error) {
5026
+ throw new TransientError(`Feishu is unreachable: ${error.message}`);
5027
+ }
5028
+ if (response.status >= 500 || response.status === 429) {
5029
+ throw new TransientError(`Feishu answered ${response.status}`);
5030
+ }
5031
+ let body;
5032
+ try {
5033
+ body = await response.json();
5034
+ } catch {
5035
+ throw new Error(`Feishu answered ${response.status}`);
5036
+ }
5037
+ return body;
5038
+ }
5039
+ function dataOf(body) {
5040
+ if (body.code && body.code !== 0) refuse2(body.code, body.msg);
5041
+ return body.data ?? {};
5042
+ }
5043
+ async function tenantAccessToken(host, appId, appSecret) {
5044
+ const body = await call(host, "/auth/v3/tenant_access_token/internal", {
5045
+ method: "POST",
5046
+ body: { app_id: appId, app_secret: appSecret }
5047
+ });
5048
+ if (body.code && body.code !== 0) refuse2(body.code, body.msg);
5049
+ const token = body.tenant_access_token ?? "";
5050
+ const expire = typeof body.expire === "number" ? body.expire : 7200;
5051
+ if (!token) throw new Error("Feishu did not return a tenant token.");
5052
+ return { token, expiresAt: Date.now() + expire * 1e3 - 6e4 };
5053
+ }
5054
+ async function botInfo(credential) {
5055
+ const body = await call(credential.host, "/bot/v3/info", { token: credential.tenantAccessToken });
5056
+ if (body.code && body.code !== 0) refuse2(body.code, body.msg);
5057
+ const bot = body.bot ?? {};
5058
+ const openId = typeof bot.open_id === "string" ? bot.open_id : "";
5059
+ const name = typeof bot.app_name === "string" ? bot.app_name : "";
5060
+ const avatarUrl = typeof bot.avatar_url === "string" ? bot.avatar_url : void 0;
5061
+ if (!openId) throw new Error("Feishu did not return which bot this app is.");
5062
+ return { openId, name, ...avatarUrl ? { avatarUrl } : {} };
5063
+ }
5064
+ async function listChats(credential) {
5065
+ const chats = [];
5066
+ let pageToken = "";
5067
+ do {
5068
+ const query2 = { page_size: "100" };
5069
+ if (pageToken) query2.page_token = pageToken;
5070
+ const body = await call(credential.host, "/im/v1/chats", {
5071
+ token: credential.tenantAccessToken,
5072
+ query: query2
5073
+ });
5074
+ const data = dataOf(body);
5075
+ const items = Array.isArray(data.items) ? data.items : [];
5076
+ for (const raw of items) {
5077
+ const chat2 = raw;
5078
+ if (!chat2.chat_id) continue;
5079
+ chats.push({ id: chat2.chat_id, name: chat2.name ?? "" });
5080
+ }
5081
+ pageToken = data.has_more === true && typeof data.page_token === "string" ? data.page_token : "";
5082
+ } while (pageToken);
5083
+ return chats;
5084
+ }
5085
+ function textOf(content) {
5086
+ if (!content) return "";
5087
+ try {
5088
+ const parsed = JSON.parse(content);
5089
+ return typeof parsed.text === "string" ? parsed.text : content;
5090
+ } catch {
5091
+ return content;
5092
+ }
5093
+ }
5094
+ function mentionsOf(raw) {
5095
+ if (!Array.isArray(raw)) return [];
5096
+ return raw.map((entry) => {
5097
+ const mention = entry;
5098
+ return typeof mention.id === "string" ? mention.id : "";
5099
+ }).filter(Boolean);
5100
+ }
5101
+ async function history2(credential, chatId, oldestMs) {
5102
+ const parsed = [];
5103
+ let pageToken = "";
5104
+ do {
5105
+ const query2 = {
5106
+ container_id_type: "chat",
5107
+ container_id: chatId,
5108
+ sort_type: oldestMs ? "ByCreateTimeAsc" : "ByCreateTimeDesc",
5109
+ page_size: oldestMs ? "50" : "1"
5110
+ };
5111
+ if (oldestMs) query2.start_time = String(Math.floor(Number(oldestMs) / 1e3));
5112
+ if (pageToken) query2.page_token = pageToken;
5113
+ const body = await call(credential.host, "/im/v1/messages", {
5114
+ token: credential.tenantAccessToken,
5115
+ query: query2
5116
+ });
5117
+ if (body.code && body.code !== 0) {
5118
+ if (body.code === 230002 || body.code === 231203) return parsed;
5119
+ refuse2(body.code, body.msg);
5120
+ }
5121
+ const data = body.data ?? {};
5122
+ const items = Array.isArray(data.items) ? data.items : [];
5123
+ for (const raw of items) {
5124
+ const message = raw;
5125
+ if (!message.message_id || message.deleted) continue;
5126
+ parsed.push({
5127
+ id: message.message_id,
5128
+ chatId: message.chat_id ?? chatId,
5129
+ createTime: message.create_time ?? "",
5130
+ text: textOf(message.body?.content),
5131
+ ...message.sender?.id ? { senderId: message.sender.id } : {},
5132
+ ...message.sender?.sender_type ? { senderType: message.sender.sender_type } : {},
5133
+ mentionIds: mentionsOf(message.mentions)
5134
+ });
5135
+ }
5136
+ pageToken = oldestMs && data.has_more === true && typeof data.page_token === "string" ? data.page_token : "";
5137
+ } while (pageToken);
5138
+ return parsed.sort((a, b) => Number(a.createTime) - Number(b.createTime));
5139
+ }
5140
+ async function replyMessage(credential, messageId, text3) {
5141
+ const body = await call(credential.host, `/im/v1/messages/${messageId}/reply`, {
5142
+ token: credential.tenantAccessToken,
5143
+ method: "POST",
5144
+ body: { msg_type: "text", content: JSON.stringify({ text: text3 }) }
5145
+ });
5146
+ const data = dataOf(body);
5147
+ const id = typeof data.message_id === "string" ? data.message_id : "";
5148
+ const chatId = typeof data.chat_id === "string" ? data.chat_id : "";
5149
+ if (!id) throw new Error("Feishu accepted the reply but did not say where it landed.");
5150
+ return { id, chatId };
5151
+ }
5152
+ async function sendMessage2(credential, chatId, text3) {
5153
+ const body = await call(credential.host, "/im/v1/messages", {
5154
+ token: credential.tenantAccessToken,
5155
+ method: "POST",
5156
+ query: { receive_id_type: "chat_id" },
5157
+ body: { receive_id: chatId, msg_type: "text", content: JSON.stringify({ text: text3 }) }
5158
+ });
5159
+ const data = dataOf(body);
5160
+ const id = typeof data.message_id === "string" ? data.message_id : "";
5161
+ const landed = typeof data.chat_id === "string" ? data.chat_id : chatId;
5162
+ if (!id) throw new Error("Feishu accepted the message but did not say where it landed.");
5163
+ return { id, chatId: landed };
5164
+ }
5165
+ function permalink3(host, chatId) {
5166
+ return `${APPLINK[host]}?openChatId=${chatId}`;
5167
+ }
5168
+
5169
+ // src/connectors/feishu/runtime.ts
5170
+ function asAsked2(value) {
5171
+ const record = value;
5172
+ if (!record || typeof record.chatId !== "string" || typeof record.messageId !== "string") {
5173
+ throw new Error("This task has lost the message it was about.");
5174
+ }
5175
+ return {
5176
+ chatId: record.chatId,
5177
+ messageId: record.messageId,
5178
+ user: typeof record.user === "string" ? record.user : void 0,
5179
+ text: typeof record.text === "string" ? record.text : ""
5180
+ };
5181
+ }
5182
+ function asCursor2(value) {
5183
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
5184
+ const out = {};
5185
+ for (const [key, entry] of Object.entries(value)) {
5186
+ if (typeof entry === "string") out[key] = entry;
5187
+ }
5188
+ return out;
5189
+ }
5190
+ function summarise3(text3) {
5191
+ const line = text3.replace(/@_user_\d+/g, "").replace(/\s+/g, " ").trim();
5192
+ if (!line) return "Feishu message";
5193
+ return line.length > 80 ? `${line.slice(0, 79)}\u2026` : line;
5194
+ }
5195
+ function isAsk2(input) {
5196
+ if (input.senderType === "app" || input.senderId === input.botOpenId) return false;
5197
+ return input.mentionIds.includes(input.botOpenId);
5198
+ }
5199
+ function destination2(target, source) {
5200
+ if (typeof target.chat === "string" && target.chat.trim()) {
5201
+ return { chatId: target.chat.trim() };
5202
+ }
5203
+ if (source.connectorId !== "feishu") {
5204
+ throw new Error("There is no Feishu chat behind this task. Choose one on the loop.");
5205
+ }
5206
+ const carry = source.carry ?? {};
5207
+ if (!carry.chatId && !carry.messageId) {
5208
+ throw new Error("There is no Feishu chat behind this task. Choose one on the loop.");
5209
+ }
5210
+ return { chatId: carry.chatId, messageId: carry.messageId };
5211
+ }
5212
+ function accountOf3(credential) {
5213
+ return {
5214
+ id: credential.botOpenId,
5215
+ label: credential.botName || credential.appId,
5216
+ ...credential.avatarUrl ? { avatarUrl: credential.avatarUrl } : {}
5217
+ };
5218
+ }
5219
+ async function usable(credential) {
5220
+ if (Date.now() < credential.expiresAt) return { credential };
5221
+ const minted = await tenantAccessToken(credential.host, credential.appId, credential.appSecret);
5222
+ const refreshed = {
5223
+ ...credential,
5224
+ tenantAccessToken: minted.token,
5225
+ expiresAt: minted.expiresAt
5226
+ };
5227
+ return { credential: refreshed, refreshed };
5228
+ }
5229
+ var feishuRuntime = {
5230
+ async readiness() {
5231
+ return { ready: true };
5232
+ },
5233
+ async poll({ workflowId, credential, cursor }) {
5234
+ if (workflowId !== "feishu.ask") {
5235
+ throw new Error(`Feishu cannot watch for ${workflowId}.`);
5236
+ }
5237
+ const { credential: account2 } = await usable(credential);
5238
+ const previous = asCursor2(cursor);
5239
+ const chats = await listChats(account2);
5240
+ const next = { ...previous };
5241
+ const signals2 = [];
5242
+ for (const chat2 of chats) {
5243
+ const oldest = previous[chat2.id];
5244
+ const messages = await history2(account2, chat2.id, oldest);
5245
+ if (messages.length === 0) {
5246
+ if (!next[chat2.id]) next[chat2.id] = String(Date.now());
5247
+ continue;
5248
+ }
5249
+ next[chat2.id] = messages[messages.length - 1].createTime;
5250
+ if (!oldest) continue;
5251
+ for (const message of messages) {
5252
+ if (oldest && Number(message.createTime) <= Number(oldest)) continue;
5253
+ if (!isAsk2({
5254
+ senderId: message.senderId,
5255
+ senderType: message.senderType,
5256
+ mentionIds: message.mentionIds,
5257
+ botOpenId: account2.botOpenId
5258
+ })) {
5259
+ continue;
5260
+ }
5261
+ const asked = {
5262
+ chatId: message.chatId,
5263
+ messageId: message.id,
5264
+ user: message.senderId,
5265
+ text: message.text
5266
+ };
5267
+ signals2.push({
5268
+ key: `${message.chatId}:${message.id}`,
5269
+ kind: "message",
5270
+ ref: message.id,
5271
+ title: summarise3(message.text),
5272
+ url: permalink3(account2.host, message.chatId),
5273
+ payload: asked
5274
+ });
5275
+ }
5276
+ }
5277
+ return { signals: signals2, cursor: next };
5278
+ },
5279
+ async resolveWorkItem({ payload }) {
5280
+ const asked = asAsked2(payload);
5281
+ return {
5282
+ kind: "message",
5283
+ ref: asked.messageId,
5284
+ title: summarise3(asked.text),
5285
+ url: "",
5286
+ carry: { chatId: asked.chatId, messageId: asked.messageId },
5287
+ context: [
5288
+ {
5289
+ name: "MESSAGE.md",
5290
+ body: [`# What you were asked`, ``, asked.text, ``].join("\n")
5291
+ }
5292
+ ]
5293
+ };
5294
+ },
5295
+ async applyAction({ actionId, target, source, body, credential }) {
5296
+ if (actionId !== "feishu.reply") throw new Error(`Feishu cannot ${actionId}.`);
5297
+ const { credential: account2 } = await usable(credential);
5298
+ const to = destination2(target, source);
5299
+ const text3 = body.trim();
5300
+ const posted = to.messageId && source.connectorId === "feishu" ? await replyMessage(account2, to.messageId, text3) : await sendMessage2(account2, to.chatId ?? "", text3);
5301
+ return { url: permalink3(account2.host, posted.chatId || to.chatId || "") };
5302
+ },
5303
+ auth: {
5304
+ async connectWithFields(fields) {
5305
+ const appId = fields.appId?.trim() ?? "";
5306
+ const appSecret = fields.appSecret?.trim() ?? "";
5307
+ if (!appId) throw new Error("Paste the App ID from your Feishu or Lark app.");
5308
+ if (!appSecret) throw new Error("Paste the App Secret from your Feishu or Lark app.");
5309
+ const host = parseHost(fields.host);
5310
+ const minted = await tenantAccessToken(host, appId, appSecret);
5311
+ const bot = await botInfo({ host, tenantAccessToken: minted.token });
5312
+ const stored = {
5313
+ appId,
5314
+ appSecret,
5315
+ host,
5316
+ botOpenId: bot.openId,
5317
+ botName: bot.name,
5318
+ ...bot.avatarUrl ? { avatarUrl: bot.avatarUrl } : {},
5319
+ tenantAccessToken: minted.token,
5320
+ expiresAt: minted.expiresAt
5321
+ };
5322
+ return { credential: stored, account: accountOf3(stored) };
5323
+ },
5324
+ async identity(credential) {
5325
+ const stored = credential;
5326
+ const { credential: fresh, refreshed } = await usable(stored);
5327
+ const bot = await botInfo(fresh);
5328
+ const updated = {
5329
+ ...fresh,
5330
+ botOpenId: bot.openId,
5331
+ botName: bot.name,
5332
+ ...bot.avatarUrl ? { avatarUrl: bot.avatarUrl } : {}
5333
+ };
5334
+ return {
5335
+ account: accountOf3(updated),
5336
+ ...refreshed ? { renewedCredential: updated } : {}
5337
+ };
5338
+ }
5339
+ }
5340
+ };
5341
+
4206
5342
  // src/connectors/wechat/runtime.ts
4207
5343
  import { randomUUID } from "node:crypto";
4208
5344
 
@@ -4227,7 +5363,7 @@ function postHeaders(token) {
4227
5363
  ...token ? { Authorization: `Bearer ${token}` } : {}
4228
5364
  };
4229
5365
  }
4230
- async function call(url, init) {
5366
+ async function call2(url, init) {
4231
5367
  let response;
4232
5368
  try {
4233
5369
  response = await fetch(url, init);
@@ -4241,7 +5377,7 @@ async function call(url, init) {
4241
5377
  return await response.json();
4242
5378
  }
4243
5379
  async function requestQrCode() {
4244
- const body = await call(
5380
+ const body = await call2(
4245
5381
  `${WECHAT_API}/ilink/bot/get_bot_qrcode?bot_type=3`,
4246
5382
  {
4247
5383
  method: "POST",
@@ -4261,7 +5397,7 @@ function absolute(host) {
4261
5397
  async function qrCodeStatus(qrcode, host) {
4262
5398
  let body;
4263
5399
  try {
4264
- body = await call(
5400
+ body = await call2(
4265
5401
  `${absolute(host)}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`,
4266
5402
  { headers: APP_HEADERS }
4267
5403
  );
@@ -4314,7 +5450,7 @@ function refusal(body) {
4314
5450
  return body.errmsg || `WeChat refused the request (${bad}).`;
4315
5451
  }
4316
5452
  async function notifyStart(credential) {
4317
- const body = await call(`${credential.baseUrl}/ilink/bot/msg/notifystart`, {
5453
+ const body = await call2(`${credential.baseUrl}/ilink/bot/msg/notifystart`, {
4318
5454
  method: "POST",
4319
5455
  headers: postHeaders(credential.botToken),
4320
5456
  body: JSON.stringify({ base_info: BASE_INFO })
@@ -4324,7 +5460,7 @@ async function notifyStart(credential) {
4324
5460
  }
4325
5461
  var FROM_USER = 1;
4326
5462
  var TEXT_ITEM = 1;
4327
- function textOf(message) {
5463
+ function textOf2(message) {
4328
5464
  return (message.item_list ?? []).filter((item) => item.type === TEXT_ITEM).map((item) => item.text_item?.text?.trim()).filter((text3) => Boolean(text3)).join("\n").trim();
4329
5465
  }
4330
5466
  function isFromPerson(message) {
@@ -4336,7 +5472,7 @@ async function getUpdates(credential, cursor) {
4336
5472
  const timer = setTimeout(() => abort.abort(), WAIT_MS);
4337
5473
  let body;
4338
5474
  try {
4339
- body = await call(`${credential.baseUrl}/ilink/bot/getupdates`, {
5475
+ body = await call2(`${credential.baseUrl}/ilink/bot/getupdates`, {
4340
5476
  method: "POST",
4341
5477
  headers: postHeaders(credential.botToken),
4342
5478
  body: JSON.stringify({ get_updates_buf: cursor, base_info: BASE_INFO }),
@@ -4352,8 +5488,8 @@ async function getUpdates(credential, cursor) {
4352
5488
  if (refused) throw new Error(refused);
4353
5489
  return { messages: body.msgs ?? [], cursor: body.get_updates_buf || cursor };
4354
5490
  }
4355
- async function sendMessage(credential, input) {
4356
- const body = await call(`${credential.baseUrl}/ilink/bot/sendmessage`, {
5491
+ async function sendMessage3(credential, input) {
5492
+ const body = await call2(`${credential.baseUrl}/ilink/bot/sendmessage`, {
4357
5493
  method: "POST",
4358
5494
  headers: postHeaders(credential.botToken),
4359
5495
  body: JSON.stringify({
@@ -4388,7 +5524,7 @@ function asAttempt(value) {
4388
5524
  }
4389
5525
  return { qrcode: record.qrcode, host: record.host };
4390
5526
  }
4391
- function asAsked(value) {
5527
+ function asAsked3(value) {
4392
5528
  const record = value;
4393
5529
  if (!record || typeof record.fromUserId !== "string" || typeof record.text !== "string") {
4394
5530
  throw new Error("This task has lost the message it was about.");
@@ -4400,7 +5536,7 @@ function asAsked(value) {
4400
5536
  text: record.text
4401
5537
  };
4402
5538
  }
4403
- function summarise2(text3) {
5539
+ function summarise4(text3) {
4404
5540
  const line = text3.split("\n")[0].trim();
4405
5541
  return line.length > 80 ? `${line.slice(0, 79)}\u2026` : line;
4406
5542
  }
@@ -4427,7 +5563,7 @@ function recipientFor(target, source, account2) {
4427
5563
  if (!carry.toUserId) throw new Error("There is nobody to reply to on this task.");
4428
5564
  return { userId: carry.toUserId, contextToken: carry.contextToken };
4429
5565
  }
4430
- function accountOf2(credential) {
5566
+ function accountOf4(credential) {
4431
5567
  return {
4432
5568
  id: credential.botId || credential.userId,
4433
5569
  label: credential.botId ? `WeChat bot ${credential.botId}` : "WeChat bot"
@@ -4455,7 +5591,7 @@ var wechatRuntime = {
4455
5591
  const signals2 = [];
4456
5592
  for (const message of messages) {
4457
5593
  if (!isFromPerson(message)) continue;
4458
- const text3 = textOf(message);
5594
+ const text3 = textOf2(message);
4459
5595
  if (!text3) continue;
4460
5596
  const fromUserId = message.from_user_id ?? "";
4461
5597
  const asked = {
@@ -4468,7 +5604,7 @@ var wechatRuntime = {
4468
5604
  key: `msg#${asked.messageId}`,
4469
5605
  kind: "message",
4470
5606
  ref: `msg#${asked.messageId}`,
4471
- title: summarise2(text3),
5607
+ title: summarise4(text3),
4472
5608
  // A message in a chat is not somewhere a browser can be sent.
4473
5609
  url: "",
4474
5610
  // Held rather than dropped, so a message that was sent and never
@@ -4481,11 +5617,11 @@ var wechatRuntime = {
4481
5617
  return { signals: signals2, cursor: next };
4482
5618
  },
4483
5619
  async resolveWorkItem({ payload }) {
4484
- const asked = asAsked(payload);
5620
+ const asked = asAsked3(payload);
4485
5621
  return {
4486
5622
  kind: "message",
4487
5623
  ref: `msg#${asked.messageId}`,
4488
- title: summarise2(asked.text),
5624
+ title: summarise4(asked.text),
4489
5625
  url: "",
4490
5626
  carry: { toUserId: asked.fromUserId, contextToken: asked.contextToken },
4491
5627
  context: [
@@ -4501,7 +5637,7 @@ var wechatRuntime = {
4501
5637
  const account2 = credential;
4502
5638
  const to = recipientFor(target, source, account2);
4503
5639
  for (const part of chunked(body)) {
4504
- await sendMessage(account2, {
5640
+ await sendMessage3(account2, {
4505
5641
  toUserId: to.userId,
4506
5642
  contextToken: to.contextToken,
4507
5643
  // Unique per piece, so a retry cannot post half an answer twice.
@@ -4527,7 +5663,7 @@ var wechatRuntime = {
4527
5663
  case "confirmed":
4528
5664
  return {
4529
5665
  state: "confirmed",
4530
- result: { credential: status.credential, account: accountOf2(status.credential) }
5666
+ result: { credential: status.credential, account: accountOf4(status.credential) }
4531
5667
  };
4532
5668
  case "expired":
4533
5669
  return { state: "expired", reason: status.reason };
@@ -4542,7 +5678,7 @@ var wechatRuntime = {
4542
5678
  async identity(credential) {
4543
5679
  const stored = credential;
4544
5680
  await notifyStart(stored);
4545
- return { account: accountOf2(stored) };
5681
+ return { account: accountOf4(stored) };
4546
5682
  }
4547
5683
  }
4548
5684
  };
@@ -4550,6 +5686,8 @@ var wechatRuntime = {
4550
5686
  // src/connectors/runtimes.ts
4551
5687
  var RUNTIMES = {
4552
5688
  github: githubRuntime,
5689
+ slack: slackRuntime,
5690
+ feishu: feishuRuntime,
4553
5691
  gmail: gmailRuntime,
4554
5692
  wechat: wechatRuntime,
4555
5693
  schedule: scheduleRuntime
@@ -6289,7 +7427,7 @@ var SQLiteSelectQueryBuilderBase = class extends TypedQueryBuilder {
6289
7427
  const baseTableName = this.tableName;
6290
7428
  const tableName = getTableLikeName(table);
6291
7429
  for (const item of extractUsedTable(table)) this.usedTables.add(item);
6292
- if (typeof tableName === "string" && this.config.joins?.some((join8) => join8.alias === tableName)) {
7430
+ if (typeof tableName === "string" && this.config.joins?.some((join9) => join9.alias === tableName)) {
6293
7431
  throw new Error(`Alias "${tableName}" is already used in this query`);
6294
7432
  }
6295
7433
  if (!this.isPartialSelect) {
@@ -7148,7 +8286,7 @@ var SQLiteUpdateBase = class extends QueryPromise {
7148
8286
  createJoin(joinType) {
7149
8287
  return (table, on2) => {
7150
8288
  const tableName = getTableLikeName(table);
7151
- if (typeof tableName === "string" && this.config.joins.some((join8) => join8.alias === tableName)) {
8289
+ if (typeof tableName === "string" && this.config.joins.some((join9) => join9.alias === tableName)) {
7152
8290
  throw new Error(`Alias "${tableName}" is already used in this query`);
7153
8291
  }
7154
8292
  if (typeof on2 === "function") {
@@ -8607,6 +9745,10 @@ var runners = sqliteTable("runners", {
8607
9745
  status: text2("status").$type().notNull().default("offline"),
8608
9746
  inventory: text2("inventory", { mode: "json" }).$type().notNull().default([]),
8609
9747
  lastSeenAt: integer("last_seen_at", { mode: "timestamp_ms" }),
9748
+ /** The runner's loopable-cli version. Null for runners before 0.3. */
9749
+ version: text2("version"),
9750
+ /** What jobs it understands; see RUNNER_PROTOCOL. Null means the first. */
9751
+ protocol: integer("protocol"),
8610
9752
  createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().default(sql`(unixepoch() * 1000)`)
8611
9753
  });
8612
9754
 
@@ -8629,13 +9771,13 @@ function migrationsFolder() {
8629
9771
  join3(here, "../../../drizzle"),
8630
9772
  join3(process.cwd(), "drizzle")
8631
9773
  ];
8632
- return candidates2.find((folder3) => existsSync2(folder3)) ?? join3(process.cwd(), "drizzle");
9774
+ return candidates2.find((folder) => existsSync2(folder)) ?? join3(process.cwd(), "drizzle");
8633
9775
  }
8634
9776
  async function migrateIfNeeded() {
8635
9777
  if (process.env.LOOPABLE_SKIP_MIGRATE === "1") return;
8636
- const folder3 = migrationsFolder();
8637
- if (!existsSync2(folder3)) return;
8638
- await migrate(db(), { migrationsFolder: folder3 });
9778
+ const folder = migrationsFolder();
9779
+ if (!existsSync2(folder)) return;
9780
+ await migrate(db(), { migrationsFolder: folder });
8639
9781
  }
8640
9782
  function rowsChanged(result) {
8641
9783
  return Number(result.rowsAffected ?? result.changes ?? 0);
@@ -8752,14 +9894,14 @@ async function decrypt(blob2) {
8752
9894
  return new TextDecoder().decode(opened);
8753
9895
  }
8754
9896
  function bytesToBase64(bytes) {
8755
- let binary3 = "";
8756
- for (const byte of bytes) binary3 += String.fromCharCode(byte);
8757
- return btoa(binary3);
9897
+ let binary4 = "";
9898
+ for (const byte of bytes) binary4 += String.fromCharCode(byte);
9899
+ return btoa(binary4);
8758
9900
  }
8759
9901
  function base64ToBytes(blob2) {
8760
- const binary3 = atob(blob2);
8761
- const bytes = new Uint8Array(binary3.length);
8762
- for (let i = 0; i < binary3.length; i += 1) bytes[i] = binary3.charCodeAt(i);
9902
+ const binary4 = atob(blob2);
9903
+ const bytes = new Uint8Array(binary4.length);
9904
+ for (let i = 0; i < binary4.length; i += 1) bytes[i] = binary4.charCodeAt(i);
8763
9905
  return bytes;
8764
9906
  }
8765
9907
  var tableStore = {
@@ -8877,12 +10019,13 @@ async function checkConnection(id) {
8877
10019
  // src/server/tasks.ts
8878
10020
  import { randomUUID as randomUUID2 } from "node:crypto";
8879
10021
  import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync, rmSync as rmSync3, statSync, writeFileSync as writeFileSync2 } from "node:fs";
8880
- import { isAbsolute, join as join6 } from "node:path";
10022
+ import { join as join7 } from "node:path";
8881
10023
 
8882
10024
  // src/agents/manifests.ts
8883
10025
  init_manifest();
8884
10026
  init_manifest2();
8885
- var AGENT_MANIFESTS = [codexManifest, cursorAgentManifest];
10027
+ init_manifest3();
10028
+ var AGENT_MANIFESTS = [codexManifest, claudeManifest, cursorAgentManifest];
8886
10029
  function agentManifest(id) {
8887
10030
  return AGENT_MANIFESTS.find((manifest) => manifest.id === id);
8888
10031
  }
@@ -8909,6 +10052,10 @@ var REVIEW_FORMAT = [
8909
10052
  `- "findings" may be empty when the summary says everything worth saying.`
8910
10053
  ].join("\n");
8911
10054
 
10055
+ // src/server/tasks.ts
10056
+ init_agent_job();
10057
+ init_version();
10058
+
8912
10059
  // src/server/agents.ts
8913
10060
  init_runtimes();
8914
10061
  var DEFAULT_AGENT_KEY = "defaultAgentId";
@@ -8980,7 +10127,11 @@ function branchFor(input) {
8980
10127
 
8981
10128
  // src/server/runners.ts
8982
10129
  import { hostname as osHostname } from "node:os";
10130
+ init_version();
8983
10131
  var ONLINE_MS = 45e3;
10132
+ function protocolOf(row) {
10133
+ return row.protocol ?? LEGACY_RUNNER_PROTOCOL;
10134
+ }
8984
10135
  function isRunnerOnline(row, now = Date.now()) {
8985
10136
  if (row.status !== "online") return false;
8986
10137
  if (!row.lastSeenAt) return false;
@@ -8994,7 +10145,10 @@ function toView2(row, now = Date.now()) {
8994
10145
  hostname: row.hostname,
8995
10146
  status: online ? "online" : "offline",
8996
10147
  inventory: row.inventory,
8997
- lastSeenAt: row.lastSeenAt?.toISOString() ?? null
10148
+ lastSeenAt: row.lastSeenAt?.toISOString() ?? null,
10149
+ version: row.version,
10150
+ protocol: protocolOf(row),
10151
+ outdated: protocolOf(row) < RUNNER_PROTOCOL || olderThan(row.version, VERSION)
8998
10152
  };
8999
10153
  }
9000
10154
  async function listRunners() {
@@ -9018,11 +10172,13 @@ async function busyRunnerIds() {
9018
10172
  )
9019
10173
  );
9020
10174
  }
9021
- async function availableAgentIds(requiresHost = false) {
10175
+ async function availableAgentIds(requiresHost = false, minProtocol = 0) {
9022
10176
  const local = osHostname();
9023
10177
  return [
9024
10178
  ...new Set(
9025
- (await listRunners()).filter((row) => row.status === "online" && (!requiresHost || row.hostname === local)).flatMap(
10179
+ (await listRunners()).filter(
10180
+ (row) => row.status === "online" && (!requiresHost || row.hostname === local) && row.protocol >= minProtocol
10181
+ ).flatMap(
9026
10182
  (row) => row.inventory.filter((entry) => entry.installed && entry.signedIn).map((entry) => entry.agentId)
9027
10183
  )
9028
10184
  )
@@ -9031,9 +10187,11 @@ async function availableAgentIds(requiresHost = false) {
9031
10187
  async function pickRunner(input) {
9032
10188
  const now = Date.now();
9033
10189
  const local = osHostname();
9034
- let candidates2 = (await db().select().from(runners).all()).filter(
10190
+ const minProtocol = input.minProtocol ?? 0;
10191
+ const capable = (await db().select().from(runners).all()).filter(
9035
10192
  (row) => isRunnerOnline(row, now) && runnerCanRun(row, input.agentId)
9036
10193
  );
10194
+ let candidates2 = capable.filter((row) => protocolOf(row) >= minProtocol);
9037
10195
  if (input.requiresHost) {
9038
10196
  candidates2 = candidates2.filter((row) => row.hostname === local);
9039
10197
  }
@@ -9044,6 +10202,11 @@ async function pickRunner(input) {
9044
10202
  if (input.requiresHost) {
9045
10203
  throw new Error("This job needs a runner on the same host as Loopable.");
9046
10204
  }
10205
+ if (capable.length > 0) {
10206
+ throw new Error(
10207
+ "Every runner with this agent is too old for this job. Upgrade them with npm install -g loopable-cli."
10208
+ );
10209
+ }
9047
10210
  throw new Error("No runner is online that can run this agent.");
9048
10211
  }
9049
10212
 
@@ -9185,11 +10348,11 @@ async function enqueueSignal(input) {
9185
10348
  });
9186
10349
  }
9187
10350
  function sweepRunDirs(maxAgeMs = 7 * 24 * 60 * 60 * 1e3) {
9188
- const root = join6(dataDir(), "runs");
10351
+ const root = join7(dataDir(), "runs");
9189
10352
  if (!existsSync4(root)) return 0;
9190
10353
  let removed = 0;
9191
10354
  for (const entry of readdirSync(root)) {
9192
- const dir = join6(root, entry);
10355
+ const dir = join7(root, entry);
9193
10356
  try {
9194
10357
  if (Date.now() - statSync(dir).mtimeMs < maxAgeMs) continue;
9195
10358
  rmSync3(dir, { recursive: true, force: true });
@@ -9252,7 +10415,7 @@ async function preparePromptTask(id, signal) {
9252
10415
  let logPath = null;
9253
10416
  if (!isHosted()) {
9254
10417
  const workspace = runDir(id);
9255
- logPath = join6(workspace, "agent.log");
10418
+ logPath = join7(workspace, "agent.log");
9256
10419
  writeFileSync2(logPath, "", { flag: "a" });
9257
10420
  }
9258
10421
  const runnerId = await pickRunner({ agentId: task.agentId, requiresHost: false });
@@ -9276,14 +10439,17 @@ async function prepareTask(id, signal) {
9276
10439
  if (!isHosted()) {
9277
10440
  const workspace = runDir(id);
9278
10441
  for (const file of item.context) {
9279
- writeFileSync2(join6(workspace, file.name), file.body);
10442
+ writeFileSync2(join7(workspace, file.name), file.body);
9280
10443
  }
9281
- logPath = join6(workspace, "agent.log");
10444
+ logPath = join7(workspace, "agent.log");
9282
10445
  writeFileSync2(logPath, "", { flag: "a" });
9283
10446
  }
10447
+ const folder = workflow.runsIn === "folder" && typeof loop.settings.folder === "string" ? loop.settings.folder : null;
9284
10448
  const runnerId = await pickRunner({
9285
10449
  agentId,
9286
- requiresHost: workflow.runsIn === "folder"
10450
+ requiresHost: folder !== null && folderIsLegacy(folder),
10451
+ // A relative folder needs a runner that resolves it under its own home.
10452
+ minProtocol: folder !== null && !folderIsLegacy(folder) ? FOLDER_JOB_PROTOCOL : 0
9287
10453
  });
9288
10454
  await updateTask(id, {
9289
10455
  state: "awaiting_agent",
@@ -9458,7 +10624,7 @@ async function pollLoop(loop, claimed) {
9458
10624
  if (!runtime.poll) throw new Error(`${manifest.name} cannot watch for anything yet.`);
9459
10625
  const { credential } = await credentialForConnector(loop.connectorId);
9460
10626
  const answer = await runtime.poll({
9461
- workflowId: loop.workflowId,
10627
+ workflowId: workflow.triggerId,
9462
10628
  settings: loop.settings,
9463
10629
  credential,
9464
10630
  cursor: loop.pollCursor ?? null
@@ -9478,7 +10644,7 @@ async function pollLoop(loop, claimed) {
9478
10644
  signals: fresh,
9479
10645
  agentId: await defaultAgentFor(loop.agentId),
9480
10646
  guidance: loop.guidance,
9481
- cwd: mkdtempSync(join7(tmpdir(), "loopable-triage-"))
10647
+ cwd: mkdtempSync(join8(tmpdir(), "loopable-triage-"))
9482
10648
  });
9483
10649
  }
9484
10650
  for (const signal of found) {