@capybara-hq/kernel 0.1.0-rc.22 → 0.1.0-rc.24

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 (234) hide show
  1. package/bootstrap.json +25 -25
  2. package/dist/admin/admin.controller.d.ts +3 -3
  3. package/dist/admin/admin.controller.js +6 -2
  4. package/dist/admin/admin.module.d.ts +2 -1
  5. package/dist/admin/admin.module.js +7 -3
  6. package/dist/admin/admin.schemas.d.ts +13 -0
  7. package/dist/admin/outbox-dead.controller.d.ts +23 -0
  8. package/dist/admin/outbox-dead.controller.js +62 -0
  9. package/dist/admin/outbox-dead.schemas.d.ts +8 -0
  10. package/dist/admin/outbox-dead.schemas.js +18 -0
  11. package/dist/admin/outbox-dead.service.d.ts +66 -0
  12. package/dist/admin/outbox-dead.service.js +179 -0
  13. package/dist/admin/overview.service.d.ts +40 -5
  14. package/dist/admin/overview.service.js +41 -7
  15. package/dist/agents/from-template.js +1 -1
  16. package/dist/background/finish.d.ts +5 -2
  17. package/dist/background/finish.js +14 -3
  18. package/dist/background/launch.d.ts +44 -5
  19. package/dist/background/launch.js +92 -9
  20. package/dist/background/session.d.ts +14 -2
  21. package/dist/background/session.js +26 -4
  22. package/dist/background/status.d.ts +1 -1
  23. package/dist/background/status.js +3 -3
  24. package/dist/background/stop.d.ts +0 -2
  25. package/dist/background/stop.js +1 -21
  26. package/dist/background/store.d.ts +8 -0
  27. package/dist/background/store.js +24 -0
  28. package/dist/catalog/admin-catalog.controller.d.ts +21 -2
  29. package/dist/catalog/admin-catalog.controller.js +45 -6
  30. package/dist/catalog/admin-catalog.schemas.d.ts +5 -0
  31. package/dist/catalog/admin-catalog.schemas.js +8 -0
  32. package/dist/catalog/catalog-content.d.ts +5 -0
  33. package/dist/catalog/catalog-content.js +1 -0
  34. package/dist/catalog/catalog.module.d.ts +2 -1
  35. package/dist/catalog/catalog.module.js +5 -1
  36. package/dist/catalog/marketplaces.service.d.ts +1 -1
  37. package/dist/catalog/platform-import-update.d.ts +50 -0
  38. package/dist/catalog/platform-import-update.js +116 -0
  39. package/dist/catalog/plugin-import.d.ts +36 -1
  40. package/dist/catalog/plugin-import.js +23 -1
  41. package/dist/catalog/plugin-upgrade.d.ts +18 -0
  42. package/dist/catalog/plugin-upgrade.js +19 -0
  43. package/dist/catalog/requests.service.js +2 -2
  44. package/dist/channels/channels.module.d.ts +5 -2
  45. package/dist/channels/channels.module.js +9 -2
  46. package/dist/channels/deliver-job.d.ts +25 -0
  47. package/dist/channels/deliver-job.js +25 -0
  48. package/dist/channels/inbound.d.ts +1 -1
  49. package/dist/channels/inbound.js +2 -15
  50. package/dist/channels/jobs.d.ts +28 -18
  51. package/dist/channels/jobs.js +63 -26
  52. package/dist/config/schema.d.ts +3 -6
  53. package/dist/config/schema.js +20 -27
  54. package/dist/conversations/agent-tree.d.ts +16 -0
  55. package/dist/conversations/agent-tree.js +36 -0
  56. package/dist/conversations/conversations.service.d.ts +44 -14
  57. package/dist/conversations/conversations.service.js +52 -38
  58. package/dist/conversations/event-log.d.ts +7 -10
  59. package/dist/conversations/event-log.js +24 -15
  60. package/dist/conversations/events.js +2 -1
  61. package/dist/conversations/queue.service.d.ts +8 -3
  62. package/dist/conversations/queue.service.js +37 -13
  63. package/dist/conversations/titles.d.ts +21 -8
  64. package/dist/conversations/titles.js +42 -15
  65. package/dist/db/kernel-schema.d.ts +26 -8
  66. package/dist/devices/devices.service.d.ts +12 -3
  67. package/dist/devices/devices.service.js +30 -9
  68. package/dist/errors.d.ts +15 -0
  69. package/dist/errors.js +15 -0
  70. package/dist/evals/run-replay.js +14 -8
  71. package/dist/extensions/extensions.module.js +3 -0
  72. package/dist/extensions/lifecycle.d.ts +6 -1
  73. package/dist/extensions/lifecycle.js +9 -2
  74. package/dist/extensions/usage-subscribers.d.ts +43 -0
  75. package/dist/extensions/usage-subscribers.js +108 -0
  76. package/dist/http/api.module.js +2 -0
  77. package/dist/identity/settings.d.ts +53 -3
  78. package/dist/identity/settings.js +92 -9
  79. package/dist/index.d.ts +1 -0
  80. package/dist/index.js +1 -0
  81. package/dist/infra/cli/kernel-cli.module.js +5 -0
  82. package/dist/infra/config/kernel-config.service.d.ts +7 -0
  83. package/dist/infra/config/kernel-config.service.js +20 -0
  84. package/dist/infra/events/kernel-events.d.ts +9 -35
  85. package/dist/infra/outbox/outbox-relay.module.d.ts +6 -0
  86. package/dist/infra/outbox/outbox-relay.module.js +18 -0
  87. package/dist/infra/outbox/outbox.dispatcher.d.ts +103 -0
  88. package/dist/infra/outbox/outbox.dispatcher.js +310 -0
  89. package/dist/infra/outbox/outbox.module.d.ts +24 -0
  90. package/dist/infra/outbox/outbox.module.js +50 -0
  91. package/dist/infra/outbox/outbox.relay.d.ts +31 -0
  92. package/dist/infra/outbox/outbox.relay.js +76 -0
  93. package/dist/infra/outbox/outbox.service.d.ts +51 -0
  94. package/dist/infra/outbox/outbox.service.js +131 -0
  95. package/dist/infra/outbox/registry.d.ts +120 -0
  96. package/dist/infra/outbox/registry.js +108 -0
  97. package/dist/infra/queue/queue-names.d.ts +8 -0
  98. package/dist/infra/queue/queue-names.js +16 -0
  99. package/dist/kernel.module.js +3 -0
  100. package/dist/local-devices/device-policy.service.d.ts +4 -0
  101. package/dist/local-devices/device-policy.service.js +61 -3
  102. package/dist/local-devices/local-device-gateway.d.ts +4 -2
  103. package/dist/local-devices/local-device-gateway.js +39 -14
  104. package/dist/local-devices/machines.d.ts +25 -1
  105. package/dist/local-devices/machines.js +91 -15
  106. package/dist/local-devices/policy-summary.d.ts +44 -1
  107. package/dist/local-devices/policy-summary.js +69 -0
  108. package/dist/models/embeddings.d.ts +9 -9
  109. package/dist/models/embeddings.js +11 -18
  110. package/dist/models/models.module.js +3 -0
  111. package/dist/orgs/orgs.service.js +7 -0
  112. package/dist/orgs/settings.controller.js +2 -2
  113. package/dist/orgs/settings.service.d.ts +11 -5
  114. package/dist/orgs/settings.service.js +28 -14
  115. package/dist/overrides.d.ts +20 -3
  116. package/dist/overrides.js +10 -2
  117. package/dist/platform/exclusions.js +5 -0
  118. package/dist/platform/ops/admin.ops.js +50 -9
  119. package/dist/platform/ops/catalog-admin.ops.d.ts +3 -1
  120. package/dist/platform/ops/catalog-admin.ops.js +32 -4
  121. package/dist/platform/ops/org-settings.ops.js +3 -3
  122. package/dist/platform/ops/orgs.ops.js +1 -0
  123. package/dist/platform/ops/outbox.ops.d.ts +21 -0
  124. package/dist/platform/ops/outbox.ops.js +125 -0
  125. package/dist/platform/ops/platform.ops.js +1 -1
  126. package/dist/platform/ops/schedules.ops.js +40 -1
  127. package/dist/platform/platform-ops.module.d.ts +2 -1
  128. package/dist/platform/platform-ops.module.js +3 -0
  129. package/dist/platform/route-schemas.js +3 -0
  130. package/dist/roles/worker.module.js +9 -4
  131. package/dist/runtime/langgraph-loop.js +13 -5
  132. package/dist/runtime/limits.d.ts +15 -0
  133. package/dist/runtime/limits.js +25 -0
  134. package/dist/runtime/loop.d.ts +8 -3
  135. package/dist/runtime/loop.js +2 -1
  136. package/dist/runtime/maintenance.d.ts +4 -2
  137. package/dist/runtime/maintenance.js +17 -2
  138. package/dist/runtime/model-call.d.ts +14 -16
  139. package/dist/runtime/model-call.js +14 -24
  140. package/dist/runtime/queue-lost-jobs.d.ts +24 -0
  141. package/dist/runtime/queue-lost-jobs.js +51 -0
  142. package/dist/runtime/run-control.d.ts +42 -5
  143. package/dist/runtime/run-control.js +66 -12
  144. package/dist/runtime/run-finish.d.ts +39 -12
  145. package/dist/runtime/run-finish.js +97 -14
  146. package/dist/runtime/run-jobs.d.ts +95 -13
  147. package/dist/runtime/run-jobs.js +199 -28
  148. package/dist/runtime/run-orphan-check.job.d.ts +142 -0
  149. package/dist/runtime/run-orphan-check.job.js +339 -0
  150. package/dist/runtime/run-processors.d.ts +16 -9
  151. package/dist/runtime/run-processors.js +1 -1
  152. package/dist/runtime/run-queue.d.ts +64 -55
  153. package/dist/runtime/run-queue.js +147 -284
  154. package/dist/runtime/run-steps.d.ts +1 -2
  155. package/dist/runtime/run-steps.js +8 -11
  156. package/dist/runtime/run-worker.d.ts +6 -17
  157. package/dist/runtime/run-worker.js +32 -34
  158. package/dist/runtime/runtime.module.d.ts +2 -3
  159. package/dist/runtime/runtime.module.js +5 -6
  160. package/dist/runtime/step-log.d.ts +16 -4
  161. package/dist/runtime/step-log.js +26 -6
  162. package/dist/runtime/stream.controller.d.ts +2 -4
  163. package/dist/runtime/stream.controller.js +5 -11
  164. package/dist/runtime/subagents/child.js +1 -1
  165. package/dist/runtime/subagents/definition.d.ts +2 -1
  166. package/dist/runtime/subagents/definition.js +4 -2
  167. package/dist/runtime/subagents/deliveries.d.ts +6 -3
  168. package/dist/runtime/subagents/deliveries.js +16 -7
  169. package/dist/runtime/subagents/dispatch.d.ts +12 -12
  170. package/dist/runtime/subagents/dispatch.js +13 -38
  171. package/dist/runtime/subagents/messages.d.ts +5 -1
  172. package/dist/runtime/subagents/messages.js +11 -3
  173. package/dist/runtime/subagents/skill-fork.d.ts +3 -3
  174. package/dist/runtime/subagents/skill-fork.js +5 -5
  175. package/dist/runtime/subagents/subagents.service.js +1 -1
  176. package/dist/runtime/subagents/tree-jobs.d.ts +61 -0
  177. package/dist/runtime/subagents/tree-jobs.js +86 -0
  178. package/dist/runtime/usage-report-jobs.d.ts +23 -48
  179. package/dist/runtime/usage-report-jobs.js +67 -119
  180. package/dist/runtime/usage-report.d.ts +28 -77
  181. package/dist/runtime/usage-report.js +35 -213
  182. package/dist/runtime/waits.d.ts +48 -198
  183. package/dist/runtime/waits.js +103 -805
  184. package/dist/sandbox/platform-tiers.d.ts +86 -0
  185. package/dist/sandbox/platform-tiers.js +97 -0
  186. package/dist/sandbox/sandbox.service.d.ts +18 -5
  187. package/dist/sandbox/sandbox.service.js +75 -20
  188. package/dist/sandbox/tiers.d.ts +14 -2
  189. package/dist/sandbox/tiers.js +8 -0
  190. package/dist/schedules/calendar.d.ts +134 -0
  191. package/dist/schedules/calendar.js +410 -0
  192. package/dist/schedules/fire.d.ts +1 -3
  193. package/dist/schedules/fire.js +5 -10
  194. package/dist/schedules/run-tracker.d.ts +0 -5
  195. package/dist/schedules/run-tracker.js +0 -13
  196. package/dist/schedules/scan.job.js +15 -20
  197. package/dist/schedules/schedule-math.d.ts +42 -0
  198. package/dist/schedules/schedule-math.js +52 -0
  199. package/dist/schedules/scheduled-tasks.service.d.ts +16 -0
  200. package/dist/schedules/scheduled-tasks.service.js +53 -0
  201. package/dist/schedules/schedules.controller.d.ts +5 -1
  202. package/dist/schedules/schedules.controller.js +15 -1
  203. package/dist/schedules/schedules.schemas.d.ts +35 -0
  204. package/dist/schedules/schedules.schemas.js +27 -0
  205. package/dist/schedules/store.d.ts +5 -0
  206. package/dist/schedules/store.js +20 -0
  207. package/dist/tools/approvals.service.d.ts +26 -7
  208. package/dist/tools/approvals.service.js +88 -10
  209. package/dist/tools/builtin-names.d.ts +5 -3
  210. package/dist/tools/builtin-names.js +12 -4
  211. package/dist/tools/builtins/builtin-tools.d.ts +1 -2
  212. package/dist/tools/builtins/builtin-tools.js +21 -17
  213. package/dist/tools/builtins/sandbox/bash.js +9 -3
  214. package/dist/tools/builtins/sandbox/common.d.ts +18 -1
  215. package/dist/tools/builtins/sandbox/common.js +14 -1
  216. package/dist/tools/builtins/sandbox/index.d.ts +2 -1
  217. package/dist/tools/builtins/sandbox/index.js +4 -1
  218. package/dist/tools/builtins/sandbox/task-list.d.ts +12 -0
  219. package/dist/tools/builtins/sandbox/task-list.js +130 -0
  220. package/dist/tools/builtins/sandbox/task-output.d.ts +8 -1
  221. package/dist/tools/builtins/sandbox/task-output.js +21 -3
  222. package/dist/tools/builtins/web/extract.d.ts +2 -4
  223. package/dist/tools/builtins/web/extract.js +2 -5
  224. package/dist/tools/pipeline.d.ts +1 -1
  225. package/dist/tools/pipeline.js +1 -1
  226. package/dist/tools/questions.service.d.ts +15 -6
  227. package/dist/tools/questions.service.js +20 -8
  228. package/dist/tools/registry.d.ts +4 -2
  229. package/dist/tools/registry.js +4 -3
  230. package/dist/tools/tools.module.js +4 -3
  231. package/package.json +3 -3
  232. package/src/migrations/0118_device_policy_ops.sql +6 -0
  233. package/src/migrations/0119_outbox.sql +22 -0
  234. package/src/migrations/0120_drop_usage_report_sweep.sql +7 -0
@@ -9,6 +9,8 @@ import { AccountsService } from "../identity/accounts.js";
9
9
  import { PlatformInboxService } from "../inbox/platform-inbox.service.js";
10
10
  import { OrgsService } from "../orgs/orgs.service.js";
11
11
  import { ProviderRegistry, type ProviderStatus } from "../runtime/providers.js";
12
+ import { QueueLostJobs } from "../runtime/queue-lost-jobs.js";
13
+ import { OutboxDeadService } from "./outbox-dead.service.js";
12
14
  /** The most rows each attention list returns; the rest are counted in `more`. */
13
15
  export declare const ATTENTION_LIST_MAX = 20;
14
16
  /** The admin overview's numbers (`GET /api/v1/admin/overview`): counts only. */
@@ -17,7 +19,8 @@ export interface AdminOverviewStats {
17
19
  accounts: number;
18
20
  /**
19
21
  * What waits for a super admin: the platform inbox total plus the extensions, plugin updates and
20
- * model providers of {@link AdminAttention} (each counted in full, not capped).
22
+ * model providers of {@link AdminAttention} (each counted in full, not capped), every dead
23
+ * outbox row and the run queue's lost-jobs item.
21
24
  */
22
25
  attentionTotal: number;
23
26
  /** An attention source could not be read: `attentionTotal` leaves its items out. */
@@ -33,7 +36,7 @@ export type AttentionExtensionReason = "config" | "load_failed" | "approval" | "
33
36
  /** What is wrong with a model provider: its model list failed, or it lists no usable chat model. */
34
37
  export type AttentionProviderProblem = "list_failed" | "no_chat_model";
35
38
  /** An attention list a source failed to read (it is then empty, the others intact). */
36
- export type AttentionSource = "extensions" | "updates" | "providers";
39
+ export type AttentionSource = "extensions" | "updates" | "providers" | "outbox" | "queue";
37
40
  export interface AttentionExtension {
38
41
  name: string;
39
42
  displayName: LocalizedName | null;
@@ -61,6 +64,27 @@ export interface AttentionProvider {
61
64
  icon: string | null;
62
65
  problem: AttentionProviderProblem;
63
66
  }
67
+ /**
68
+ * Background work the kernel could not queue (transactional outbox spec §3.4): how many outbox
69
+ * rows are dead and the oldest one's `dead_at`. One item for all of them; the rows themselves are
70
+ * `GET /api/v1/admin/outbox/dead`.
71
+ */
72
+ export interface AttentionOutboxDead {
73
+ kind: "outbox_dead";
74
+ count: number;
75
+ oldestAt: string;
76
+ }
77
+ /**
78
+ * The run queue lost background jobs (transactional outbox spec §8.3): the `run-orphan-check`
79
+ * found queued runs whose job was neither in the queue nor owed, which only Redis losing data
80
+ * explains. Their jobs were added again; the item says Redis durability needs a look, how many
81
+ * jobs were lost and when last (it stays a week after the last loss).
82
+ */
83
+ export interface AttentionQueueLostJobs {
84
+ kind: "queue_lost_jobs";
85
+ count: number;
86
+ lastAt: string;
87
+ }
64
88
  /**
65
89
  * `GET /api/v1/admin/overview/attention` (spec §5.2, §9): what needs a super admin besides the
66
90
  * inbox, each list at most {@link ATTENTION_LIST_MAX} rows with the rest counted in `more`, and
@@ -71,6 +95,10 @@ export interface AdminAttention {
71
95
  extensions: AttentionExtension[];
72
96
  updates: AttentionUpdate[];
73
97
  providers: AttentionProvider[];
98
+ /** System items: at most one, `outbox_dead`, when some outbox rows are dead. */
99
+ outbox: AttentionOutboxDead[];
100
+ /** System items: at most one, `queue_lost_jobs`, when the run queue lost jobs this week. */
101
+ queue: AttentionQueueLostJobs[];
74
102
  more: Record<AttentionSource, number>;
75
103
  unavailable: AttentionSource[];
76
104
  }
@@ -81,8 +109,9 @@ export declare function providerProblem(p: ProviderStatus): AttentionProviderPro
81
109
  /**
82
110
  * The admin overview (spec §4.1, §5.2): its numbers and the 待处理 lists, read live from the
83
111
  * sources the admin pages read (`ExtensionAdmin`, the catalog, the provider registry's cached
84
- * lists). Each source is read on its own: a failing one is reported in `unavailable`. Reads are not
85
- * audited (decision D5) and not cached further (every source is PG or already Redis-cached).
112
+ * lists, the dead outbox rows, the run queue's lost-jobs canary). Each source is read on its own: a failing one is reported in
113
+ * `unavailable`. Reads are not audited (decision D5) and not cached further (every source is PG
114
+ * or already Redis-cached).
86
115
  */
87
116
  export declare class AdminOverviewService {
88
117
  private readonly extensions;
@@ -94,10 +123,16 @@ export declare class AdminOverviewService {
94
123
  private readonly orgs;
95
124
  private readonly accounts;
96
125
  private readonly marketplaces;
126
+ private readonly outboxDead;
127
+ private readonly lostJobs;
97
128
  private readonly log;
98
- constructor(extensions: ExtensionAdmin, catalog: CatalogService, registry: ProviderRegistry, displayNames: ExtensionDisplayNames, icons: PluginIcons, inbox: PlatformInboxService, orgs: OrgsService, accounts: AccountsService, marketplaces: MarketplacesService, log: Logger);
129
+ constructor(extensions: ExtensionAdmin, catalog: CatalogService, registry: ProviderRegistry, displayNames: ExtensionDisplayNames, icons: PluginIcons, inbox: PlatformInboxService, orgs: OrgsService, accounts: AccountsService, marketplaces: MarketplacesService, outboxDead: OutboxDeadService, lostJobs: Pick<QueueLostJobs, "summary">, log: Logger);
99
130
  stats(): Promise<AdminOverviewStats>;
100
131
  attention(): Promise<AdminAttention>;
132
+ /** The `queue_lost_jobs` item when the run queue lost jobs this week, else none. */
133
+ private queueRows;
134
+ /** The `outbox_dead` item when some outbox rows are dead, else none. */
135
+ private outboxRows;
101
136
  /**
102
137
  * The extensions needing a look, but not one pending approval whose plugin waits in the platform
103
138
  * inbox (a permission confirmation or an org upload: the inbox already lists and counts it). A
@@ -18,6 +18,8 @@ import { PlatformInboxService } from "../inbox/platform-inbox.service.js";
18
18
  import { KERNEL_LOGGER } from "../infra/tokens.js";
19
19
  import { OrgsService } from "../orgs/orgs.service.js";
20
20
  import { ProviderRegistry } from "../runtime/providers.js";
21
+ import { QueueLostJobs } from "../runtime/queue-lost-jobs.js";
22
+ import { OutboxDeadService } from "./outbox-dead.service.js";
21
23
  /** The most rows each attention list returns; the rest are counted in `more`. */
22
24
  export const ATTENTION_LIST_MAX = 20;
23
25
  /** The reason an extension is listed, or null when it needs nothing. */
@@ -53,8 +55,9 @@ export function providerProblem(p) {
53
55
  /**
54
56
  * The admin overview (spec §4.1, §5.2): its numbers and the 待处理 lists, read live from the
55
57
  * sources the admin pages read (`ExtensionAdmin`, the catalog, the provider registry's cached
56
- * lists). Each source is read on its own: a failing one is reported in `unavailable`. Reads are not
57
- * audited (decision D5) and not cached further (every source is PG or already Redis-cached).
58
+ * lists, the dead outbox rows, the run queue's lost-jobs canary). Each source is read on its own: a failing one is reported in
59
+ * `unavailable`. Reads are not audited (decision D5) and not cached further (every source is PG
60
+ * or already Redis-cached).
58
61
  */
59
62
  let AdminOverviewService = class AdminOverviewService {
60
63
  extensions;
@@ -66,8 +69,10 @@ let AdminOverviewService = class AdminOverviewService {
66
69
  orgs;
67
70
  accounts;
68
71
  marketplaces;
72
+ outboxDead;
73
+ lostJobs;
69
74
  log;
70
- constructor(extensions, catalog, registry, displayNames, icons, inbox, orgs, accounts, marketplaces, log) {
75
+ constructor(extensions, catalog, registry, displayNames, icons, inbox, orgs, accounts, marketplaces, outboxDead, lostJobs, log) {
71
76
  this.extensions = extensions;
72
77
  this.catalog = catalog;
73
78
  this.registry = registry;
@@ -77,6 +82,8 @@ let AdminOverviewService = class AdminOverviewService {
77
82
  this.orgs = orgs;
78
83
  this.accounts = accounts;
79
84
  this.marketplaces = marketplaces;
85
+ this.outboxDead = outboxDead;
86
+ this.lostJobs = lostJobs;
80
87
  this.log = log;
81
88
  }
82
89
  async stats() {
@@ -87,10 +94,17 @@ let AdminOverviewService = class AdminOverviewService {
87
94
  this.attention(),
88
95
  ]);
89
96
  const listed = (s) => attention[s].length + attention.more[s];
97
+ // each dead row waits for a super admin, not the one item that counts them
98
+ const dead = attention.outbox.reduce((n, o) => n + o.count, 0);
90
99
  return {
91
100
  orgs: orgs.length,
92
101
  accounts,
93
- attentionTotal: inbox + listed("extensions") + listed("updates") + listed("providers"),
102
+ attentionTotal: inbox +
103
+ listed("extensions") +
104
+ listed("updates") +
105
+ listed("providers") +
106
+ dead +
107
+ attention.queue.length,
94
108
  partial: attention.unavailable.length > 0,
95
109
  };
96
110
  }
@@ -98,16 +112,20 @@ let AdminOverviewService = class AdminOverviewService {
98
112
  // the catalog is read once: the update rows, and the plugins the inbox already lists
99
113
  const items = this.catalogItems();
100
114
  items.catch(() => { }); // awaited (and reported) by updateRows
101
- const [extensions, updates, providers] = await Promise.allSettled([
115
+ const [extensions, updates, providers, outbox, queue] = await Promise.allSettled([
102
116
  this.extensionRows(items),
103
117
  this.updateRows(items),
104
118
  this.providerRows(),
119
+ this.outboxRows(),
120
+ this.queueRows(),
105
121
  ]);
106
122
  const out = {
107
123
  extensions: [],
108
124
  updates: [],
109
125
  providers: [],
110
- more: { extensions: 0, updates: 0, providers: 0 },
126
+ outbox: [],
127
+ queue: [],
128
+ more: { extensions: 0, updates: 0, providers: 0, outbox: 0, queue: 0 },
111
129
  unavailable: [],
112
130
  };
113
131
  /** The rows of a source that answered (capped), or none and its name in `unavailable`. */
@@ -127,8 +145,22 @@ let AdminOverviewService = class AdminOverviewService {
127
145
  out.extensions = take("extensions", extensions);
128
146
  out.updates = take("updates", updates);
129
147
  out.providers = take("providers", providers);
148
+ out.outbox = take("outbox", outbox);
149
+ out.queue = take("queue", queue);
130
150
  return out;
131
151
  }
152
+ /** The `queue_lost_jobs` item when the run queue lost jobs this week, else none. */
153
+ async queueRows() {
154
+ const lost = await this.lostJobs.summary();
155
+ return lost === null ? [] : [{ kind: "queue_lost_jobs", ...lost }];
156
+ }
157
+ /** The `outbox_dead` item when some outbox rows are dead, else none. */
158
+ async outboxRows() {
159
+ const { count, oldestAt } = await this.outboxDead.summary();
160
+ if (count === 0 || oldestAt === null)
161
+ return [];
162
+ return [{ kind: "outbox_dead", count, oldestAt: oldestAt.toISOString() }];
163
+ }
132
164
  /**
133
165
  * The extensions needing a look, but not one pending approval whose plugin waits in the platform
134
166
  * inbox (a permission confirmation or an org upload: the inbox already lists and counts it). A
@@ -218,6 +250,8 @@ AdminOverviewService = __decorate([
218
250
  __param(6, Inject(OrgsService)),
219
251
  __param(7, Inject(AccountsService)),
220
252
  __param(8, Inject(MarketplacesService)),
221
- __param(9, Inject(KERNEL_LOGGER))
253
+ __param(9, Inject(OutboxDeadService)),
254
+ __param(10, Inject(QueueLostJobs)),
255
+ __param(11, Inject(KERNEL_LOGGER))
222
256
  ], AdminOverviewService);
223
257
  export { AdminOverviewService };
@@ -329,7 +329,7 @@ export async function planFromTemplate(db, orgId, row, opts) {
329
329
  }
330
330
  if (describesSubagents)
331
331
  listed.add(AGENT_TOOL);
332
- // `TaskOutput` / `TaskStop` follow `Bash`
332
+ // `TaskOutput` / `TaskStop` / `TaskList` follow `Bash`
333
333
  for (const name of AGENT_BUILTIN_NAMES)
334
334
  if (!listed.has(name))
335
335
  offBuiltin(name);
@@ -1,6 +1,7 @@
1
1
  import { EventLog } from "../conversations/event-log.js";
2
2
  import { TransactionHost } from "../infra/database/transaction-host.js";
3
3
  import { KernelEvents } from "../infra/events/kernel-events.js";
4
+ import { OutboxService } from "../infra/outbox/outbox.service.js";
4
5
  import { type BackgroundTaskRow, BackgroundTaskStore } from "./store.js";
5
6
  /** Where a task ends (as {@link BackgroundTaskStore.finish} takes it). */
6
7
  export type BackgroundFinish = Parameters<BackgroundTaskStore["finish"]>[1];
@@ -19,7 +20,8 @@ export declare const finishedEventKey: (taskId: string) => string;
19
20
  * task (two pollers, a poll and a stop) exactly one writes it. After the commit: the log's notify,
20
21
  * the `changed: "background"` frame (to the owner and, for a sub-agent's task, the conversation
21
22
  * that owns the sandbox, whose panel lists it too) and the `background_task.finished` domain event
22
- * (`BackgroundWake` wakes an idle conversation; it lives in the runtime, above this module).
23
+ * (`BackgroundWake` wakes an idle conversation; it lives in the runtime, above this module). A
24
+ * finish that may wake the conversation also owes the ensure-run backstop in the transaction.
23
25
  *
24
26
  * Holds no lock and runs no sandbox command: the caller reads `lastLines` before.
25
27
  */
@@ -28,7 +30,8 @@ export declare class BackgroundTaskFinisher {
28
30
  private readonly eventLog;
29
31
  private readonly events;
30
32
  readonly txHost: TransactionHost;
31
- constructor(store: Pick<BackgroundTaskStore, "finish">, eventLog: EventLog, events: KernelEvents, txHost: TransactionHost);
33
+ private readonly outbox;
34
+ constructor(store: Pick<BackgroundTaskStore, "finish">, eventLog: EventLog, events: KernelEvents, txHost: TransactionHost, outbox: Pick<OutboxService, "add">);
32
35
  /**
33
36
  * Finishes `task` as `to` with its last output lines (cut to their last 4 000 characters);
34
37
  * `reported` when a tool call already told the model in its run. False (nothing written) when the
@@ -13,6 +13,8 @@ import { afterCommit } from "../infra/database/after-commit.js";
13
13
  import { TransactionHost } from "../infra/database/transaction-host.js";
14
14
  import { Transactional } from "../infra/database/transactional.decorator.js";
15
15
  import { KernelEvents } from "../infra/events/kernel-events.js";
16
+ import { OutboxService } from "../infra/outbox/outbox.service.js";
17
+ import { oweEnsureRun } from "../runtime/run-jobs.js";
16
18
  import { taskOutputFile } from "./launch.js";
17
19
  import { LAST_LINES_MAX_CHARS } from "./session.js";
18
20
  import { BackgroundTaskStore } from "./store.js";
@@ -25,7 +27,8 @@ export const finishedEventKey = (taskId) => `background_task_finished:${taskId}`
25
27
  * task (two pollers, a poll and a stop) exactly one writes it. After the commit: the log's notify,
26
28
  * the `changed: "background"` frame (to the owner and, for a sub-agent's task, the conversation
27
29
  * that owns the sandbox, whose panel lists it too) and the `background_task.finished` domain event
28
- * (`BackgroundWake` wakes an idle conversation; it lives in the runtime, above this module).
30
+ * (`BackgroundWake` wakes an idle conversation; it lives in the runtime, above this module). A
31
+ * finish that may wake the conversation also owes the ensure-run backstop in the transaction.
29
32
  *
30
33
  * Holds no lock and runs no sandbox command: the caller reads `lastLines` before.
31
34
  */
@@ -34,11 +37,13 @@ let BackgroundTaskFinisher = class BackgroundTaskFinisher {
34
37
  eventLog;
35
38
  events;
36
39
  txHost;
37
- constructor(store, eventLog, events, txHost) {
40
+ outbox;
41
+ constructor(store, eventLog, events, txHost, outbox) {
38
42
  this.store = store;
39
43
  this.eventLog = eventLog;
40
44
  this.events = events;
41
45
  this.txHost = txHost;
46
+ this.outbox = outbox;
42
47
  }
43
48
  /**
44
49
  * Finishes `task` as `to` with its last output lines (cut to their last 4 000 characters);
@@ -71,6 +76,11 @@ let BackgroundTaskFinisher = class BackgroundTaskFinisher {
71
76
  },
72
77
  },
73
78
  ]);
79
+ // a finish that may wake the conversation owes the ensure-run backstop with its event: the
80
+ // wake after the commit (`BackgroundWake`) may never happen (transactional outbox spec §6)
81
+ if (event && !reported && opts.stoppedBy === undefined) {
82
+ await oweEnsureRun(this.outbox, task.conversationId, event.seq, task.orgId);
83
+ }
74
84
  const conversations = [...new Set([task.conversationId, task.sandboxConversationId])];
75
85
  afterCommit(async () => {
76
86
  if (event)
@@ -90,6 +100,7 @@ BackgroundTaskFinisher = __decorate([
90
100
  __param(0, Inject(BackgroundTaskStore)),
91
101
  __param(1, Inject(EventLog)),
92
102
  __param(2, Inject(KernelEvents)),
93
- __param(3, Inject(TransactionHost))
103
+ __param(3, Inject(TransactionHost)),
104
+ __param(4, Inject(OutboxService))
94
105
  ], BackgroundTaskFinisher);
95
106
  export { BackgroundTaskFinisher };
@@ -28,14 +28,53 @@ export declare const taskSidPath: (id: string) => string;
28
28
  export declare function newTaskId(): string;
29
29
  /** A task id ({@link newTaskId}). */
30
30
  export declare const TASK_ID_RE: RegExp;
31
+ /**
32
+ * A shell fragment that sets how a task's processes are found and signalled by its sid, defining
33
+ * `tk_alive <sid>` (status 0 while the task has a process) and `tk_kill <SIGNAL> <sid>` (0: sent,
34
+ * 1: no process left, anything else: a failure), and `pg` to the mode:
35
+ * - `-s`, the session, where `pgrep` has `-s` (Linux procps and busybox, the sandbox-fake shims:
36
+ * `pgrep -s 0`, the own session, exits 0 or 1): `pgrep -s` and `pkill -s`;
37
+ * - `-g`, the process group, elsewhere (macOS). Its `pgrep` has no `-s`, and inside the OS sandbox
38
+ * (Seatbelt) a command can neither list processes nor signal those of another command. So the
39
+ * task's process group (its id is the sid) is watched by its launcher ({@link GROUP_LAUNCHER}),
40
+ * which signals it on request: `tk_kill` appends the signal to `.capybara/tasks/g<sid>.ctl`, and
41
+ * `tk_alive` asks `kill(0, -sid)`, where "not permitted" (another command's group) still means
42
+ * the group exists. A group signal is one system call, so it misses no process forked meanwhile.
43
+ * Relative to the workspace root. Every script that asks for or signals a task's processes starts
44
+ * with it; the launch picks its launcher with the same test.
45
+ */
46
+ export declare const SESSION_MODE_SCRIPT: string;
47
+ /**
48
+ * The process group launcher (macOS: {@link SESSION_MODE_SCRIPT}'s `-g`), a perl program run as
49
+ * `perl -e <this> <seconds> <task path>` from the workspace root. It makes a session of its own
50
+ * (leaving the launch exec's process group), starts `bash <task>.sh` as the leader of a new process
51
+ * group (the task; its id, the sid, goes to `<task>.sid` through a rename) and watches it every
52
+ * 0.1 s, outside the group:
53
+ * - the command exits: its status as the shell gives it (`128 + signal` when killed) goes to
54
+ * `<task>.exit`, and what it left running in the group is SIGKILLed (the task ends when its
55
+ * command exits, as the poll does on Linux);
56
+ * - the time limit passes: 137 goes to `<task>.exit`, then the group is SIGKILLed (as
57
+ * `timeout -s KILL`);
58
+ * - a signal is requested in `g<sid>.ctl` ({@link SESSION_MODE_SCRIPT}'s `tk_kill`, read through
59
+ * a rename): the group gets it, and no exit file is written any more (a stop, as on Linux).
60
+ * It exits once the group is empty. Contains no single quote (it goes between single quotes).
61
+ */
62
+ export declare const GROUP_LAUNCHER: string;
31
63
  /**
32
64
  * The shell of the launch exec: writes `command` (base64 in the script, so nothing is quoted by
33
65
  * hand) to `.capybara/tasks/<id>.sh` and starts it with bash (the shell a foreground `Bash` call
34
- * runs its command in) in a session of its own under `timeout -s KILL <seconds>`, with its exit
35
- * status written to `<id>.exit` and its output to `<id>.out`; writes the session id (`$!`, the pid
36
- * `setsid` started) to `<id>.sid` and prints it. An environment without one of the needed programs fails with a readable error on
37
- * stderr and exit code 127, before anything starts. `id` must be a task id and `seconds` a
38
- * positive integer (both go into the script unquoted).
66
+ * runs its command in), detached from the exec under a hard time limit of `seconds` that SIGKILLs
67
+ * every process of the task, with its exit status written to `<id>.exit` and its output to
68
+ * `<id>.out`; the task's sid goes to `<id>.sid` and is printed. Where `pgrep` has `-s`
69
+ * ({@link SESSION_MODE_SCRIPT}) the command runs in a session of its own under
70
+ * `setsid nohup sh -c 'timeout -s KILL <seconds> …'` and the sid is `$!`, the pid `setsid` started;
71
+ * elsewhere (macOS: no `setsid`, no `timeout`, no `pgrep -s`) {@link GROUP_LAUNCHER} runs it in a
72
+ * process group of its own, watched by the launcher, and writes the sid (the group id) itself,
73
+ * which the launch waits for (up to 10 s). The script starts with `BACKGROUND_LAUNCH_LINE`, so a
74
+ * local device, which kills what any other exec leaves running, leaves the task alone. An
75
+ * environment without one of the needed programs fails with a readable error on stderr and exit
76
+ * code 127, before anything starts. `id` must be a task id and `seconds` a positive integer (both
77
+ * go into the script unquoted).
39
78
  */
40
79
  export declare function launchScript(id: string, command: string, seconds: number): string;
41
80
  /** The model-visible result of a launch (spec §2.1); `workspace`: the instance's workspace root. */
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { DEFAULT_SANDBOX_ROOTS } from "@capybara-hq/plugin-sdk";
2
+ import { BACKGROUND_LAUNCH_LINE, DEFAULT_SANDBOX_ROOTS } from "@capybara-hq/plugin-sdk";
3
3
  /**
4
4
  * Launching a background Bash task (spec 2026-11-05-background-bash-design §4.2): the task id, the
5
5
  * one short exec that starts the command detached from the exec, and the model-visible result.
@@ -34,18 +34,89 @@ export function newTaskId() {
34
34
  out += BASE32[b & 31];
35
35
  return out;
36
36
  }
37
- /** The programs the launch and the later polls and stops need in the environment. */
38
- const REQUIRED = ["bash", "setsid", "nohup", "timeout", "pgrep", "pkill", "base64"];
37
+ /** The programs a session launch and the later polls and stops need (Linux, sandbox-fake). */
38
+ const REQUIRED_SESSION = ["bash", "setsid", "nohup", "timeout", "pgrep", "pkill", "base64"];
39
+ /** The programs a process group launch and the later polls and stops need (macOS). */
40
+ const REQUIRED_GROUP = ["bash", "perl", "nohup", "base64"];
39
41
  /** A task id ({@link newTaskId}). */
40
42
  export const TASK_ID_RE = /^bt_[a-z2-7]{6}$/;
43
+ /**
44
+ * A shell fragment that sets how a task's processes are found and signalled by its sid, defining
45
+ * `tk_alive <sid>` (status 0 while the task has a process) and `tk_kill <SIGNAL> <sid>` (0: sent,
46
+ * 1: no process left, anything else: a failure), and `pg` to the mode:
47
+ * - `-s`, the session, where `pgrep` has `-s` (Linux procps and busybox, the sandbox-fake shims:
48
+ * `pgrep -s 0`, the own session, exits 0 or 1): `pgrep -s` and `pkill -s`;
49
+ * - `-g`, the process group, elsewhere (macOS). Its `pgrep` has no `-s`, and inside the OS sandbox
50
+ * (Seatbelt) a command can neither list processes nor signal those of another command. So the
51
+ * task's process group (its id is the sid) is watched by its launcher ({@link GROUP_LAUNCHER}),
52
+ * which signals it on request: `tk_kill` appends the signal to `.capybara/tasks/g<sid>.ctl`, and
53
+ * `tk_alive` asks `kill(0, -sid)`, where "not permitted" (another command's group) still means
54
+ * the group exists. A group signal is one system call, so it misses no process forked meanwhile.
55
+ * Relative to the workspace root. Every script that asks for or signals a task's processes starts
56
+ * with it; the launch picks its launcher with the same test.
57
+ */
58
+ export const SESSION_MODE_SCRIPT = [
59
+ 'if pgrep -s 0 >/dev/null 2>&1 || [ "$?" -eq 1 ]; then pg=-s',
60
+ 'tk_alive() { pgrep -s "$1" >/dev/null 2>&1; }',
61
+ 'tk_kill() { pkill -"$1" -s "$2"; }',
62
+ "else pg=-g",
63
+ `tk_alive() { perl -e 'exit((kill(0, -$ARGV[0]) || $!{EPERM}) ? 0 : 1)' "$1"; }`,
64
+ `tk_kill() { tk_alive "$2" || return 1; echo "$1" >> ${TASKS_DIR}/g"$2".ctl || return 2; }`,
65
+ "fi",
66
+ ].join("\n");
67
+ /**
68
+ * The process group launcher (macOS: {@link SESSION_MODE_SCRIPT}'s `-g`), a perl program run as
69
+ * `perl -e <this> <seconds> <task path>` from the workspace root. It makes a session of its own
70
+ * (leaving the launch exec's process group), starts `bash <task>.sh` as the leader of a new process
71
+ * group (the task; its id, the sid, goes to `<task>.sid` through a rename) and watches it every
72
+ * 0.1 s, outside the group:
73
+ * - the command exits: its status as the shell gives it (`128 + signal` when killed) goes to
74
+ * `<task>.exit`, and what it left running in the group is SIGKILLed (the task ends when its
75
+ * command exits, as the poll does on Linux);
76
+ * - the time limit passes: 137 goes to `<task>.exit`, then the group is SIGKILLed (as
77
+ * `timeout -s KILL`);
78
+ * - a signal is requested in `g<sid>.ctl` ({@link SESSION_MODE_SCRIPT}'s `tk_kill`, read through
79
+ * a rename): the group gets it, and no exit file is written any more (a stop, as on Linux).
80
+ * It exits once the group is empty. Contains no single quote (it goes between single quotes).
81
+ */
82
+ export const GROUP_LAUNCHER = [
83
+ "use POSIX;",
84
+ "my ($n, $t) = @ARGV;",
85
+ "my ($d) = $t =~ m{^(.*)/};",
86
+ 'sub put { my ($f, $v) = @_; open(my $h, ">", "$f.tmp") or return; print $h "$v\\n"; close($h); rename("$f.tmp", $f) }',
87
+ 'POSIX::setsid() or die "setsid: $!\\n";',
88
+ "my $c = fork();",
89
+ 'defined $c or die "fork: $!\\n";',
90
+ 'if ($c == 0) { setpgid(0, 0); exec("bash", "$t.sh") or POSIX::_exit(127) }',
91
+ "setpgid($c, $c);",
92
+ 'put("$t.sid", $c);',
93
+ 'my $ctl = "$d/g$c.ctl";',
94
+ "my $end = time() + $n;",
95
+ "my ($code, $done);",
96
+ 'sub fin { put("$t.exit", $_[0]) unless $done; $done = 1 }',
97
+ "while (1) {",
98
+ ' if (rename($ctl, "$ctl.$$")) { open(my $h, "<", "$ctl.$$"); my @s = grep { /^[A-Z0-9]+$/ } map { s/\\s+//gr } <$h>; close($h); unlink("$ctl.$$"); $done = 1; kill($_, -$c) for @s }',
99
+ ' if (!defined $code && waitpid($c, WNOHANG) == $c) { $code = ($? & 127) ? 128 + ($? & 127) : $? >> 8; fin($code); kill("KILL", -$c) }',
100
+ ' if (!defined $code && time() >= $end) { fin(137); kill("KILL", -$c) }',
101
+ " last if defined $code && !kill(0, -$c);",
102
+ " select(undef, undef, undef, 0.1);",
103
+ "}",
104
+ ].join(" ");
41
105
  /**
42
106
  * The shell of the launch exec: writes `command` (base64 in the script, so nothing is quoted by
43
107
  * hand) to `.capybara/tasks/<id>.sh` and starts it with bash (the shell a foreground `Bash` call
44
- * runs its command in) in a session of its own under `timeout -s KILL <seconds>`, with its exit
45
- * status written to `<id>.exit` and its output to `<id>.out`; writes the session id (`$!`, the pid
46
- * `setsid` started) to `<id>.sid` and prints it. An environment without one of the needed programs fails with a readable error on
47
- * stderr and exit code 127, before anything starts. `id` must be a task id and `seconds` a
48
- * positive integer (both go into the script unquoted).
108
+ * runs its command in), detached from the exec under a hard time limit of `seconds` that SIGKILLs
109
+ * every process of the task, with its exit status written to `<id>.exit` and its output to
110
+ * `<id>.out`; the task's sid goes to `<id>.sid` and is printed. Where `pgrep` has `-s`
111
+ * ({@link SESSION_MODE_SCRIPT}) the command runs in a session of its own under
112
+ * `setsid nohup sh -c 'timeout -s KILL <seconds> …'` and the sid is `$!`, the pid `setsid` started;
113
+ * elsewhere (macOS: no `setsid`, no `timeout`, no `pgrep -s`) {@link GROUP_LAUNCHER} runs it in a
114
+ * process group of its own, watched by the launcher, and writes the sid (the group id) itself,
115
+ * which the launch waits for (up to 10 s). The script starts with `BACKGROUND_LAUNCH_LINE`, so a
116
+ * local device, which kills what any other exec leaves running, leaves the task alone. An
117
+ * environment without one of the needed programs fails with a readable error on stderr and exit
118
+ * code 127, before anything starts. `id` must be a task id and `seconds` a positive integer (both
119
+ * go into the script unquoted).
49
120
  */
50
121
  export function launchScript(id, command, seconds) {
51
122
  if (!TASK_ID_RE.test(id))
@@ -55,15 +126,27 @@ export function launchScript(id, command, seconds) {
55
126
  }
56
127
  const b64 = Buffer.from(command, "utf8").toString("base64");
57
128
  const t = `${TASKS_DIR}/${id}`;
129
+ const need = (list) => `for c in ${list.join(" ")}; do command -v "$c" >/dev/null 2>&1 || { echo "Background tasks need ${list.join(", ")} in this environment; $c is missing." >&2; exit 127; }; done`;
58
130
  return [
131
+ // first: a local device kills what an exec leaves running, except a launch's detached task
132
+ BACKGROUND_LAUNCH_LINE,
59
133
  "set -e",
60
- `for c in ${REQUIRED.join(" ")}; do command -v "$c" >/dev/null 2>&1 || { echo "Background tasks need ${REQUIRED.join(", ")} in this environment; $c is missing." >&2; exit 127; }; done`,
134
+ SESSION_MODE_SCRIPT,
135
+ `if [ "$pg" = -s ]; then ${need(REQUIRED_SESSION)}; else ${need(REQUIRED_GROUP)}; fi`,
61
136
  `mkdir -p ${TASKS_DIR}`,
62
137
  `printf '%s' '${b64}' | base64 -d > ${t}.sh`,
138
+ `if [ "$pg" = -s ]; then`,
63
139
  `setsid nohup sh -c 'timeout -s KILL ${seconds} bash ${t}.sh; echo $? > ${t}.exit' > ${t}.out 2>&1 < /dev/null &`,
64
140
  // the session id goes to a file first: a launch that ends before its output comes back
65
141
  // (aborted, timed out) still leaves the id of what it started
66
142
  `echo $! > ${t}.sid`,
143
+ "else",
144
+ `rm -f ${t}.sid`,
145
+ `nohup perl -e '${GROUP_LAUNCHER}' ${seconds} ${t} > ${t}.out 2>&1 < /dev/null &`,
146
+ // the launcher writes the group id once the group exists
147
+ `i=0; while [ ! -s ${t}.sid ] && [ "$i" -lt 200 ]; do sleep 0.05; i=$((i+1)); done`,
148
+ `[ -s ${t}.sid ] || { echo "The background task did not start: no process group id after 10 s." >&2; exit 1; }`,
149
+ "fi",
67
150
  `cat ${t}.sid`,
68
151
  ].join("\n");
69
152
  }
@@ -4,7 +4,8 @@ import type { HeldProvider, TaggedProvider } from "../sandbox/sandbox.service.js
4
4
  * A background task's session inside the sandbox (spec 2026-11-05-background-bash-design §4.2):
5
5
  * every process of a task is in the session `setsid` started, so liveness is `pgrep -s <sid>` and
6
6
  * stopping is `pkill -s <sid>` (never `kill -0` or `/proc`, which disagree between sandbox-fake and
7
- * Linux).
7
+ * Linux). macOS has no `setsid` and its `pgrep` / `pkill` no `-s`: there the task is the process
8
+ * group the launch made (`launchScript`), signalled with `kill -- -<sid>` ({@link SESSION_MODE_SCRIPT}).
8
9
  */
9
10
  /**
10
11
  * `p` with every `exec` carrying `actor` (`SandboxService.ifRunning` tags execs but leaves the
@@ -27,7 +28,8 @@ export declare function assertTaskRef(id: string, sid: string): void;
27
28
  * session is empty (pkill exits 1: no process left): a process may fork between pkill's listing
28
29
  * and its signals, and the child would live on. Sets `ks` to 0 when the session was confirmed
29
30
  * empty, else to pkill's failing status (3 when it was still not empty after 10 tries). Uses only
30
- * the variables `ks`, `kc` and `ki`. `sid` must be a session id.
31
+ * the variables and functions of {@link SESSION_MODE_SCRIPT} (it starts with it), `ks`, `kc` and `ki`. `sid` must be
32
+ * a session id.
31
33
  */
32
34
  export declare function killSessionScript(sid: string): string;
33
35
  /**
@@ -37,6 +39,16 @@ export declare function killSessionScript(sid: string): string;
37
39
  * empty; false when it could not be (an error is never thrown).
38
40
  */
39
41
  export declare function killSession(p: Pick<HeldProvider, "exec">, h: SandboxHandle, sid: string): Promise<boolean>;
42
+ /** How long a stop waits after SIGTERM before SIGKILL (spec §2.3). */
43
+ export declare const STOP_GRACE_MS = 5000;
44
+ /** The time limit of the SIGTERM exec: the grace period plus some room. */
45
+ export declare const TERM_EXEC_TIMEOUT_MS: number;
46
+ /**
47
+ * Exit code 0 with `exited <code>` when the task had already finished (its exit file exists),
48
+ * else sends SIGTERM to the session and waits up to {@link STOP_GRACE_MS} for it to empty, then
49
+ * prints `term`. Relative to the workspace root.
50
+ */
51
+ export declare function termScript(id: string, sid: string): string;
40
52
  /** The longest `lastLines` of a finished task (spec §2.4). */
41
53
  export declare const LAST_LINES_MAX_CHARS = 4000;
42
54
  /**
@@ -1,10 +1,11 @@
1
1
  import { rootsOf } from "@capybara-hq/plugin-sdk";
2
- import { TASK_ID_RE, TASKS_DIR } from "./launch.js";
2
+ import { SESSION_MODE_SCRIPT, TASK_ID_RE, TASKS_DIR } from "./launch.js";
3
3
  /**
4
4
  * A background task's session inside the sandbox (spec 2026-11-05-background-bash-design §4.2):
5
5
  * every process of a task is in the session `setsid` started, so liveness is `pgrep -s <sid>` and
6
6
  * stopping is `pkill -s <sid>` (never `kill -0` or `/proc`, which disagree between sandbox-fake and
7
- * Linux).
7
+ * Linux). macOS has no `setsid` and its `pgrep` / `pkill` no `-s`: there the task is the process
8
+ * group the launch made (`launchScript`), signalled with `kill -- -<sid>` ({@link SESSION_MODE_SCRIPT}).
8
9
  */
9
10
  /**
10
11
  * `p` with every `exec` carrying `actor` (`SandboxService.ifRunning` tags execs but leaves the
@@ -35,12 +36,13 @@ export function assertTaskRef(id, sid) {
35
36
  * session is empty (pkill exits 1: no process left): a process may fork between pkill's listing
36
37
  * and its signals, and the child would live on. Sets `ks` to 0 when the session was confirmed
37
38
  * empty, else to pkill's failing status (3 when it was still not empty after 10 tries). Uses only
38
- * the variables `ks`, `kc` and `ki`. `sid` must be a session id.
39
+ * the variables and functions of {@link SESSION_MODE_SCRIPT} (it starts with it), `ks`, `kc` and `ki`. `sid` must be
40
+ * a session id.
39
41
  */
40
42
  export function killSessionScript(sid) {
41
43
  if (!SID_RE.test(sid))
42
44
  throw new Error(`invalid background task session: ${JSON.stringify(sid)}`);
43
- return `ks=3; for ki in 1 2 3 4 5 6 7 8 9 10; do pkill -KILL -s ${sid}; kc=$?; if [ "$kc" -eq 1 ]; then ks=0; break; fi; if [ "$kc" -ne 0 ]; then ks=$kc; break; fi; sleep 0.2; done`;
45
+ return `${SESSION_MODE_SCRIPT}\nks=3; for ki in 1 2 3 4 5 6 7 8 9 10; do tk_kill KILL ${sid}; kc=$?; if [ "$kc" -eq 1 ]; then ks=0; break; fi; if [ "$kc" -ne 0 ]; then ks=$kc; break; fi; sleep 0.2; done`;
44
46
  }
45
47
  /**
46
48
  * Kills every process of the session `sid` ({@link killSessionScript}) in an exec of its own. Any
@@ -62,6 +64,26 @@ export async function killSession(p, h, sid) {
62
64
  .catch(() => undefined);
63
65
  return r?.exitCode === 0;
64
66
  }
67
+ /** How long a stop waits after SIGTERM before SIGKILL (spec §2.3). */
68
+ export const STOP_GRACE_MS = 5000;
69
+ /** The time limit of the SIGTERM exec: the grace period plus some room. */
70
+ export const TERM_EXEC_TIMEOUT_MS = STOP_GRACE_MS + 5000;
71
+ /**
72
+ * Exit code 0 with `exited <code>` when the task had already finished (its exit file exists),
73
+ * else sends SIGTERM to the session and waits up to {@link STOP_GRACE_MS} for it to empty, then
74
+ * prints `term`. Relative to the workspace root.
75
+ */
76
+ export function termScript(id, sid) {
77
+ const f = `${TASKS_DIR}/${id}`;
78
+ const tries = Math.ceil(STOP_GRACE_MS / 200);
79
+ return [
80
+ `if [ -s ${f}.exit ]; then echo "exited $(cat ${f}.exit)"; exit 0; fi`,
81
+ SESSION_MODE_SCRIPT,
82
+ `tk_kill TERM ${sid} >/dev/null 2>&1`,
83
+ `i=0; while [ "$i" -lt ${tries} ] && tk_alive ${sid}; do sleep 0.2; i=$((i+1)); done`,
84
+ "echo term",
85
+ ].join("\n");
86
+ }
65
87
  /** The most bytes of a task's output end a tail reads (the event keeps at most 4 000 characters). */
66
88
  const TAIL_BYTES = 12_000;
67
89
  /** The longest `lastLines` of a finished task (spec §2.4). */
@@ -5,7 +5,7 @@ export interface TaskReport {
5
5
  /** The exit file's code; null while there is none. */
6
6
  exitCode: number | null;
7
7
  /**
8
- * Whether the session still has a process: `pgrep -s <sid>`, or, for a task whose command
8
+ * Whether the session still has a process: `pgrep -s <sid>` (`-g` on macOS), or, for a task whose command
9
9
  * exited, whether its leftover processes could not be confirmed killed.
10
10
  */
11
11
  alive: boolean;
@@ -1,4 +1,4 @@
1
- import { TASK_ID_RE, TASKS_DIR } from "./launch.js";
1
+ import { SESSION_MODE_SCRIPT, TASK_ID_RE, TASKS_DIR } from "./launch.js";
2
2
  import { decodeTail, killSessionScript, SID_RE, tailCommand } from "./session.js";
3
3
  import { exitedAs } from "./stop.js";
4
4
  /**
@@ -17,7 +17,7 @@ export function pollScript(tasks) {
17
17
  }
18
18
  const f = `${TASKS_DIR}/${id}`;
19
19
  return [
20
- `if pgrep -s ${sid} >/dev/null 2>&1; then a=1; else a=0; fi`,
20
+ `if tk_alive ${sid}; then a=1; else a=0; fi`,
21
21
  `if [ -s ${f}.exit ]; then c=$(tr -dc '0-9-' < ${f}.exit); else c=-; fi`,
22
22
  `[ -n "$c" ] || c=-`,
23
23
  `if [ "$c" != - ] && [ "$a" = 1 ]; then ${killSessionScript(sid)}; [ "$ks" = 0 ] && a=0; fi`,
@@ -25,7 +25,7 @@ export function pollScript(tasks) {
25
25
  `echo "${id} $c $a \${t:--}"`,
26
26
  ].join("\n");
27
27
  });
28
- return lines.join("\n");
28
+ return [SESSION_MODE_SCRIPT, ...lines].join("\n");
29
29
  }
30
30
  /** Parses {@link pollScript}'s output; a task missing from it is absent from the map. */
31
31
  export function parsePollOutput(stdout) {
@@ -5,8 +5,6 @@ import { type BackgroundFinish, BackgroundTaskFinisher } from "./finish.js";
5
5
  import { type BackgroundTaskRow, BackgroundTaskStore } from "./store.js";
6
6
  /** Why a task is stopped. */
7
7
  export type StopReason = "tool" | "person" | "subagent_end" | "conversation_deleted";
8
- /** How long a stop waits after SIGTERM before SIGKILL (spec §2.3). */
9
- export declare const STOP_GRACE_MS = 5000;
10
8
  /** How a task that exited by itself ends (spec §4.3): exit 137 past its deadline timed out. */
11
9
  export declare function exitedAs(task: BackgroundTaskRow, code: number, now: Date): BackgroundFinish;
12
10
  /** How a task whose sandbox is not running ends (`ifRunning`'s `not_running` status). */