thursday-agent 0.4.0 → 0.5.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 (199) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +3 -3
  3. package/.next/prerender-manifest.json +3 -3
  4. package/.next/server/app/_global-error/page.js.nft.json +1 -1
  5. package/.next/server/app/_global-error.html +1 -1
  6. package/.next/server/app/_global-error.rsc +1 -1
  7. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  10. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  11. package/.next/server/app/_not-found.html +1 -1
  12. package/.next/server/app/_not-found.rsc +1 -1
  13. package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  14. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  15. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  16. package/.next/server/app/api/artifact/route.js +4 -4
  17. package/.next/server/app/api/artifact/route.js.nft.json +1 -1
  18. package/.next/server/app/api/bot/memory/files/route.js +3 -3
  19. package/.next/server/app/api/bot/memory/files/route.js.nft.json +1 -1
  20. package/.next/server/app/api/bot/memory/route.js.nft.json +1 -1
  21. package/.next/server/app/api/bot/route.js.nft.json +1 -1
  22. package/.next/server/app/api/bot/task/[id]/route.js +8 -8
  23. package/.next/server/app/api/bot/task/[id]/route.js.nft.json +1 -1
  24. package/.next/server/app/api/bot/task/route.js +6 -6
  25. package/.next/server/app/api/bot/task/route.js.nft.json +1 -1
  26. package/.next/server/app/api/config/route.js.nft.json +1 -1
  27. package/.next/server/app/api/events/route.js.nft.json +1 -1
  28. package/.next/server/app/api/file/[...path]/route.js +4 -4
  29. package/.next/server/app/api/file/[...path]/route.js.nft.json +1 -1
  30. package/.next/server/app/api/llm-model/catalog/route.js +2 -2
  31. package/.next/server/app/api/llm-model/catalog/route.js.nft.json +1 -1
  32. package/.next/server/app/api/llm-model/credits/route.js +2 -2
  33. package/.next/server/app/api/llm-model/credits/route.js.nft.json +1 -1
  34. package/.next/server/app/api/llm-model/route.js +2 -2
  35. package/.next/server/app/api/llm-model/route.js.nft.json +1 -1
  36. package/.next/server/app/api/llm-model/subscription/route.js +2 -2
  37. package/.next/server/app/api/llm-model/subscription/route.js.nft.json +1 -1
  38. package/.next/server/app/api/mcp/[name]/route.js.nft.json +1 -1
  39. package/.next/server/app/api/mcp/oauth/callback/route.js.nft.json +1 -1
  40. package/.next/server/app/api/mcp/route.js.nft.json +1 -1
  41. package/.next/server/app/api/mcp/tools/route.js.nft.json +1 -1
  42. package/.next/server/app/api/memory/edit/route.js +11 -11
  43. package/.next/server/app/api/memory/edit/route.js.nft.json +1 -1
  44. package/.next/server/app/api/memory/route.js.nft.json +1 -1
  45. package/.next/server/app/api/skills/[source]/[name]/route.js.nft.json +1 -1
  46. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  47. package/.next/server/app/api/thursday/call/route.js +6 -6
  48. package/.next/server/app/api/thursday/call/route.js.nft.json +1 -1
  49. package/.next/server/app/api/thursday/skills/route.js +6 -6
  50. package/.next/server/app/api/thursday/skills/route.js.nft.json +1 -1
  51. package/.next/server/app/api/thursday/tool-call/route.js +8 -8
  52. package/.next/server/app/api/thursday/tool-call/route.js.nft.json +1 -1
  53. package/.next/server/app/api/workspace/route.js +3 -2
  54. package/.next/server/app/api/workspace/route.js.nft.json +1 -1
  55. package/.next/server/app/artifact/[...path]/page/server-reference-manifest.json +1 -1
  56. package/.next/server/app/artifact/[...path]/page.js +2 -2
  57. package/.next/server/app/artifact/[...path]/page.js.nft.json +1 -1
  58. package/.next/server/app/artifact/[...path]/page_client-reference-manifest.js +1 -1
  59. package/.next/server/app/favicon.ico/route.js.nft.json +1 -1
  60. package/.next/server/app/page/server-reference-manifest.json +40 -40
  61. package/.next/server/app/page.js +1 -1
  62. package/.next/server/app/page.js.nft.json +1 -1
  63. package/.next/server/app/page_client-reference-manifest.js +1 -1
  64. package/.next/server/chunks/[root-of-the-server]__026u77e._.js +5 -0
  65. package/.next/server/chunks/{[root-of-the-server]__02xjoto._.js → [root-of-the-server]__050zri4._.js} +1 -1
  66. package/.next/server/chunks/[root-of-the-server]__0bg9710._.js +3 -0
  67. package/.next/server/chunks/[root-of-the-server]__0in-d5s._.js +3 -0
  68. package/.next/server/chunks/[root-of-the-server]__0pa9k-f._.js +8 -0
  69. package/.next/server/chunks/[root-of-the-server]__0qi_m0e._.js +8 -0
  70. package/.next/server/chunks/[root-of-the-server]__0tq0m_k._.js +3 -0
  71. package/.next/server/chunks/[root-of-the-server]__0xep6j_._.js +3 -0
  72. package/.next/server/chunks/[root-of-the-server]__144l7qt._.js +8 -0
  73. package/.next/server/chunks/[root-of-the-server]__1anc1p9._.js +1 -1
  74. package/.next/server/chunks/[root-of-the-server]__1jg8t86._.js +8 -0
  75. package/.next/server/chunks/{[root-of-the-server]__12cabg9._.js → [root-of-the-server]__1lldzy9._.js} +1 -1
  76. package/.next/server/chunks/{[root-of-the-server]__01dkp4a._.js → [root-of-the-server]__1peqvsi._.js} +1 -1
  77. package/.next/server/chunks/[root-of-the-server]__1w8gm5h._.js +10 -0
  78. package/.next/server/chunks/[root-of-the-server]__1xt39nn._.js +8 -0
  79. package/.next/server/chunks/[root-of-the-server]__1zb8mfo._.js +8 -0
  80. package/.next/server/chunks/_0-fjql4._.js +11 -6
  81. package/.next/server/chunks/_00xh_q0._.js +1 -1
  82. package/.next/server/chunks/{_1_9mjx-._.js → _091bi8d._.js} +1 -1
  83. package/.next/server/chunks/_0fzqt2y._.js +29 -21
  84. package/.next/server/chunks/{_0u4aiy2._.js → _0lr92vd._.js} +1 -1
  85. package/.next/server/chunks/_0mecv0f._.js +11 -6
  86. package/.next/server/chunks/{_0sbynis._.js → _0si7xzn._.js} +1 -1
  87. package/.next/server/chunks/_1230wbx._.js +5 -5
  88. package/.next/server/chunks/{_0obojvv._.js → _18d7-5p._.js} +1 -1
  89. package/.next/server/chunks/_1c78vw5._.js +33 -25
  90. package/.next/server/chunks/_1d0j961._.js +2 -2
  91. package/.next/server/chunks/_1krd9rf._.js +1 -1
  92. package/.next/server/chunks/{_03a0897._.js → _1zkfbaa._.js} +1 -1
  93. package/.next/server/chunks/features_1qf3cei._.js +6 -1
  94. package/.next/server/chunks/{node_modules_consola_dist_chunks_prompt_mjs_0r28-sm._.js → node_modules_consola_dist_chunks_prompt_mjs_17k8irj._.js} +1 -1
  95. package/.next/server/chunks/{node_modules_consola_dist_chunks_prompt_mjs_1xoq2rl._.js → node_modules_consola_dist_chunks_prompt_mjs_1jidylq._.js} +1 -1
  96. package/.next/server/chunks/ssr/[root-of-the-server]__0-nob52._.js +4 -0
  97. package/.next/server/chunks/ssr/{[root-of-the-server]__202p40z._.js → [root-of-the-server]__0s4wb56._.js} +1 -1
  98. package/.next/server/chunks/ssr/{[root-of-the-server]__0xsog8n._.js → [root-of-the-server]__0sd3izg._.js} +2 -2
  99. package/.next/server/chunks/ssr/{[root-of-the-server]__1gtlw6v._.js → [root-of-the-server]__1-13tcn._.js} +2 -2
  100. package/.next/server/chunks/ssr/[root-of-the-server]__10-dz5o._.js +1 -1
  101. package/.next/server/chunks/ssr/{[root-of-the-server]__0s9l2sv._.js → [root-of-the-server]__1zidpfl._.js} +1 -1
  102. package/.next/server/chunks/ssr/_001_ms1._.js +6 -1
  103. package/.next/server/chunks/ssr/{_0gb4gmf._.js → _04z2c7b._.js} +2 -2
  104. package/.next/server/chunks/ssr/{_1nnsk7z._.js → _06tssji._.js} +2 -2
  105. package/.next/server/chunks/ssr/_0fk4c7d._.js +3 -0
  106. package/.next/server/chunks/ssr/_0i_6q1w._.js +1 -1
  107. package/.next/server/chunks/ssr/{_1h0rw11._.js → _0m2lsd1._.js} +2 -2
  108. package/.next/server/chunks/ssr/_0mggveb._.js +3 -0
  109. package/.next/server/chunks/ssr/{_1jhd09t._.js → _0nbpc_8._.js} +2 -2
  110. package/.next/server/chunks/ssr/{_1fojd41._.js → _0rzucul._.js} +3 -3
  111. package/.next/server/chunks/ssr/_0s99bgb._.js +1 -1
  112. package/.next/server/chunks/ssr/_1-teee5._.js +4 -4
  113. package/.next/server/chunks/ssr/{_0fz4mf_._.js → _11j92tu._.js} +2 -2
  114. package/.next/server/chunks/ssr/{_1b2mjwr._.js → _1ebxla1._.js} +2 -2
  115. package/.next/server/chunks/ssr/{_1gd8q8l._.js → _1jdgjv9._.js} +2 -2
  116. package/.next/server/chunks/ssr/_1lz_cq2._.js +19 -11
  117. package/.next/server/chunks/ssr/{_0ilna52._.js → _1nj4niv._.js} +2 -2
  118. package/.next/server/chunks/ssr/_1ovmc9s._.js +3 -0
  119. package/.next/server/chunks/ssr/features_config_components_config-setting_tsx_0xfwqdg._.js +1 -1
  120. package/.next/server/chunks/ssr/features_connectors_components_mcp-setting_tsx_0jmbgvp._.js +1 -1
  121. package/.next/server/chunks/ssr/{node_modules_0pvd7q-._.js → node_modules_0-dr_o7._.js} +1 -1
  122. package/.next/server/chunks/ssr/{node_modules_1_pc9kq._.js → node_modules_04sksn9._.js} +1 -1
  123. package/.next/server/chunks/ssr/{node_modules_1vh5ntv._.js → node_modules_05-n6dz._.js} +1 -1
  124. package/.next/server/chunks/ssr/{node_modules_0b__sgl._.js → node_modules_056qgf-._.js} +1 -1
  125. package/.next/server/chunks/ssr/{node_modules_0g7fzk3._.js → node_modules_07dsgkx._.js} +1 -1
  126. package/.next/server/chunks/ssr/{node_modules_04jf_m8._.js → node_modules_0bvopy2._.js} +1 -1
  127. package/.next/server/chunks/ssr/{node_modules_0arus10._.js → node_modules_0fkeku3._.js} +1 -1
  128. package/.next/server/chunks/ssr/{node_modules_14om4ar._.js → node_modules_0ifp389._.js} +1 -1
  129. package/.next/server/chunks/ssr/{node_modules_0cxaxk9._.js → node_modules_0iur59s._.js} +1 -1
  130. package/.next/server/chunks/ssr/{node_modules_1aaxuef._.js → node_modules_0m383r2._.js} +1 -1
  131. package/.next/server/chunks/ssr/{node_modules_1qpjvy5._.js → node_modules_0u9da1h._.js} +1 -1
  132. package/.next/server/chunks/ssr/{node_modules_1cx_ql6._.js → node_modules_0zeg3x_._.js} +1 -1
  133. package/.next/server/chunks/ssr/{node_modules_0_-_a--._.js → node_modules_1-y7nni._.js} +1 -1
  134. package/.next/server/chunks/ssr/{node_modules_08dhkiq._.js → node_modules_16mkxuo._.js} +1 -1
  135. package/.next/server/chunks/ssr/{node_modules_198nmll._.js → node_modules_1a0kmjl._.js} +1 -1
  136. package/.next/server/chunks/ssr/{node_modules_1efrqxi._.js → node_modules_1chvq5f._.js} +1 -1
  137. package/.next/server/chunks/ssr/{node_modules_0yugy6m._.js → node_modules_1j8j4pt._.js} +1 -1
  138. package/.next/server/chunks/ssr/{node_modules_09q35v_._.js → node_modules_1k6n600._.js} +1 -1
  139. package/.next/server/chunks/ssr/{node_modules_1cz2hle._.js → node_modules_1k7z2j6._.js} +1 -1
  140. package/.next/server/chunks/ssr/{node_modules_1gdh31p._.js → node_modules_1k8j8n0._.js} +1 -1
  141. package/.next/server/chunks/ssr/{node_modules_1eg7m6c._.js → node_modules_1lz0oxc._.js} +1 -1
  142. package/.next/server/chunks/ssr/{node_modules_0940ns5._.js → node_modules_1t77e4n._.js} +1 -1
  143. package/.next/server/chunks/ssr/{node_modules_00etblx._.js → node_modules_1uyk91w._.js} +1 -1
  144. package/.next/server/chunks/ssr/{node_modules_0had7xd._.js → node_modules_1x0ztf4._.js} +1 -1
  145. package/.next/server/chunks/ssr/{node_modules_14q6g4n._.js → node_modules_1y79i83._.js} +1 -1
  146. package/.next/server/chunks/ssr/{node_modules_1hpopmo._.js → node_modules_20c28js._.js} +1 -1
  147. package/.next/server/chunks/ssr/{node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_1b748ic._.js → node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_0bkwd0u._.js} +1 -1
  148. package/.next/server/chunks/ssr/{node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_1z4hm5e._.js → node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_0p9a9su._.js} +1 -1
  149. package/.next/server/chunks/ssr/{node_modules_@vercel_oidc_dist_06mk8id._.js → node_modules_@vercel_oidc_dist_1o553h-._.js} +1 -1
  150. package/.next/server/middleware-build-manifest.js +3 -3
  151. package/.next/server/pages/404.html +1 -1
  152. package/.next/server/pages/500.html +1 -1
  153. package/.next/server/server-reference-manifest.js +1 -1
  154. package/.next/server/server-reference-manifest.json +41 -41
  155. package/.next/static/chunks/{39j0fvpybzabl.js → 0n3jle9kbqfr2.js} +2 -2
  156. package/.next/static/chunks/{0fl3gwe4-l6tz.js → 11r2yp_yh9_qn.js} +1 -1
  157. package/.next/static/chunks/{1jd09sxcjddu1.js → 126fj4pga_72k.js} +1 -1
  158. package/.next/static/chunks/{0gnqdjn2lvdad.js → 159udiexf6dom.js} +1 -1
  159. package/.next/static/chunks/{08qe4as2q428z.js → 18gi7o2ibrgh8.js} +1 -1
  160. package/.next/static/chunks/{2u7xt6mxg44lf.js → 1q9z1tohil7xn.js} +1 -1
  161. package/.next/static/chunks/{3zmcsnuo25zvq.js → 1sjgfets224re.js} +1 -1
  162. package/.next/static/chunks/{0wcprt5ot5she.js → 1wszsr83t01gh.js} +1 -1
  163. package/.next/static/chunks/{0w52rrrmuj9r9.js → 2szintyibu0s5.js} +1 -1
  164. package/.next/static/chunks/{0hwap1-p0m5sa.js → 3-b6b7entqazs.js} +1 -1
  165. package/.next/static/chunks/{17p6tk6k88dq6.js → 3dmbhsv2u_8kj.js} +1 -1
  166. package/.next/static/chunks/{214ya3n5gxeih.js → 3i0a5px-oufgc.js} +1 -1
  167. package/.next/static/chunks/{1w73ece_6bheu.js → 3wexk9qo8zuj3.js} +3 -3
  168. package/.next/static/chunks/3xre8bu_pk1p_.js +1 -0
  169. package/.next/static/chunks/{3woj-xd7ki5_i.js → 3zv6t3i77xhr_.js} +1 -1
  170. package/.next/static/chunks/{3akmqm77muj4-.js → 408ueu4hfvcg7.js} +1 -1
  171. package/.next/static/chunks/{15m575979un5f.js → 435atsnhs7ksu.js} +6 -6
  172. package/bin/thursday.mjs +0 -0
  173. package/features/ai/prompts/bot.prompt.ts +1 -1
  174. package/features/ai/tools/bot.tool.ts +3 -3
  175. package/features/ai/tools/skills.tool.ts +25 -8
  176. package/features/bot/bot.run.ts +184 -73
  177. package/features/bot/bot.runner.ts +48 -23
  178. package/features/bot/task.query.ts +107 -4
  179. package/features/bot/task.store.ts +3 -5
  180. package/features/workspace/workspace.ts +27 -20
  181. package/package.json +1 -1
  182. package/skills/browser/SKILL.md +6 -5
  183. package/.next/server/chunks/[root-of-the-server]__04q--0f._.js +0 -8
  184. package/.next/server/chunks/[root-of-the-server]__0bl-e63._.js +0 -3
  185. package/.next/server/chunks/[root-of-the-server]__0jq4ohy._.js +0 -8
  186. package/.next/server/chunks/[root-of-the-server]__0ls6_8y._.js +0 -8
  187. package/.next/server/chunks/[root-of-the-server]__1339jnv._.js +0 -8
  188. package/.next/server/chunks/[root-of-the-server]__18_teo8._.js +0 -8
  189. package/.next/server/chunks/[root-of-the-server]__1ccon1w._.js +0 -10
  190. package/.next/server/chunks/[root-of-the-server]__1wa1k7r._.js +0 -8
  191. package/.next/server/chunks/_1zjrdag._.js +0 -3
  192. package/.next/server/chunks/ssr/[root-of-the-server]__1ox7hbk._.js +0 -4
  193. package/.next/server/chunks/ssr/_0n3toaw._.js +0 -3
  194. package/.next/server/chunks/ssr/_1-_etts._.js +0 -3
  195. package/.next/server/chunks/ssr/_1f-dg-6._.js +0 -3
  196. package/.next/static/chunks/00rd4-v8r7axp.js +0 -1
  197. /package/.next/static/{kaTO0UHqF1bMnWgCDik2f → G6-gNoyGDEcgAsq-L4PE1}/_buildManifest.js +0 -0
  198. /package/.next/static/{kaTO0UHqF1bMnWgCDik2f → G6-gNoyGDEcgAsq-L4PE1}/_clientMiddlewareManifest.js +0 -0
  199. /package/.next/static/{kaTO0UHqF1bMnWgCDik2f → G6-gNoyGDEcgAsq-L4PE1}/_ssgManifest.js +0 -0
package/bin/thursday.mjs CHANGED
File without changes
@@ -232,7 +232,7 @@ function methods(skills: SkillMetadata[]): string {
232
232
 
233
233
  return `## Skills
234
234
 
235
- Written-down methods. When the job is one of these, read it with \`${TOOL_NAMES.load_skill}\` before starting.
235
+ Written-down methods. Use the instructions already in your conversation. When the job needs a skill whose full instructions are absent, read it with \`${TOOL_NAMES.load_skill}\` before starting.
236
236
 
237
237
  ${skillLines(skills)}`;
238
238
  }
@@ -34,12 +34,12 @@ export const delegateSpec = {
34
34
 
35
35
  /**
36
36
  * `context` carries what the asking bot found, since its thread is not handed over (bot.run).
37
- * `request` is a brief, not a line: the borrowed bot sees nothing else.
37
+ * A returning participant keeps its own thread; `context` adds only what this caller knows.
38
38
  */
39
39
  export const askBotSpec = {
40
40
  name: TOOL_NAMES.ask_bot,
41
41
  description:
42
- "Hand one part of the job you are holding to another bot and wait for what they bring back. The job stays yours.",
42
+ "Send a request to another bot on this job and wait for their reply. The same bot continues its own work and conversation whenever you ask it again. The job stays yours.",
43
43
  parameters: z.object({
44
44
  bot: z.string().describe("A name from the bot list."),
45
45
  request: z
@@ -50,7 +50,7 @@ export const askBotSpec = {
50
50
  context: z
51
51
  .string()
52
52
  .describe(
53
- "What you have done on the job so far and why this part is theirs — exact values, and the paths of files you wrote for them to read. They see this, the part, the job and the call; not your thread or your tool output, so a value you leave out is one they fetch again.",
53
+ "Pass on what you know that they do not: exact values and paths they need. They keep their own previous work and your exchanges, but cannot read your private thread or tool output.",
54
54
  ),
55
55
  }),
56
56
  };
@@ -6,9 +6,8 @@ import { splitFrontmatter } from "@/features/skills/skills.query";
6
6
  import type { Sandbox } from "@/lib/sandbox";
7
7
 
8
8
  /**
9
- * A skill's full text is returned once per run; repeat loads get a pointer
10
- * back to the first result, since every step resends the whole context.
11
- * The tool set is built per run, so the `loaded` set lives in this closure.
9
+ * Reuse instructions only while their full text remains in this bot's context.
10
+ * A resume can carry them; compaction can remove them.
12
11
  */
13
12
  export const createSkillTools = ({
14
13
  skills,
@@ -17,7 +16,6 @@ export const createSkillTools = ({
17
16
  skills: SkillMetadata[];
18
17
  sandbox: Sandbox;
19
18
  }) => {
20
- const loaded = new Set<string>();
21
19
  return {
22
20
  [TOOL_NAMES.load_skill]: tool({
23
21
  description:
@@ -29,7 +27,7 @@ export const createSkillTools = ({
29
27
  "Exact name from the Skills list. Do not guess names that are not on it.",
30
28
  ),
31
29
  }),
32
- execute: async ({ name }) => {
30
+ execute: async ({ name }, { messages }) => {
33
31
  const skill = skills.find(
34
32
  (s) => s.name.toLowerCase() === name.toLowerCase(),
35
33
  );
@@ -41,10 +39,30 @@ export const createSkillTools = ({
41
39
  }
42
40
 
43
41
  const skillFile = `${skill.path}/SKILL.md`;
44
- if (loaded.has(skill.path)) {
42
+ const carried = messages.some(
43
+ (message) =>
44
+ Array.isArray(message.content) &&
45
+ message.content.some((part) => {
46
+ if (
47
+ part.type !== "tool-result" ||
48
+ part.toolName !== TOOL_NAMES.load_skill ||
49
+ part.output.type !== "json"
50
+ )
51
+ return false;
52
+ const value = part.output.value as {
53
+ skillDirectory?: string;
54
+ content?: string;
55
+ } | null;
56
+ return (
57
+ value?.skillDirectory === skill.path &&
58
+ typeof value.content === "string"
59
+ );
60
+ }),
61
+ );
62
+ if (carried) {
45
63
  return {
46
64
  skillDirectory: skill.path,
47
- note: `You already loaded '${skill.name}' in this run — its instructions are above, unchanged. Scroll back to that result rather than reading it again; if you need the file itself, read ${skillFile} with the shell.`,
65
+ note: `Use the full '${skill.name}' instructions already in your conversation. To check for changes on disk, read ${skillFile} with the shell.`,
48
66
  };
49
67
  }
50
68
 
@@ -55,7 +73,6 @@ export const createSkillTools = ({
55
73
  path: skill.path,
56
74
  limit: 50,
57
75
  });
58
- loaded.add(skill.path);
59
76
 
60
77
  return {
61
78
  skillDirectory: skill.path,
@@ -41,17 +41,21 @@ import {
41
41
  type TokenUsage,
42
42
  } from "@/features/bot/bot.schema";
43
43
  import {
44
- closeHiddenBrowser,
44
+ botBrowserSession,
45
45
  filesOnDisk,
46
46
  openBotFolder,
47
47
  openJobScratch,
48
48
  } from "@/features/workspace/workspace";
49
49
  import { logger } from "@/lib/logger";
50
50
  import { publicError } from "@/lib/public-error";
51
+ import { createKeyedLock } from "@/lib/queue";
51
52
  import { estimateTokens } from "@/lib/tokens";
53
+ import { PromiseChain } from "@/lib/utils";
52
54
  import { findJobBot } from "./bot.query";
53
55
  import {
54
56
  findTask,
57
+ listBorrowedTails,
58
+ listBotThread,
55
59
  listWrittenPaths,
56
60
  optionsOf,
57
61
  writtenPathsIn,
@@ -65,6 +69,11 @@ import {
65
69
 
66
70
  /** One thing that happened in a run. The runner adds which bot and inside which `ask_bot` call. */
67
71
  export type BotEvent =
72
+ /** A request enters this participant's own thread before its model runs. */
73
+ | {
74
+ type: "request";
75
+ content: Extract<ModelMessage, { role: "user" }>["content"];
76
+ }
68
77
  /** A finished chunk of prose. */
69
78
  | { type: "text"; text: string }
70
79
  /** A tool call. `id` is where the result lands. */
@@ -147,6 +156,8 @@ export type RunOptions = {
147
156
  * in one window.
148
157
  */
149
158
  session?: string | null;
159
+ /** Shared by every participant in this live segment; history itself lives in the DB. */
160
+ desks?: ReturnType<typeof createBotDesks>;
150
161
  /**
151
162
  * Drained once per step boundary; each string goes in front of the model as
152
163
  * a user turn before the next step. Only the job's own bot has one.
@@ -189,11 +200,11 @@ export async function runBot(
189
200
  ): Promise<void> {
190
201
  const depth = options.depth ?? 0;
191
202
  const parent = options.parent ?? null;
192
- const name = input.bot.trim();
203
+ const bot = await findJobBot(input.bot.trim());
204
+ const name = bot?.name ?? input.bot.trim();
193
205
  const emit = (event: BotEvent) =>
194
206
  options.emit({ ...event, bot: name, parent });
195
207
 
196
- const bot = await findJobBot(name);
197
208
  if (!bot) {
198
209
  await emit({
199
210
  type: "error",
@@ -205,7 +216,11 @@ export async function runBot(
205
216
  // Switched off is not a bot to pick — the backstop for every fresh start
206
217
  // (`delegate`, `ask_bot`, the screen). A resume carries `messages` instead and
207
218
  // is let through: its thread already exists and the user can still answer it.
208
- if (bot.disabled && "request" in input) {
219
+ const previous =
220
+ "askedBy" in input && options.taskId
221
+ ? await listBotThread(options.taskId, name)
222
+ : [];
223
+ if (bot.disabled && "request" in input && !previous.length) {
209
224
  await emit({
210
225
  type: "error",
211
226
  message: `${bot.name} is switched off. Use a name from the list.`,
@@ -214,6 +229,30 @@ export async function runBot(
214
229
  return;
215
230
  }
216
231
 
232
+ options = { ...options, desks: options.desks ?? createBotDesks() };
233
+ let history: ModelMessage[];
234
+ if ("messages" in input) {
235
+ history = input.messages;
236
+ } else {
237
+ const content = previous.length
238
+ ? `## ${input.askedBy} → ${name}: the next request\n\n${input.request.trim()}${input.context?.trim() ? `\n\n## What ${input.askedBy} passes on\n\n${input.context.trim()}` : ""}`
239
+ : buildHandoff({
240
+ chain: input.chain,
241
+ bots: [...input.above, name],
242
+ did: input.context ?? null,
243
+ part: input.request,
244
+ });
245
+ history = [
246
+ ...resumeThread(
247
+ previous,
248
+ options.taskId ? await listBorrowedTails(options.taskId) : undefined,
249
+ ),
250
+ { role: "user", content },
251
+ ];
252
+ options.signal?.throwIfAborted();
253
+ await emit({ type: "request", content });
254
+ }
255
+
217
256
  // Tools are built on the model: web search runs on this bot's model (load-tools).
218
257
  const model = await resolveModel(bot);
219
258
  // The job's row: the same one for every seat on the job, a borrowed bot's too
@@ -254,21 +293,6 @@ export async function runBot(
254
293
  ("askedBy" in input ? 0 : row?.contextBudget) || Number.POSITIVE_INFINITY,
255
294
  );
256
295
 
257
- const history: ModelMessage[] =
258
- "messages" in input
259
- ? input.messages
260
- : [
261
- {
262
- role: "user",
263
- content: buildHandoff({
264
- chain: input.chain,
265
- bots: [...input.above, name],
266
- did: input.context ?? null,
267
- part: input.request,
268
- }),
269
- },
270
- ];
271
-
272
296
  /** Remaining `ask_back` calls for a borrowed bot; the tool counts it down. */
273
297
  const asks = { left: BOT_RUN.askBack };
274
298
  // A borrowed bot asks back instead of asking Thursday. Any seat above the
@@ -879,15 +903,20 @@ export async function runBotToText(
879
903
  await runBot(input, {
880
904
  ...options,
881
905
  emit: async (event) => {
882
- if (event.type === "answer") {
906
+ if (
907
+ event.parent === (options.parent ?? null) &&
908
+ event.type === "answer"
909
+ ) {
883
910
  outcome = event.stopped
884
911
  ? `${event.text}\n\n(As far as they got — they stopped before finishing.)`
885
912
  : event.text;
886
913
  }
887
- if (event.type === "error") failure = event.message;
914
+ if (event.parent === (options.parent ?? null) && event.type === "error")
915
+ failure = event.message;
888
916
  await options.emit(event);
889
917
  },
890
918
  });
919
+ options.signal?.throwIfAborted();
891
920
  if (failure) publicError(failure);
892
921
  return outcome;
893
922
  }
@@ -918,6 +947,11 @@ function withAskBot(
918
947
  },
919
948
  ): ToolSet {
920
949
  if (asker.peers.length === 0) return tools;
950
+ // Calls made in the same step answer from one evolving conversation.
951
+ const conversations = new WeakMap<
952
+ ModelMessage[],
953
+ { messages: ModelMessage[]; answer: ReturnType<typeof PromiseChain> }
954
+ >();
921
955
 
922
956
  const agentTools: ToolSet = {
923
957
  ...tools,
@@ -925,66 +959,143 @@ function withAskBot(
925
959
  description: askBotSpec.description,
926
960
  inputSchema: askBotSpec.parameters,
927
961
  execute: async ({ bot, request, context }, call) => {
928
- // A bot above is blocked waiting on this very call; borrowing it back never returns
962
+ const found = await findJobBot(bot);
963
+ if (!found) return `No bot named "${bot}". Use a name from the list.`;
964
+ bot = found.name;
929
965
  if (bot === asker.name || asker.above.includes(bot)) {
930
- return `${bot} is already on this job, above you and waiting on your part. Pick another bot, or do it yourself.`;
931
- }
932
- // The borrowed bot works in the job's folder but drives a browser session
933
- // of its own, under this call's id. When it answers, the same rule as a
934
- // job's end: what it showed on their screen stays, what nobody can see
935
- // closes (workspace.ts closeHiddenBrowser).
936
- const session = asker.options.taskId
937
- ? `${asker.options.session ?? asker.options.taskId}-${call.toolCallId.slice(0, 8)}`
938
- : null;
939
- const exchanges: { question: string; answer: string }[] = [];
940
- const answer = async (question: string) => {
941
- const reply = await answerBack({
942
- model: asker.model,
943
- instructions: asker.instructions,
944
- // Same tools, calling off (see compact).
945
- tools: agentTools,
946
- messages: call.messages,
947
- asked: { bot, request },
948
- question,
949
- signal: asker.options.signal,
950
- });
951
- await asker.emit({ type: "answered", question, ...reply });
952
- exchanges.push({ question, answer: reply.text });
953
- return reply.text;
954
- };
955
- try {
956
- const outcome = await runBotToText(
957
- {
958
- bot,
959
- request,
960
- context,
961
- askedBy: asker.name,
962
- chain: asker.chain,
963
- above: [...asker.above, asker.name],
964
- },
965
- {
966
- ...asker.options,
967
- // This call's signal, not the job's: a stream that ends — the job
968
- // stopping, or the model going quiet — ends the part it waited on
969
- signal: call.abortSignal ?? asker.options.signal,
970
- depth: asker.depth + 1,
971
- parent: call.toolCallId,
972
- session,
973
- answer,
974
- },
975
- );
976
- return exchanges.length
977
- ? `${outcome}\n\n${exchangeLines(bot, exchanges)}`
978
- : outcome;
979
- } finally {
980
- if (session) void closeHiddenBrowser(session);
966
+ return `${bot} is already above you and waiting on this part. Use ask_back for a question, or pick another bot.`;
981
967
  }
968
+ const signal = call.abortSignal ?? asker.options.signal;
969
+ const desks = asker.options.desks!;
970
+ return desks.run(asker.name, bot, signal, async () => {
971
+ const session = asker.options.taskId
972
+ ? botBrowserSession(asker.options.taskId, bot)
973
+ : null;
974
+ const exchanges: { question: string; answer: string }[] = [];
975
+ let conversation = conversations.get(call.messages);
976
+ if (!conversation) {
977
+ conversation = {
978
+ messages: [...call.messages],
979
+ answer: PromiseChain(),
980
+ };
981
+ conversations.set(call.messages, conversation);
982
+ }
983
+ const current = conversation;
984
+ const answer = (question: string) =>
985
+ current.answer(async () => {
986
+ signal?.throwIfAborted();
987
+ const reply = await answerBack({
988
+ model: asker.model,
989
+ instructions: asker.instructions,
990
+ tools: agentTools,
991
+ messages: current.messages,
992
+ asked: { bot, request },
993
+ question,
994
+ signal,
995
+ });
996
+ current.messages.push(
997
+ {
998
+ role: "user",
999
+ content: `${bot} asks about the part you handed over:\n\n${question}`,
1000
+ },
1001
+ { role: "assistant", content: reply.text },
1002
+ );
1003
+ await asker.emit({ type: "answered", question, ...reply });
1004
+ exchanges.push({ question, answer: reply.text });
1005
+ return reply.text;
1006
+ });
1007
+ try {
1008
+ const outcome = await runBotToText(
1009
+ {
1010
+ bot,
1011
+ request,
1012
+ context,
1013
+ askedBy: asker.name,
1014
+ chain: asker.chain,
1015
+ above: [...asker.above, asker.name],
1016
+ },
1017
+ {
1018
+ ...asker.options,
1019
+ signal,
1020
+ depth: asker.depth + 1,
1021
+ parent: call.toolCallId,
1022
+ session,
1023
+ // Only the task's owner drains words sent by the user.
1024
+ notes: undefined,
1025
+ answer,
1026
+ },
1027
+ );
1028
+ return exchanges.length
1029
+ ? `${outcome}\n\n${exchangeLines(bot, exchanges)}`
1030
+ : outcome;
1031
+ } catch (cause) {
1032
+ signal?.throwIfAborted();
1033
+ publicError(
1034
+ [modelErrorToString(cause), exchangeLines(bot, exchanges)]
1035
+ .filter(Boolean)
1036
+ .join("\n\n"),
1037
+ );
1038
+ }
1039
+ });
982
1040
  },
983
1041
  }),
984
1042
  };
985
1043
  return agentTools;
986
1044
  }
987
1045
 
1046
+ /** One execution per participant, with wait-cycle detection across concurrent branches. */
1047
+ export function createBotDesks() {
1048
+ const lock = createKeyedLock();
1049
+ const waiting = new Map<string, Map<string, number>>();
1050
+ const pending = new Set<Promise<unknown>>();
1051
+ const reaches = (
1052
+ from: string,
1053
+ to: string,
1054
+ seen = new Set<string>(),
1055
+ ): boolean => {
1056
+ if (from === to) return true;
1057
+ if (seen.has(from)) return false;
1058
+ seen.add(from);
1059
+ return [...(waiting.get(from)?.keys() ?? [])].some((next) =>
1060
+ reaches(next, to, seen),
1061
+ );
1062
+ };
1063
+ return {
1064
+ async run<T>(
1065
+ from: string,
1066
+ to: string,
1067
+ signal: AbortSignal | undefined,
1068
+ work: () => Promise<T>,
1069
+ ): Promise<T> {
1070
+ signal?.throwIfAborted();
1071
+ if (reaches(to, from))
1072
+ publicError(
1073
+ `${to} is waiting on your work. Finish this part before asking them, or choose another bot.`,
1074
+ );
1075
+ const edges = waiting.get(from) ?? new Map<string, number>();
1076
+ waiting.set(from, edges);
1077
+ edges.set(to, (edges.get(to) ?? 0) + 1);
1078
+ const run = lock(to, async () => {
1079
+ signal?.throwIfAborted();
1080
+ return work();
1081
+ });
1082
+ pending.add(run);
1083
+ try {
1084
+ return await run;
1085
+ } finally {
1086
+ pending.delete(run);
1087
+ const left = (edges.get(to) ?? 1) - 1;
1088
+ if (left) edges.set(to, left);
1089
+ else edges.delete(to);
1090
+ if (!edges.size) waiting.delete(from);
1091
+ }
1092
+ },
1093
+ async settle() {
1094
+ while (pending.size) await Promise.allSettled([...pending]);
1095
+ },
1096
+ };
1097
+ }
1098
+
988
1099
  /** Appended under the answer: what the borrowed bot asked and what this bot answered. */
989
1100
  function exchangeLines(
990
1101
  bot: string,
@@ -33,7 +33,12 @@ import { desktopNotify } from "@/lib/desktop-notify";
33
33
  import { logger } from "@/lib/logger";
34
34
  import { publicError } from "@/lib/public-error";
35
35
  import { createKeyedLock } from "@/lib/queue";
36
- import { resumeThread, runBot, type TaskEvent } from "./bot.run";
36
+ import {
37
+ createBotDesks,
38
+ resumeThread,
39
+ runBot,
40
+ type TaskEvent,
41
+ } from "./bot.run";
37
42
  import {
38
43
  TASK_CONTINUE,
39
44
  type TaskPending,
@@ -44,7 +49,6 @@ import {
44
49
  import {
45
50
  addTaskUsage,
46
51
  countStopsSinceSpoken,
47
- deleteFinishedTasks,
48
52
  deleteMessages,
49
53
  deleteTask,
50
54
  findTask,
@@ -275,7 +279,8 @@ export async function cancelTask(id: string) {
275
279
  // write "Cancelled." over the thing it was run for.
276
280
  if (task.endedAt) publicError("That job has already ended.");
277
281
  forgetResume(id);
278
- running.get(id)?.stop.abort();
282
+ const live = running.get(id);
283
+ live?.stop.abort();
279
284
  await updateTask(id, {
280
285
  status: "failed",
281
286
  outcome: "Cancelled.",
@@ -286,24 +291,27 @@ export async function cancelTask(id: string) {
286
291
  });
287
292
  // Cancel is a real end, so the browser session closes here; the abort in
288
293
  // answerTask is followed by a relaunch and must not close it.
289
- void closeJobShell(id);
294
+ await live?.done;
295
+ await closeJobShell(id);
290
296
  });
291
297
  }
292
298
 
293
299
  /** Deletes the job, stopping it first if it is still running. */
294
300
  export async function removeTask(id: string) {
295
- return taskLock(id, async () => {
296
- const task = await findTask(id);
297
- forgetResume(id);
298
- running.get(id)?.stop.abort();
299
- running.delete(id);
300
- void closeJobShell(id);
301
- const gone = await deleteTask(id);
302
- // The working folder's lifetime is the row's: with the job gone, what it was
303
- // working with is nobody's (workspace.ts jobScratch).
304
- if (gone && task) await removeJobScratch(id, task.label);
305
- return gone;
306
- });
301
+ return taskLock(id, () => removeLockedTask(id));
302
+ }
303
+
304
+ /** The caller holds taskLock, so a follow-up cannot race deletion or browser cleanup. */
305
+ async function removeLockedTask(id: string) {
306
+ const task = await findTask(id);
307
+ forgetResume(id);
308
+ const live = running.get(id);
309
+ live?.stop.abort();
310
+ await live?.done;
311
+ await closeJobShell(id);
312
+ const gone = await deleteTask(id);
313
+ if (gone && task) await removeJobScratch(id, task.label);
314
+ return gone;
307
315
  }
308
316
 
309
317
  /**
@@ -312,9 +320,16 @@ export async function removeTask(id: string) {
312
320
  * and only one of the two is the database's.
313
321
  */
314
322
  export async function removeFinishedTasks(): Promise<number> {
315
- const removed = await deleteFinishedTasks();
316
- for (const task of removed) await removeJobScratch(task.id, task.label);
317
- return removed.length;
323
+ let removed = 0;
324
+ for (const task of await listTaskFolders()) {
325
+ if (task.status !== "done" && task.status !== "failed") continue;
326
+ await taskLock(task.id, async () => {
327
+ const current = await findTask(task.id);
328
+ if (current?.status !== "done" && current?.status !== "failed") return;
329
+ if (await removeLockedTask(task.id)) removed += 1;
330
+ });
331
+ }
332
+ return removed;
318
333
  }
319
334
 
320
335
  /**
@@ -345,6 +360,7 @@ export async function sweepJobFiles(): Promise<string[]> {
345
360
  await taskLock(task.id, async () => {
346
361
  const now = await findTask(task.id);
347
362
  if (!now || !stale(now)) return;
363
+ await closeHiddenBrowser(task.id);
348
364
  await removeJobScratch(task.id, task.label);
349
365
  removed.push(folder);
350
366
  });
@@ -652,6 +668,12 @@ class ThreadWriter {
652
668
  if (this.closed) return;
653
669
  const step = this.step(event);
654
670
  switch (event.type) {
671
+ case "request":
672
+ return this.write(++this.seq, event, {
673
+ role: "user",
674
+ content: event.content,
675
+ });
676
+
655
677
  case "text":
656
678
  step.assistant ??= ++this.seq;
657
679
  step.parts.assistant.push({ type: "text", text: event.text });
@@ -747,6 +769,7 @@ async function drive(
747
769
  notes: string[],
748
770
  ) {
749
771
  const thread = new ThreadWriter(id, await lastSeq(id));
772
+ const desks = createBotDesks();
750
773
 
751
774
  let ending: {
752
775
  status: TaskStatus;
@@ -767,6 +790,7 @@ async function drive(
767
790
  await runBot(input, {
768
791
  signal,
769
792
  taskId: id,
793
+ desks,
770
794
  notes: () => notes.splice(0),
771
795
  emit: async (event) => {
772
796
  await thread.on(event);
@@ -829,6 +853,8 @@ async function drive(
829
853
  pending: null,
830
854
  };
831
855
  } finally {
856
+ // A resumed segment cannot overtake a participant still unwinding its tools.
857
+ await desks.settle();
832
858
  thread.close();
833
859
  running.delete(id);
834
860
  }
@@ -881,10 +907,9 @@ async function drive(
881
907
  return;
882
908
  }
883
909
 
884
- // A waiting job keeps its browser: answering resumes into the page it left
885
- // off at. An ended one closes only what nobody can see (closeHiddenBrowser).
886
- if (park || final.status === "waiting") void pruneJobFiles();
887
- else void closeHiddenBrowser(id);
910
+ // Follow-ups can reopen a completed job. Its participants keep their browsers
911
+ // until cancellation, deletion, or the workspace retention sweep.
912
+ void pruneJobFiles();
888
913
 
889
914
  const task = await findTask(id);
890
915
  if (!task) return;