crewly 1.20.270 → 1.20.271

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 (71) hide show
  1. package/dist/backend/backend/src/constants.d.ts +39 -0
  2. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  3. package/dist/backend/backend/src/constants.js +39 -0
  4. package/dist/backend/backend/src/constants.js.map +1 -1
  5. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts.map +1 -1
  6. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js +12 -0
  7. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js.map +1 -1
  8. package/dist/backend/backend/src/controllers/team/team.controller.d.ts.map +1 -1
  9. package/dist/backend/backend/src/controllers/team/team.controller.js +11 -0
  10. package/dist/backend/backend/src/controllers/team/team.controller.js.map +1 -1
  11. package/dist/backend/backend/src/index.d.ts +18 -0
  12. package/dist/backend/backend/src/index.d.ts.map +1 -1
  13. package/dist/backend/backend/src/index.js +85 -0
  14. package/dist/backend/backend/src/index.js.map +1 -1
  15. package/dist/backend/backend/src/services/agent/idle-detection.service.d.ts.map +1 -1
  16. package/dist/backend/backend/src/services/agent/idle-detection.service.js +3 -0
  17. package/dist/backend/backend/src/services/agent/idle-detection.service.js.map +1 -1
  18. package/dist/backend/backend/src/services/agent/resource-mode.service.d.ts +16 -1
  19. package/dist/backend/backend/src/services/agent/resource-mode.service.d.ts.map +1 -1
  20. package/dist/backend/backend/src/services/agent/resource-mode.service.js +24 -3
  21. package/dist/backend/backend/src/services/agent/resource-mode.service.js.map +1 -1
  22. package/dist/backend/backend/src/services/decisions/decision.service.d.ts +17 -0
  23. package/dist/backend/backend/src/services/decisions/decision.service.d.ts.map +1 -1
  24. package/dist/backend/backend/src/services/decisions/decision.service.js +21 -0
  25. package/dist/backend/backend/src/services/decisions/decision.service.js.map +1 -1
  26. package/dist/backend/backend/src/services/decisions/decision.wiring.d.ts.map +1 -1
  27. package/dist/backend/backend/src/services/decisions/decision.wiring.js +11 -0
  28. package/dist/backend/backend/src/services/decisions/decision.wiring.js.map +1 -1
  29. package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.d.ts +11 -0
  30. package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.d.ts.map +1 -1
  31. package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.js +25 -0
  32. package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.js.map +1 -1
  33. package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.service.d.ts +320 -0
  34. package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.service.d.ts.map +1 -0
  35. package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.service.js +692 -0
  36. package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.service.js.map +1 -0
  37. package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.wiring.d.ts +137 -0
  38. package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.wiring.d.ts.map +1 -0
  39. package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.wiring.js +234 -0
  40. package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.wiring.js.map +1 -0
  41. package/dist/backend/backend/src/services/slack/slack-agent-post.service.d.ts.map +1 -1
  42. package/dist/backend/backend/src/services/slack/slack-agent-post.service.js +3 -0
  43. package/dist/backend/backend/src/services/slack/slack-agent-post.service.js.map +1 -1
  44. package/dist/backend/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
  45. package/dist/backend/backend/src/services/task-pool/task-pool.service.js +13 -1
  46. package/dist/backend/backend/src/services/task-pool/task-pool.service.js.map +1 -1
  47. package/dist/backend/build-info.json +2 -2
  48. package/dist/cli/backend/src/constants.d.ts +39 -0
  49. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  50. package/dist/cli/backend/src/constants.js +39 -0
  51. package/dist/cli/backend/src/constants.js.map +1 -1
  52. package/dist/cli/backend/src/services/agent/resource-mode.service.d.ts +16 -1
  53. package/dist/cli/backend/src/services/agent/resource-mode.service.d.ts.map +1 -1
  54. package/dist/cli/backend/src/services/agent/resource-mode.service.js +24 -3
  55. package/dist/cli/backend/src/services/agent/resource-mode.service.js.map +1 -1
  56. package/dist/cli/backend/src/services/decisions/decision.service.d.ts +17 -0
  57. package/dist/cli/backend/src/services/decisions/decision.service.d.ts.map +1 -1
  58. package/dist/cli/backend/src/services/decisions/decision.service.js +21 -0
  59. package/dist/cli/backend/src/services/decisions/decision.service.js.map +1 -1
  60. package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.d.ts +11 -0
  61. package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.d.ts.map +1 -1
  62. package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.js +25 -0
  63. package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.js.map +1 -1
  64. package/dist/cli/backend/src/services/messaging/owner-thread-sentinel.service.d.ts +320 -0
  65. package/dist/cli/backend/src/services/messaging/owner-thread-sentinel.service.d.ts.map +1 -0
  66. package/dist/cli/backend/src/services/messaging/owner-thread-sentinel.service.js +692 -0
  67. package/dist/cli/backend/src/services/messaging/owner-thread-sentinel.service.js.map +1 -0
  68. package/dist/cli/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
  69. package/dist/cli/backend/src/services/task-pool/task-pool.service.js +13 -1
  70. package/dist/cli/backend/src/services/task-pool/task-pool.service.js.map +1 -1
  71. package/package.json +1 -1
@@ -0,0 +1,692 @@
1
+ /**
2
+ * Owner-thread sentinel: an owner Slack thread is never left silent.
3
+ *
4
+ * The failure it closes (2026-10-08, Atlas in #pro-think-tank): an agent says
5
+ * in the owner's thread "I'm reading the attachments, ~15 min", then gets
6
+ * stuck — a browser approval card goes top-level in the channel, Crewly
7
+ * restarts, the card expires, the agent is stopped to free a slot — and the
8
+ * thread says nothing until the owner asks "why no reply?".
9
+ *
10
+ * A thread is watched while the owner's last message there is unanswered, the
11
+ * agent's last post there is a promise ("I'm doing…", "~N min", 「马上」), the
12
+ * agent has owner work from that thread, or a card the agent waits on is open.
13
+ * On any blocking event for that agent — card posted / expired, work blocked,
14
+ * agent stopped, start failed or deferred, delivery held, Crewly restarted —
15
+ * ONE short status line goes into the thread. A promise of "~N min" that
16
+ * passes N×1.5 with no post gets one line saying what the agent is doing now,
17
+ * and the agent is nudged once.
18
+ *
19
+ * Dedupe: one line per state change per thread; the same state is never
20
+ * repeated within REPEAT_STATE_MS; informational lines are at least
21
+ * MIN_INFO_GAP_MS apart. Any post by the agent in the thread resets it.
22
+ *
23
+ * Transport-free: posting, nudging and lookups are injected (see the wiring).
24
+ *
25
+ * @module services/messaging/owner-thread-sentinel.service
26
+ * @see specs/2026-10-08-owner-thread-sentinel.md
27
+ */
28
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
29
+ import * as path from 'path';
30
+ import { ORCHESTRATOR_SESSION_NAME, OWNER_THREAD_SENTINEL_CONSTANTS as C } from '../../constants.js';
31
+ import { LoggerService } from '../core/logger.service.js';
32
+ const EN_PROMISE = [
33
+ /\b(?:i'?m|i am|we'?re|we are)\s+(?:now\s+|currently\s+|still\s+|just\s+)?(?:[a-z]+ing\b|on it|going to|about to)/i,
34
+ /\b(?:i'?ll|i will|we'?ll|we will|let me|i'?m going to|will (?:report|update|follow up|get back|post|send|share|let you know|ping))\b/i,
35
+ /\b(?:on it|eta|shortly|be right back|brb|in a (?:few|bit|moment|minute|sec(?:ond)?))\b/i,
36
+ /\bwaiting (?:for|on)\b/i,
37
+ ];
38
+ const ZH_PROMISE = /(正在|在做|在查|在看|在读|在写|马上|稍等|等(?:我|一下|下|会|你)|预计|一会|待会|稍后|这就|我来|我去|我先|做完|弄好|搞定后|完成后|回头)/;
39
+ const DURATION = /(?:~|~|约|大约|大概|about|around|approx\.?|roughly|in|within|需要|要)?\s*(\d+(?:\.\d+)?)\s*(?:[-–~~到至]\s*(\d+(?:\.\d+)?)\s*)?(minutes?|mins?|m\b|分钟|分|hours?|hrs?|h\b|小时)/i;
40
+ const CN_DIGITS = { 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
41
+ /**
42
+ * A Chinese numeral up to 99 ("十五" → 15, "二十" → 20, "五" → 5).
43
+ *
44
+ * @param s - Numeral
45
+ * @returns The number, or undefined
46
+ */
47
+ function cnNumber(s) {
48
+ if (!s)
49
+ return undefined;
50
+ if (s === '十')
51
+ return 10;
52
+ const m = /^([一二两三四五六七八九])?(十)?([一二三四五六七八九])?$/.exec(s);
53
+ if (!m)
54
+ return undefined;
55
+ if (!m[2])
56
+ return m[1] ? CN_DIGITS[m[1]] : undefined;
57
+ return (m[1] ? CN_DIGITS[m[1]] : 1) * 10 + (m[3] ? CN_DIGITS[m[3]] : 0);
58
+ }
59
+ /**
60
+ * Minutes stated in a text ("~15 min", "10-20 minutes" → 20, "1.5h" → 90,
61
+ * 「半小时」 → 30, 「十五分钟」 → 15, 「几分钟」 → 5).
62
+ *
63
+ * @param text - The post
64
+ * @returns Minutes, or undefined when none is stated
65
+ */
66
+ export function parsePromisedMinutes(text) {
67
+ const t = text ?? '';
68
+ if (/半(?:个)?小时/.test(t))
69
+ return 30;
70
+ if (/几分钟/.test(t))
71
+ return 5;
72
+ const cn = /([一二两三四五六七八九十]{1,3})\s*(?:个)?(分钟|小时)/.exec(t);
73
+ if (cn) {
74
+ const n = cnNumber(cn[1]);
75
+ if (n !== undefined)
76
+ return cn[2] === '小时' ? n * 60 : n;
77
+ }
78
+ const m = DURATION.exec(t);
79
+ if (!m)
80
+ return undefined;
81
+ const n = Number(m[2] ?? m[1]);
82
+ if (!Number.isFinite(n) || n <= 0)
83
+ return undefined;
84
+ const unit = m[3].toLowerCase();
85
+ const minutes = /^(h|hours?|hrs?|小时)$/.test(unit) ? n * 60 : n;
86
+ return Math.round(minutes);
87
+ }
88
+ /**
89
+ * Whether an agent's post is a promise / in-progress statement, and the
90
+ * minutes it states. A post that only reports what was done is not one.
91
+ *
92
+ * @param text - The agent's post
93
+ * @returns The promise, or null
94
+ *
95
+ * @example
96
+ * ```typescript
97
+ * detectPromise("I'm reading the attachments now, ~15 min"); // { minutes: 15 }
98
+ * detectPromise('Done — the report is attached.'); // null
99
+ * ```
100
+ */
101
+ export function detectPromise(text) {
102
+ const t = (text ?? '').trim();
103
+ if (!t)
104
+ return null;
105
+ const minutes = parsePromisedMinutes(t);
106
+ const explicitEta = /(?:~|~|约|大约|预计|\beta\b)\s*\d/i.test(t) || /\b(?:in|within)\s+\d+\s*(?:min|minutes?|h|hours?)\b/i.test(t);
107
+ const said = explicitEta || EN_PROMISE.some((re) => re.test(t)) || ZH_PROMISE.test(t);
108
+ if (!said)
109
+ return null;
110
+ return minutes !== undefined && minutes <= C.MAX_PROMISE_MINUTES ? { minutes } : {};
111
+ }
112
+ /**
113
+ * Key of a thread.
114
+ *
115
+ * @param ref - The place
116
+ * @returns `<channel>:<threadTs or ''>`
117
+ */
118
+ export function threadKey(ref) {
119
+ return `${ref.slackChannelId}:${ref.threadTs ?? ''}`;
120
+ }
121
+ /**
122
+ * A Slack DM (its top level is the conversation).
123
+ *
124
+ * @param slackChannelId - Channel id
125
+ * @returns True for a DM id
126
+ */
127
+ function isDm(slackChannelId) {
128
+ return slackChannelId.startsWith('D');
129
+ }
130
+ /**
131
+ * Clip text to one line.
132
+ *
133
+ * @param text - Text
134
+ * @param max - Limit
135
+ * @returns Clipped text
136
+ */
137
+ function clip(text, max = C.QUOTE_CHARS) {
138
+ const one = (text ?? '').replace(/\s+/g, ' ').trim();
139
+ return one.length > max ? `${one.slice(0, max - 1)}…` : one;
140
+ }
141
+ /**
142
+ * The status line for an event, in plain English (harness text).
143
+ *
144
+ * @param event - What happened
145
+ * @param name - Agent display name
146
+ * @returns The line, or null when the event posts nothing
147
+ */
148
+ export function statusLineFor(event, name) {
149
+ const link = (url, label) => (url ? ` — <${url}|${label}>` : '');
150
+ switch (event.kind) {
151
+ case 'card_posted':
152
+ return `⏳ ${name} is waiting for your OK: ${clip(event.question)}${link(event.link, 'Approve here')}`;
153
+ case 'card_expired':
154
+ return event.browser
155
+ ? `⚠️ ${name}'s approval request expired before you answered${event.why ? ` (${event.why})` : ''}. ${name} is asking again — the new card will show up here.`
156
+ : `⚠️ ${name}'s question expired before you answered: ${clip(event.question)}${link(event.link, 'see the card')}. Reply here if it is still needed.`;
157
+ case 'card_parked':
158
+ return `⏸ ${name} is still waiting for your OK and has parked this until you answer: ${clip(event.question)}${link(event.link, 'Answer here')}`;
159
+ case 'card_settled':
160
+ return null;
161
+ case 'work_blocked':
162
+ return `⏸ ${name} is blocked${event.reason ? `: ${clip(event.reason)}` : '.'} Your request is kept.`;
163
+ case 'stopped':
164
+ if (event.why === 'slot')
165
+ return `⏸ ${name} was paused to free a slot for another agent; it resumes automatically when one frees (your request is kept).`;
166
+ return `⏸ ${name} was stopped to save memory while it looked idle; your request is kept and ${name} is woken when you reply here or its work moves.`;
167
+ case 'start_failed':
168
+ return `⚠️ ${name} could not start${event.detail ? `: ${clip(event.detail)}` : '.'} Your request is kept — reply here to try again.`;
169
+ case 'start_deferred':
170
+ return `⏳ ${name} is queued to start: this machine is at its running-agent limit. It starts when a slot frees (your request is kept).`;
171
+ case 'delivery_held': {
172
+ const why = event.why === 'unreadable'
173
+ ? "Crewly can't read its input box and is recovering it"
174
+ : event.why === 'foreign'
175
+ ? 'its input box has text Crewly did not type — check its terminal'
176
+ : event.why === 'busy'
177
+ ? 'it has looked busy for a long time and may be stuck'
178
+ : 'its input is blocked';
179
+ return `⚠️ Messages to ${name} are on hold: ${why}. Your request is kept.`;
180
+ }
181
+ }
182
+ }
183
+ /** State id of an event, for dedupe. */
184
+ function stateOf(event) {
185
+ switch (event.kind) {
186
+ case 'card_posted':
187
+ case 'card_expired':
188
+ case 'card_parked':
189
+ case 'card_settled':
190
+ return `${event.kind}:${event.decisionId}`;
191
+ case 'stopped':
192
+ return `stopped:${event.why}`;
193
+ case 'delivery_held':
194
+ return `held:${event.why}`;
195
+ default:
196
+ return event.kind;
197
+ }
198
+ }
199
+ /** Events the owner must act on: never held back by the info gap. */
200
+ function isActionable(event) {
201
+ return event.kind === 'card_posted' || event.kind === 'card_expired' || event.kind === 'card_parked' || event.kind === 'start_failed';
202
+ }
203
+ /**
204
+ * Watches owner threads and posts one status line per blocking event.
205
+ */
206
+ export class OwnerThreadSentinelService {
207
+ deps;
208
+ logger;
209
+ entries = new Map();
210
+ timer = null;
211
+ ticking = false;
212
+ /**
213
+ * @param deps - Injected behaviour
214
+ */
215
+ constructor(deps) {
216
+ this.deps = deps;
217
+ this.logger = LoggerService.getInstance().createComponentLogger('OwnerThreadSentinel');
218
+ this.load();
219
+ }
220
+ now() {
221
+ return this.deps.now ? this.deps.now() : Date.now();
222
+ }
223
+ nameOf(agent) {
224
+ return this.deps.displayNameOf?.(agent) ?? agent;
225
+ }
226
+ /** Start the periodic evaluation. */
227
+ start() {
228
+ if (this.timer)
229
+ return;
230
+ this.timer = setInterval(() => void this.tick(), C.TICK_MS);
231
+ this.timer.unref?.();
232
+ }
233
+ /** Stop the periodic evaluation. */
234
+ stop() {
235
+ if (this.timer)
236
+ clearInterval(this.timer);
237
+ this.timer = null;
238
+ }
239
+ /** @returns Watched threads (debug, tests) */
240
+ list() {
241
+ return [...this.entries.values()].map((e) => ({ ...e, cards: [...e.cards], recentStates: { ...e.recentStates } }));
242
+ }
243
+ // -------------------------------------------------------------------------
244
+ // Inbound
245
+ // -------------------------------------------------------------------------
246
+ /**
247
+ * The owner wrote in a Slack thread / DM and `agent` must answer.
248
+ *
249
+ * @param input - Where, who must answer, when
250
+ */
251
+ noteOwnerMessage(input) {
252
+ if (!input.slackChannelId || !input.agent)
253
+ return;
254
+ const at = input.at ?? this.now();
255
+ const entry = this.ensure({ slackChannelId: input.slackChannelId, threadTs: input.threadTs }, input.agent);
256
+ entry.agent = input.agent;
257
+ entry.ownerAt = Math.max(entry.ownerAt ?? 0, at);
258
+ entry.updatedAt = this.now();
259
+ this.persist();
260
+ }
261
+ /**
262
+ * An agent posted in Slack. In a watched thread the post resets the
263
+ * status state, and a promise ("~15 min") is remembered and timed.
264
+ *
265
+ * @param input - Who, where, what
266
+ */
267
+ noteAgentPost(input) {
268
+ const entry = this.find(input);
269
+ if (!entry)
270
+ return;
271
+ // The orchestrator routing or relaying in a thread an agent owns ("Atlas
272
+ // is on it") neither takes the thread over nor settles the agent's promise.
273
+ if (input.agent === ORCHESTRATOR_SESSION_NAME && entry.agent !== ORCHESTRATOR_SESSION_NAME)
274
+ return;
275
+ const at = input.at ?? this.now();
276
+ entry.agent = input.agent;
277
+ entry.agentPostAt = at;
278
+ entry.agentPreview = clip(input.text, 120);
279
+ entry.lastStatus = undefined;
280
+ entry.recentStates = {};
281
+ entry.restartPending = false;
282
+ const promise = detectPromise(input.text) ?? (input.interim ? {} : null);
283
+ if (promise) {
284
+ const minutes = promise.minutes;
285
+ entry.promise = {
286
+ at,
287
+ ...(minutes !== undefined ? { minutes, deadlineAt: at + Math.max(C.MIN_DEADLINE_MS, minutes * C.DEADLINE_FACTOR * 60_000) } : {}),
288
+ };
289
+ }
290
+ else {
291
+ entry.promise = undefined;
292
+ entry.workOrigin = false;
293
+ }
294
+ entry.updatedAt = at;
295
+ this.persist();
296
+ }
297
+ /**
298
+ * Something blocked `agent`: post one status line in each owner thread
299
+ * waiting on it (deduped).
300
+ *
301
+ * @param agent - The agent
302
+ * @param event - What happened
303
+ * @returns How many lines were posted
304
+ */
305
+ async noteBlocking(agent, event) {
306
+ if (!agent)
307
+ return 0;
308
+ if (event.kind === 'card_settled') {
309
+ let changed = false;
310
+ for (const e of this.entries.values()) {
311
+ if (e.agent !== agent)
312
+ continue;
313
+ const before = e.cards.length;
314
+ e.cards = e.cards.filter((c) => c.id !== event.decisionId);
315
+ if (e.reask?.decisionId === event.decisionId)
316
+ e.reask = undefined;
317
+ if (e.cards.length !== before) {
318
+ changed = true;
319
+ e.cardClosedAt = this.now();
320
+ }
321
+ }
322
+ if (changed)
323
+ this.persist();
324
+ return 0;
325
+ }
326
+ const targets = await this.targetsFor(agent, event);
327
+ if (targets.length === 0)
328
+ return 0;
329
+ const now = this.now();
330
+ let posted = 0;
331
+ for (const entry of targets) {
332
+ if (event.kind === 'card_posted') {
333
+ if (!entry.cards.some((c) => c.id === event.decisionId))
334
+ entry.cards.push({ id: event.decisionId, at: now });
335
+ // The agent asked again: no re-ask nudge.
336
+ if (entry.reask && !entry.reask.done)
337
+ entry.reask = undefined;
338
+ // The card is in this very thread: the card is the status.
339
+ if (event.place && threadKey(event.place) === entry.key) {
340
+ entry.lastStatus = { state: stateOf(event), at: now };
341
+ entry.recentStates[stateOf(event)] = now;
342
+ entry.updatedAt = now;
343
+ continue;
344
+ }
345
+ }
346
+ if (event.kind === 'card_expired' || event.kind === 'card_parked') {
347
+ entry.cards = entry.cards.filter((c) => c.id !== event.decisionId);
348
+ entry.cardClosedAt = now;
349
+ if (event.kind === 'card_expired' && event.browser) {
350
+ entry.reask = { decisionId: event.decisionId, dueAt: now + C.REASK_GRACE_MS };
351
+ }
352
+ }
353
+ // A card already tells the owner why the agent waits; its answer wakes it.
354
+ if ((event.kind === 'work_blocked' || event.kind === 'stopped') && entry.cards.length > 0)
355
+ continue;
356
+ const text = statusLineFor(event, this.nameOf(agent));
357
+ if (text && (await this.emit(entry, stateOf(event), text, isActionable(event))))
358
+ posted++;
359
+ entry.updatedAt = now;
360
+ }
361
+ this.persist();
362
+ return posted;
363
+ }
364
+ /**
365
+ * Whether the agent has an unanswered owner message or an open promise in
366
+ * an owner thread within RECENT_PROMISE_MS — slot freeing and idle stops
367
+ * spare it when another candidate exists.
368
+ *
369
+ * @param agent - The agent
370
+ * @returns True when it owes an owner thread right now
371
+ */
372
+ owesRecently(agent) {
373
+ const now = this.now();
374
+ for (const e of this.entries.values()) {
375
+ if (e.agent !== agent)
376
+ continue;
377
+ if (e.ownerAt && (!e.agentPostAt || e.ownerAt > e.agentPostAt) && now - e.ownerAt <= C.RECENT_PROMISE_MS)
378
+ return true;
379
+ if (e.promise && now - e.promise.at <= C.RECENT_PROMISE_MS)
380
+ return true;
381
+ }
382
+ return false;
383
+ }
384
+ /**
385
+ * The owner thread `agent` owes right now (the newest one active within
386
+ * CARD_THREAD_MAX_AGE_MS), for placing a card it asks.
387
+ *
388
+ * @param agent - The agent
389
+ * @returns The thread, or null
390
+ */
391
+ ownerThreadFor(agent) {
392
+ const now = this.now();
393
+ const mine = [...this.entries.values()]
394
+ .filter((e) => e.agent === agent && owes(e, now) && now - e.updatedAt <= C.CARD_THREAD_MAX_AGE_MS)
395
+ .sort((a, b) => b.updatedAt - a.updatedAt);
396
+ const e = mine[0];
397
+ return e ? { slackChannelId: e.slackChannelId, ...(e.threadTs ? { threadTs: e.threadTs } : {}) } : null;
398
+ }
399
+ /**
400
+ * Agents an owner thread waits on: a promised follow-up, an unanswered
401
+ * owner message, or an open card (boot restore treats them as work in hand).
402
+ *
403
+ * @returns Session names
404
+ */
405
+ owingAgents() {
406
+ const now = this.now();
407
+ return [...new Set([...this.entries.values()].filter((e) => owes(e, now)).map((e) => e.agent))];
408
+ }
409
+ // -------------------------------------------------------------------------
410
+ // Tick
411
+ // -------------------------------------------------------------------------
412
+ /**
413
+ * One pass: restart lines, overdue promises, browser re-asks, pruning.
414
+ *
415
+ * @returns How many lines / nudges went out
416
+ */
417
+ async tick() {
418
+ if (this.ticking)
419
+ return 0;
420
+ this.ticking = true;
421
+ let acted = 0;
422
+ try {
423
+ const now = this.now();
424
+ for (const entry of [...this.entries.values()]) {
425
+ if (now - entry.updatedAt > C.FORGET_AFTER_MS && entry.cards.length === 0) {
426
+ this.entries.delete(entry.key);
427
+ continue;
428
+ }
429
+ if (entry.restartPending) {
430
+ entry.restartPending = false;
431
+ const name = this.nameOf(entry.agent);
432
+ if (await this.emit(entry, 'restarted', `🔄 Crewly restarted while ${name} was on this. Your request is kept; ${name} picks it up when it is back — reply here if you need it sooner.`, false))
433
+ acted++;
434
+ }
435
+ acted += await this.checkDeadline(entry, now);
436
+ acted += await this.checkReask(entry, now);
437
+ }
438
+ this.persist();
439
+ }
440
+ catch (err) {
441
+ this.logger.warn('Owner thread sentinel tick failed', { error: err instanceof Error ? err.message : String(err) });
442
+ }
443
+ finally {
444
+ this.ticking = false;
445
+ }
446
+ return acted;
447
+ }
448
+ /** A promise of "~N min" past N×1.5 with no post: say what the agent is doing, nudge it once. */
449
+ async checkDeadline(entry, now) {
450
+ const p = entry.promise;
451
+ if (!p?.deadlineAt || p.firedAt || now < p.deadlineAt)
452
+ return 0;
453
+ if (entry.agentPostAt !== undefined && entry.agentPostAt > p.at)
454
+ return 0;
455
+ // Waiting on the owner (a card is open), or a card just closed / a re-ask
456
+ // is in flight: the owner already has a line about it; the clock waits.
457
+ if (entry.cards.length > 0)
458
+ return 0;
459
+ if (entry.cardClosedAt !== undefined && now - entry.cardClosedAt < C.REPEAT_STATE_MS)
460
+ return 0;
461
+ p.firedAt = now;
462
+ const name = this.nameOf(entry.agent);
463
+ const ago = Math.max(1, Math.round((now - p.at) / 60_000));
464
+ const activity = await this.deps.currentActivity?.(entry.agent).catch(() => null);
465
+ const line = `⏱ ${name} said ~${p.minutes} min, ${ago} min ago, and hasn't posted since. ` +
466
+ `Right now: ${activity ? clip(activity) : `no update from ${name}`}. I've asked ${name} for an update here.`;
467
+ let acted = 0;
468
+ if (await this.emit(entry, `deadline:${p.at}`, line, true))
469
+ acted++;
470
+ const ref = entry.threadTs ? `${entry.slackChannelId}:${entry.threadTs}` : entry.slackChannelId;
471
+ const nudge = `[OWNER THREAD ${ref}] You told the owner "~${p.minutes} min" ${ago} min ago in this Slack thread and have not posted there since. ` +
472
+ `Post a short update there now: reply --thread ${ref} "<what is done, what is left, when>". If you are blocked, say on what.`;
473
+ if (await this.deps.nudgeAgent(entry.agent, nudge).catch(() => false))
474
+ acted++;
475
+ this.logger.info('Owner thread promise overdue — status posted, agent nudged', { key: entry.key, agent: entry.agent, minutes: p.minutes });
476
+ return acted;
477
+ }
478
+ /** A browser approval expired and the agent did not ask again: nudge it once. */
479
+ async checkReask(entry, now) {
480
+ const r = entry.reask;
481
+ if (!r || r.done || now < r.dueAt)
482
+ return 0;
483
+ r.done = true;
484
+ if (!owes(entry, now) && !entry.workOrigin)
485
+ return 0;
486
+ const ref = entry.threadTs ? `${entry.slackChannelId}:${entry.threadTs}` : entry.slackChannelId;
487
+ const text = `[OWNER THREAD ${ref}] Your browser approval card ${r.decisionId} expired before the owner answered, and the owner is still waiting in this thread. ` +
488
+ 'Redo the step now (look at the page again, then retry the action) so a new card is posted — or say in the thread why it is no longer needed.';
489
+ const ok = await this.deps.nudgeAgent(entry.agent, text).catch(() => false);
490
+ this.logger.info('Expired browser approval — agent asked to re-ask once', { key: entry.key, agent: entry.agent, decisionId: r.decisionId, delivered: ok });
491
+ return ok ? 1 : 0;
492
+ }
493
+ // -------------------------------------------------------------------------
494
+ // Internals
495
+ // -------------------------------------------------------------------------
496
+ /** Threads that get a line for this event. */
497
+ async targetsFor(agent, event) {
498
+ const now = this.now();
499
+ if (event.kind === 'work_blocked' && event.thread) {
500
+ const e = this.ensure(event.thread, agent);
501
+ e.agent = agent;
502
+ e.workOrigin = true;
503
+ e.updatedAt = now;
504
+ return [e];
505
+ }
506
+ const work = await this.deps.ownerWorkThread?.(agent).catch(() => null);
507
+ if (work?.slackChannelId) {
508
+ const e = this.ensure(work, agent);
509
+ if (e.agent === agent || !owes(e, now)) {
510
+ e.agent = agent;
511
+ e.workOrigin = true;
512
+ e.updatedAt = Math.max(e.updatedAt, now);
513
+ }
514
+ }
515
+ return [...this.entries.values()]
516
+ .filter((e) => e.agent === agent && (owes(e, now) || (e.workOrigin && now - e.updatedAt <= C.ACTIVE_WINDOW_MS)))
517
+ .sort((a, b) => b.updatedAt - a.updatedAt)
518
+ .slice(0, C.MAX_THREADS_PER_EVENT);
519
+ }
520
+ /** Post a line unless dedupe holds it back. */
521
+ async emit(entry, state, text, actionable) {
522
+ const now = this.now();
523
+ if (entry.lastStatus?.state === state)
524
+ return false;
525
+ const last = entry.recentStates[state];
526
+ if (last !== undefined && now - last < C.REPEAT_STATE_MS)
527
+ return false;
528
+ if (!actionable && entry.lastStatus && now - entry.lastStatus.at < C.MIN_INFO_GAP_MS)
529
+ return false;
530
+ let ok = false;
531
+ try {
532
+ ok = await this.deps.postStatus({ slackChannelId: entry.slackChannelId, ...(entry.threadTs ? { threadTs: entry.threadTs } : {}) }, entry.agent, text);
533
+ }
534
+ catch (err) {
535
+ this.logger.warn('Could not post the owner thread status', { key: entry.key, state, error: err instanceof Error ? err.message : String(err) });
536
+ }
537
+ if (!ok)
538
+ return false;
539
+ entry.lastStatus = { state, at: now };
540
+ entry.recentStates[state] = now;
541
+ this.logger.info('Owner thread status posted', { key: entry.key, agent: entry.agent, state });
542
+ return true;
543
+ }
544
+ /** The entry for a place, created when missing. */
545
+ ensure(ref, agent) {
546
+ const key = threadKey(ref);
547
+ let entry = this.entries.get(key);
548
+ if (!entry) {
549
+ entry = {
550
+ key,
551
+ slackChannelId: ref.slackChannelId,
552
+ ...(ref.threadTs ? { threadTs: ref.threadTs } : {}),
553
+ agent,
554
+ cards: [],
555
+ recentStates: {},
556
+ updatedAt: this.now(),
557
+ };
558
+ this.entries.set(key, entry);
559
+ while (this.entries.size > C.MAX_THREADS) {
560
+ const oldest = this.entries.keys().next().value;
561
+ if (oldest === undefined)
562
+ break;
563
+ this.entries.delete(oldest);
564
+ }
565
+ }
566
+ return entry;
567
+ }
568
+ /** The watched entry a post belongs to (a DM's top-level post: that DM's newest entry). */
569
+ find(ref) {
570
+ const exact = this.entries.get(threadKey(ref));
571
+ if (exact)
572
+ return exact;
573
+ if (!ref.threadTs && isDm(ref.slackChannelId)) {
574
+ return [...this.entries.values()]
575
+ .filter((e) => e.slackChannelId === ref.slackChannelId)
576
+ .sort((a, b) => b.updatedAt - a.updatedAt)[0];
577
+ }
578
+ return undefined;
579
+ }
580
+ persist() {
581
+ const file = this.deps.storePath;
582
+ if (!file)
583
+ return;
584
+ try {
585
+ mkdirSync(path.dirname(file), { recursive: true });
586
+ const tmp = `${file}.tmp`;
587
+ writeFileSync(tmp, JSON.stringify({ entries: [...this.entries.values()] }), { mode: 0o600 });
588
+ renameSync(tmp, file);
589
+ }
590
+ catch (err) {
591
+ this.logger.warn('Could not save the owner thread sentinel state', { error: err instanceof Error ? err.message : String(err) });
592
+ }
593
+ }
594
+ load() {
595
+ const now = this.now();
596
+ for (const e of readEntries(this.deps.storePath)) {
597
+ // Anything active when Crewly went down gets one restart line.
598
+ e.restartPending = owes(e, now) && (!!e.promise || (!!e.ownerAt && (!e.agentPostAt || e.ownerAt > e.agentPostAt)));
599
+ this.entries.set(e.key, e);
600
+ }
601
+ if (this.entries.size > 0)
602
+ this.logger.info('Restored watched owner threads from before the restart', { count: this.entries.size });
603
+ }
604
+ }
605
+ /**
606
+ * Whether a thread waits on its agent: an open card, an unanswered owner
607
+ * message, or a promise — within ACTIVE_WINDOW_MS.
608
+ *
609
+ * @param e - The entry
610
+ * @param now - Clock
611
+ * @returns True while it waits
612
+ */
613
+ function owes(e, now) {
614
+ if (e.cards.length > 0)
615
+ return true;
616
+ if (e.ownerAt && (!e.agentPostAt || e.ownerAt > e.agentPostAt) && now - e.ownerAt <= C.ACTIVE_WINDOW_MS)
617
+ return true;
618
+ if (e.promise && now - e.promise.at <= C.ACTIVE_WINDOW_MS)
619
+ return true;
620
+ return false;
621
+ }
622
+ /**
623
+ * Read persisted entries (tolerates a missing or corrupt file).
624
+ *
625
+ * @param file - Store path
626
+ * @returns Entries
627
+ */
628
+ function readEntries(file) {
629
+ if (!file || !existsSync(file))
630
+ return [];
631
+ try {
632
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
633
+ return (raw.entries ?? []).filter((e) => e && typeof e.key === 'string' && typeof e.slackChannelId === 'string' && typeof e.agent === 'string').map((e) => ({
634
+ ...e,
635
+ cards: Array.isArray(e.cards) ? e.cards : [],
636
+ recentStates: e.recentStates && typeof e.recentStates === 'object' ? e.recentStates : {},
637
+ updatedAt: typeof e.updatedAt === 'number' ? e.updatedAt : 0,
638
+ }));
639
+ }
640
+ catch {
641
+ return [];
642
+ }
643
+ }
644
+ /**
645
+ * Agents an owner thread waits on, read straight from the persisted state —
646
+ * for the boot restore, which can run before the sentinel is started.
647
+ *
648
+ * @param file - Store path
649
+ * @param now - Clock
650
+ * @returns Session names
651
+ */
652
+ export function owingAgentsOnDisk(file, now = Date.now()) {
653
+ return [...new Set(readEntries(file).filter((e) => owes(e, now)).map((e) => e.agent))];
654
+ }
655
+ // ---------------------------------------------------------------------------
656
+ // Singleton
657
+ // ---------------------------------------------------------------------------
658
+ let instance = null;
659
+ /** @returns The wired sentinel, or null before it started (and in tests). */
660
+ export function getOwnerThreadSentinel() {
661
+ return instance;
662
+ }
663
+ /** @param service - The sentinel to expose (null to clear) */
664
+ export function setOwnerThreadSentinel(service) {
665
+ instance = service;
666
+ }
667
+ /**
668
+ * Report a blocking event for an agent, if the sentinel runs. Never throws.
669
+ *
670
+ * @param agent - The agent
671
+ * @param event - What happened
672
+ */
673
+ export function reportOwnerThreadBlocking(agent, event) {
674
+ const s = instance;
675
+ if (!s || !agent)
676
+ return;
677
+ void s.noteBlocking(agent, event).catch(() => undefined);
678
+ }
679
+ /**
680
+ * Report an agent's Slack post, if the sentinel runs. Never throws.
681
+ *
682
+ * @param input - Who, where, what
683
+ */
684
+ export function reportOwnerThreadAgentPost(input) {
685
+ try {
686
+ instance?.noteAgentPost(input);
687
+ }
688
+ catch {
689
+ /* best-effort */
690
+ }
691
+ }
692
+ //# sourceMappingURL=owner-thread-sentinel.service.js.map