@rubytech/create-realagent-code 0.1.575 → 0.1.577

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 (56) hide show
  1. package/dist/__tests__/preserve-house-cloudflare.test.js +2 -72
  2. package/dist/index.js +8 -59
  3. package/dist/preserve-house-cloudflare.js +2 -49
  4. package/package.json +1 -1
  5. package/payload/platform/lib/active-rules/dist/index.d.ts.map +1 -1
  6. package/payload/platform/lib/active-rules/dist/index.js +32 -1
  7. package/payload/platform/lib/active-rules/dist/index.js.map +1 -1
  8. package/payload/platform/lib/active-rules/src/index.test.ts +52 -3
  9. package/payload/platform/lib/active-rules/src/index.ts +36 -2
  10. package/payload/platform/lib/dispatch-read/dist/allocate.d.ts +57 -0
  11. package/payload/platform/lib/dispatch-read/dist/allocate.d.ts.map +1 -0
  12. package/payload/platform/lib/dispatch-read/dist/allocate.js +33 -0
  13. package/payload/platform/lib/dispatch-read/dist/allocate.js.map +1 -0
  14. package/payload/platform/lib/dispatch-read/dist/index.d.ts +2 -0
  15. package/payload/platform/lib/dispatch-read/dist/index.d.ts.map +1 -1
  16. package/payload/platform/lib/dispatch-read/dist/index.js +10 -1
  17. package/payload/platform/lib/dispatch-read/dist/index.js.map +1 -1
  18. package/payload/platform/lib/dispatch-read/src/__tests__/allocate.test.ts +41 -0
  19. package/payload/platform/lib/dispatch-read/src/allocate.ts +89 -0
  20. package/payload/platform/lib/dispatch-read/src/index.ts +14 -0
  21. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +7 -2
  22. package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +8 -2
  23. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +2 -2
  24. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +12 -0
  25. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visit-status-write.test.ts +333 -22
  26. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visits-scope.test.ts +262 -0
  27. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +126 -21
  28. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visit-status.ts +142 -2
  29. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visits.ts +118 -6
  30. package/payload/platform/plugins/dispatch/PLUGIN.md +2 -0
  31. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts +19 -50
  32. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts.map +1 -1
  33. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js +1 -28
  34. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js.map +1 -1
  35. package/payload/platform/plugins/dispatch/skills/dispatch-page/SKILL.md +45 -6
  36. package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.css +60 -0
  37. package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.html +13 -0
  38. package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.js +203 -5
  39. package/payload/platform/plugins/docs/references/internals.md +1 -1
  40. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +5 -0
  41. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  42. package/payload/platform/services/claude-session-manager/dist/http-server.js +29 -0
  43. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  44. package/payload/platform/services/telegram-channel/dist/notification.d.ts +35 -3
  45. package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
  46. package/payload/platform/services/telegram-channel/dist/notification.js +38 -5
  47. package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
  48. package/payload/platform/services/telegram-channel/dist/server.js +1 -1
  49. package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
  50. package/payload/platform/services/webchat-channel/dist/notification.d.ts +2 -1
  51. package/payload/platform/services/webchat-channel/dist/notification.d.ts.map +1 -1
  52. package/payload/platform/services/webchat-channel/dist/notification.js.map +1 -1
  53. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +2 -2
  54. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  55. package/payload/server/server.js +670 -155
  56. package/dist/__tests__/converge-client-admins.test.js +0 -50
@@ -114,20 +114,133 @@
114
114
  column order above. */
115
115
  var HOUR_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
116
116
 
117
- /* One visit. Clicking moves it to the next declared status and posts that
118
- through the portal's existing write door. Which transitions are legal is
119
- Task 2511's; this cycles the declared list. */
120
- function visitButton(v, meta, zone) {
117
+ /* Whether the account declared this move (Task 2511).
118
+
119
+ Absent from the published list means refused, and the list travels with the
120
+ week, so a drag is refused with the install offline. `visit-status.ts`
121
+ refuses it a second time from the same list; that refusal is the binding
122
+ one, and this one exists so the operator is told now rather than in sixty
123
+ seconds. An unchanged status is NOT a move, which is the same rule the door
124
+ applies: `[from, to]` declares a move and no account declares a
125
+ self-transition, so without this every pure re-timing would be refused. */
126
+ function declared(meta, from, to) {
127
+ if (from === to) return true;
128
+ var list = (meta && meta.transitions) || [];
129
+ for (var i = 0; i < list.length; i++) {
130
+ if (list[i][0] === from && list[i][1] === to) return true;
131
+ }
132
+ return false;
133
+ }
134
+
135
+ /* The same wall-clock time on a different day, IN THE ACCOUNT'S ZONE.
136
+
137
+ Built by asking the account's own formatter what instant renders as the
138
+ wanted wall time, never from the browser's offset: a page that guesses the
139
+ zone re-times a visit to a plausible wrong hour, which is the Task 2280
140
+ defect. Two passes, because the offset either side of a daylight-saving
141
+ transition differs and the first correction can land on the far side of it.
142
+
143
+ Returns the shape the door admits, which is what `toISOString` produces. */
144
+ function retimed(iso, dayKey, zone) {
145
+ var wall = new Intl.DateTimeFormat('en-GB', {
146
+ timeZone: zone,
147
+ hour: '2-digit',
148
+ minute: '2-digit',
149
+ hour12: false,
150
+ });
151
+ var held = wall.format(new Date(iso));
152
+ var guess = new Date(dayKey + 'T' + held + ':00.000Z');
153
+ for (var i = 0; i < 2; i++) {
154
+ var seen = wall.format(guess);
155
+ if (seen === held) break;
156
+ var drift =
157
+ (Number(held.slice(0, 2)) - Number(seen.slice(0, 2))) * 60 +
158
+ (Number(held.slice(3)) - Number(seen.slice(3)));
159
+ guess = new Date(guess.getTime() + drift * 60000);
160
+ }
161
+ return guess.toISOString();
162
+ }
163
+
164
+ /* One visit. Clicking moves it to the next declared status; dragging re-times
165
+ or re-assigns it. Both post through the portal's existing write door, and
166
+ both are refused here first against the published transition list. */
167
+ function visitButton(v, data, meta, zone) {
121
168
  var b = document.createElement('button');
122
169
  b.type = 'button';
123
170
  b.className = 'pl-visit';
171
+ b.draggable = true;
124
172
  b.textContent = [timeOf(v.startDate, zone), v.status, v.purpose].filter(Boolean).join(' · ');
125
173
  b.addEventListener('click', function () {
126
174
  advance(v, meta);
127
175
  });
176
+ /* The panel is a second gesture on the same card, so the click keeps
177
+ meaning what it meant before this task. */
178
+ b.addEventListener('contextmenu', function (e) {
179
+ e.preventDefault();
180
+ openVisit(v, data, meta, zone);
181
+ });
182
+ b.addEventListener('dragstart', function (e) {
183
+ dragging = v;
184
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
185
+ });
186
+ b.addEventListener('dragend', function () {
187
+ dragging = null;
188
+ });
128
189
  return b;
129
190
  }
130
191
 
192
+ /* The visit under the pointer during a drag. Held here rather than in
193
+ dataTransfer, which only carries strings and would mean looking the visit
194
+ back up by id on every drop. */
195
+ var dragging = null;
196
+
197
+ function move(v, meta, zone, toOwnerId, toDayKey) {
198
+ var body = { visitId: v.visitId, status: v.status };
199
+ if (toOwnerId !== v.ownerId) body.assignedOwnerId = toOwnerId;
200
+ if (toDayKey !== dayOf(v.startDate, zone)) {
201
+ body.startDate = retimed(v.startDate, toDayKey, zone);
202
+ }
203
+ /* A drop that changes neither is not a write. */
204
+ if (body.assignedOwnerId === undefined && body.startDate === undefined) return;
205
+ say('Saving…');
206
+ api('/api/visit-status', {
207
+ method: 'POST',
208
+ headers: { 'content-type': 'application/json' },
209
+ body: JSON.stringify(body),
210
+ })
211
+ .then(function (r) {
212
+ return r.json().then(function (d) {
213
+ return { s: r.status, d: d };
214
+ });
215
+ })
216
+ .then(function (res) {
217
+ if (!res.d.ok) {
218
+ console.error('[planner] move refused', { status: res.s, error: res.d.error });
219
+ say(
220
+ res.s === 404
221
+ ? 'That is not yours to change.'
222
+ : res.d.error === 'illegal-transition'
223
+ ? 'That move is not one this account allows.'
224
+ : 'That change was refused.',
225
+ );
226
+ /* The card returns to origin by redrawing what the device last
227
+ published. Leaving it where it fell would be the page telling the
228
+ operator something happened that did not. */
229
+ if (latest) render(latest);
230
+ return;
231
+ }
232
+ if (body.assignedOwnerId !== undefined) v.ownerId = body.assignedOwnerId;
233
+ if (body.startDate !== undefined) v.startDate = body.startDate;
234
+ say('Saved. It reaches the office within a minute.');
235
+ if (latest) render(latest);
236
+ })
237
+ .catch(function (err) {
238
+ console.error('[planner] move failed', { status: 'network', error: err });
239
+ say('Could not reach the server.');
240
+ if (latest) render(latest);
241
+ });
242
+ }
243
+
131
244
  function advance(v, meta) {
132
245
  var names = ((meta && meta.statuses) || []).map(function (s) {
133
246
  return s.name;
@@ -137,6 +250,10 @@
137
250
  return;
138
251
  }
139
252
  var next = names[(names.indexOf(v.status) + 1) % names.length];
253
+ if (!declared(meta, v.status, next)) {
254
+ say('That move is not one this account allows.');
255
+ return;
256
+ }
140
257
  say('Saving…');
141
258
  api('/api/visit-status', {
142
259
  method: 'POST',
@@ -169,6 +286,60 @@
169
286
  });
170
287
  }
171
288
 
289
+ /* Who may be sent to this record, why the others may not, and what has been
290
+ recorded against it.
291
+
292
+ Rendered from what /api/visits returned and nothing else. The install
293
+ computed the offers and the exclusions with the one implementation of
294
+ qualification and clash; re-deciding any of it here would be a second rule
295
+ to get wrong, which is what `functions/api/visits.ts` rules out. A
296
+ candidate carries no travel estimate today and is rendered without one
297
+ rather than dropped. */
298
+ function openVisit(v, data, meta, zone) {
299
+ var mine = (data.candidates || []).filter(function (c) {
300
+ return c.visitId === v.visitId;
301
+ });
302
+ document.getElementById('pl-allocate-head').textContent =
303
+ [timeOf(v.startDate, zone), v.status, v.purpose].filter(Boolean).join(' · ');
304
+
305
+ var list = document.getElementById('pl-allocate-list');
306
+ list.replaceChildren();
307
+ if (!mine.length) {
308
+ /* Never an empty box. Nobody offered and nobody excluded is a different
309
+ fact from an unstaffable record, and the operator cannot tell them
310
+ apart from a blank panel. */
311
+ var none = cell('pl-candidate', 'Nobody has been offered for this, and nobody was excluded.');
312
+ none.setAttribute('data-kind', 'excluded');
313
+ list.appendChild(none);
314
+ }
315
+ mine.forEach(function (c) {
316
+ var who = c.name || c.workerId;
317
+ var note =
318
+ c.kind === 'excluded'
319
+ ? c.reason
320
+ : (c.conflicts || []).length
321
+ ? (c.conflicts || []).length + ' already booked in this window'
322
+ : 'free';
323
+ var li = document.createElement('li');
324
+ li.className = 'pl-candidate';
325
+ li.setAttribute('data-kind', c.kind);
326
+ li.textContent = who + ' · ' + note;
327
+ list.appendChild(li);
328
+ });
329
+
330
+ /* What the worker recorded, as counts. The contents themselves are files,
331
+ and the portal's own file exchange already carries them. */
332
+ document.getElementById('pl-record').textContent = [
333
+ 'notes ' + (v.notes || 0),
334
+ 'media ' + (v.media || 0),
335
+ 'parts ' + (v.parts || 0),
336
+ 'checklist ' + (v.checklistDone || 0) + '/' + (v.checklistTotal || 0),
337
+ 'signatures ' + (v.signatures || 0),
338
+ ].join(' · ');
339
+
340
+ document.getElementById('pl-allocate').hidden = false;
341
+ }
342
+
172
343
  function render(data) {
173
344
  var meta = data.meta;
174
345
  /* UTC when the account published no config. Never the browser's: a page
@@ -225,12 +396,35 @@
225
396
  'data-off',
226
397
  w.workingHours && w.workingHours[HOUR_KEYS[i]] ? 'false' : 'true',
227
398
  );
399
+ /* Every cell is a drop target, including a day the worker does not
400
+ work: the office books into one deliberately, and a cell that
401
+ refused the drop would make that decision for them. A row with no
402
+ configured worker is not a target, because there is no ownerId to
403
+ re-assign to. */
404
+ if (w.workerId) {
405
+ c.addEventListener('dragover', function (e) {
406
+ if (!dragging) return;
407
+ e.preventDefault();
408
+ c.setAttribute('data-over', 'true');
409
+ });
410
+ c.addEventListener('dragleave', function () {
411
+ c.removeAttribute('data-over');
412
+ });
413
+ c.addEventListener('drop', function (e) {
414
+ e.preventDefault();
415
+ c.removeAttribute('data-over');
416
+ if (!dragging) return;
417
+ var v = dragging;
418
+ dragging = null;
419
+ move(v, meta, zone, w.ownerId, k);
420
+ });
421
+ }
228
422
  (byOwner[w.ownerId] || [])
229
423
  .filter(function (v) {
230
424
  return dayOf(v.startDate, zone) === k;
231
425
  })
232
426
  .forEach(function (v) {
233
- c.appendChild(visitButton(v, meta, zone));
427
+ c.appendChild(visitButton(v, data, meta, zone));
234
428
  });
235
429
  gridEl.appendChild(c);
236
430
  });
@@ -316,6 +510,10 @@
316
510
  });
317
511
  });
318
512
 
513
+ document.getElementById('pl-allocate-close').addEventListener('click', function () {
514
+ document.getElementById('pl-allocate').hidden = true;
515
+ });
516
+
319
517
  document.getElementById('pl-prev').addEventListener('click', function () {
320
518
  anchor.setUTCDate(anchor.getUTCDate() - 7);
321
519
  if (latest) render(latest);
@@ -406,7 +406,7 @@ Standing rules the operator gives over a channel are `Preference` nodes. Two mec
406
406
 
407
407
  **Reconcile by meaning on write (`profile-update`).** The existence check is two-tier. First an exact `(category, key)` match — the classic fast path, unchanged. When that misses, a semantic-neighbour lookup runs over the `preference_embedding` vector index, scoped to the same `accountId` and `scope:'admin'`, non-trashed. If the nearest neighbour is at or above `SAME_SUBJECT_COSINE` (0.85), the write lands on that node through the existing reinforce/update/contradict curve instead of minting a duplicate; the matched neighbours are returned in the tool result so the agent can classify future writes against real prior state. The confidence constants are unchanged — only the join key widens, so a rephrasing under a new `key` no longer forks the rule and a correction demotes the prior node rather than adding a contradictory one.
408
408
 
409
- **Inject the active rule-set every turn (channel gateways).** The one definition of "this account's active rules" is `@maxy/active-rules`: the account owner's admin-scope preferences (resolve the seeded `AdminUser {role:'owner'}`, then select through that owner's `UserProfile` via `HAS_PREFERENCE`), a floor-filtered (`confidence >= 0.4`), confidence-ordered query that returns every such preference verbatim — no cap, no de-confliction, no candidate limit. Concision is enforced at write time by the `conversational-memory` skill (each stored value is a single minimal imperative) and by the write-time semantic reconcile (a same-subject write at or above `SAME_SUBJECT_COSINE` folds into the existing node rather than minting a duplicate), so the injected block stays small because each stored preference is short and non-redundant, not because injection truncates. A preference owned by a non-owner profile (an operator's, stranded on the operator's own profile while managing the account) is never injected; an account with no seeded owner injects nothing. `profile-read` exposes this same owner-scoped set as `activeRules`, and the three inbound gateways (`wa-gateway`, `telegram-gateway`, `webchat-gateway`) fetch it for the effective session account and attach it as `standingRules` on the inbound frame; the channel builders prepend it as a `## Standing rules` block above `## Context`. Both channel-server inbound paths carry the block: the single-target SSE loop parses the whole frame, and the unified multi-target door reader (`runDoorStream`,) reconstructs it through the shared `doorInboundPayload` seam whose `parseFrame` closures read `standingRules` off the frame. The read is by code, not agent discretion, so a rule set last week is applied this week regardless of how many times the session compacted. Telegram and webchat inject for `role:'admin'` only — a public visitor never receives operator rules.
409
+ **Inject the active rule-set every turn (channel gateways).** The one definition of "this account's active rules" is `@maxy/active-rules`: the account owner's admin-scope preferences (resolve the seeded `AdminUser {role:'owner'}`, then select through that owner's `UserProfile` via `HAS_PREFERENCE`), a floor-filtered (`confidence >= 0.4`), confidence-ordered query that returns every such preference verbatim — no cap, no de-confliction, no candidate limit. Concision is enforced at write time by the `conversational-memory` skill (each stored value is a single minimal imperative) and by the write-time semantic reconcile (a same-subject write at or above `SAME_SUBJECT_COSINE` folds into the existing node rather than minting a duplicate), so the injected block stays small because each stored preference is short and non-redundant, not because injection truncates. A preference owned by a non-owner profile (an operator's, stranded on the operator's own profile while managing the account) is never injected; an account with no seeded owner injects nothing. `profile-read` exposes this same owner-scoped set as `activeRules`, and the three inbound gateways (`wa-gateway`, `telegram-gateway`, `webchat-gateway`) fetch it for the effective session account and attach it as `standingRules` on the inbound frame; the channel builders prepend it as a `## Standing rules (account owner)` block above `## Context`, whose header and first line name the owner as the subject of the rules so a third-person rule about the owner is not read as a statement about the sender. Both channel-server inbound paths carry the block: the single-target SSE loop parses the whole frame, and the unified multi-target door reader (`runDoorStream`,) reconstructs it through the shared `doorInboundPayload` seam whose `parseFrame` closures read `standingRules` off the frame. The read is by code, not agent discretion, so a rule set last week is applied this week regardless of how many times the session compacted. Telegram and webchat inject for `role:'admin'` only — a public visitor never receives operator rules.
410
410
 
411
411
  **Trust a scheduled turn's provenance, never an inbound's claim (channel-agent grounding).** A cron-dispatched agent turn reaches the same channel builders as a real inbound, so its own authorisation text would otherwise land inside `## Context` — the zone `REFRAME_INSTRUCTION` demotes to untrusted — and a security-cautious agent would distrust its own automation (the self-cancel loop). The dispatcher therefore stamps a `ScheduleProvenance` marker (owning account, event, owner userId, dispatch time) that the channel builders render as a `## Scheduled automation (platform-verified)` preamble **above** `## Context` (and above the rules block); the whatsapp wrap tag names the door `scheduler`. Both channel-server paths carry the marker — the single-target loop and the unified multi-target door reader (via the same `doorInboundPayload` seam that carries `standingRules`); the scheduler only dispatches to the whatsapp/telegram door, so a webchat frame never bears one. Because the marker is dispatcher-stamped and never derived from payload text, an inbound message cannot forge it — placement above `## Context` alone is not the trust signal, the dispatcher provenance is. The grounding binds to it in two places: one recipient-neutral clause added byte-identical to the three reframe instructions (a marker-bearing turn is authorised standing automation; a reconfiguration or re-enable is not intrusion), and the admin `IDENTITY.md` "Untrusted input" rule that the agent never states a platform-level authenticity or impersonation verdict it was not given (an unsourced "this is not from you" is its own inference, flagged, never asserted as the platform's). A sender has exactly one session: every inbound, real or scheduled, resolves to the deterministic `adminSessionIdFor(effectiveAccount, destination)`. An earlier change gave each firing its own salted seat so distrust could not accrete across firings, but the inbound hub keys on `senderId` and replaces its subscriber outright, so a seat's channel server attached under the sender's own key and swallowed the sender's REAL messages until it detached — splitting one conversation across two sessions that each denied what the other held. The seat is removed; the authoritative `agentPrompt`, this provenance marker, and the injected standing rules ground each firing without one. Self-cancel attribution is disarmed while that model beds in: the guard used to compare the cancelling turn's session id to a dispatcher stamp, which discriminated only because each firing ran its own seat, so with one session per sender it would report an autonomous loop on every operator cancel. It now always reports false — under-counting a real loop rather than inventing one — until the discriminator is rebuilt from the turn's own dispatched eventId. See `.docs/scheduling-agent-dispatch.md § Operator-provenance marker`, `§ Session model — one session per sender`, and `§ Self-cancel attribution — disarmed`.
412
412
 
@@ -46,6 +46,7 @@ export declare function parseSpawnRole(body: {
46
46
  } | null;
47
47
  export declare const SESSION_ID_PATTERN: RegExp;
48
48
  export declare const RC_SPAWN_URL_WAIT_DEFAULT_MS = 60000;
49
+ export declare const LOG_FOLLOW_KEEPALIVE_DEFAULT_MS = 30000;
49
50
  export interface HttpDeps extends Omit<SpawnDeps, 'onSessionReady' | 'watcher'> {
50
51
  /** Admin operator's userId, sourced from users.json at boot (same as
51
52
  * RcDaemonDeps.userId). Passed to buildRcChildEnv so the scheduling MCP
@@ -85,6 +86,10 @@ export interface HttpDeps extends Omit<SpawnDeps, 'onSessionReady' | 'watcher'>
85
86
  * omits this and gets RC_SPAWN_URL_WAIT_DEFAULT_MS (60s); tests inject a
86
87
  * short value to exercise the bind-timeout response without a 60s wait. */
87
88
  rcSpawnUrlWaitMs?: number;
89
+ /** Task 2557 — idle gap after which `/log?follow=1` writes a bare newline.
90
+ * Production omits this and gets LOG_FOLLOW_KEEPALIVE_DEFAULT_MS (30s);
91
+ * tests inject a short value to exercise the keepalive without a 30s wait. */
92
+ logFollowKeepaliveMs?: number;
88
93
  /** Task 1749 — accessor for the standing PTY census's most recent snapshot,
89
94
  * wired from `createPtyCensus(...).latest`. Optional so the pre-1749 test
90
95
  * deps factories keep working; production index.ts always injects it.
@@ -1 +1 @@
1
- {"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"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"}
@@ -207,6 +207,17 @@ export const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
207
207
  // Injectable via `HttpDeps.rcSpawnUrlWaitMs` so the timeout path is
208
208
  // unit-testable without a real 60s wait.
209
209
  export const RC_SPAWN_URL_WAIT_DEFAULT_MS = 60_000;
210
+ // Task 2557 — how long a follow stream may go without writing a byte before it
211
+ // emits a bare newline. The stream writes only when the JSONL grows, and Node's
212
+ // global fetch (undici) kills a response body after 300s with no bytes, so a
213
+ // conversation between messages killed its own reader: 129 of 129
214
+ // log-follow-close lines on laptop 192.168.88.10 read reason=aborted, shortest
215
+ // lifetime 313440ms, and no follower in the whole log history outlived a quiet
216
+ // patch. 30s costs one byte and leaves a tenfold margin against a slow tick.
217
+ // A genuinely stalled manager still trips undici's bound, because a stalled
218
+ // manager writes no keepalive either. Injectable via
219
+ // `HttpDeps.logFollowKeepaliveMs` so the path is testable without a 30s wait.
220
+ export const LOG_FOLLOW_KEEPALIVE_DEFAULT_MS = 30_000;
210
221
  const LIST_TAIL_BYTES = 64 * 1024;
211
222
  function timed(logger, method, path, status, ms) {
212
223
  logger(`api method=${method} path=${path} outcome=${status} ms=${ms}`);
@@ -2534,6 +2545,10 @@ export function buildHttpApp(deps) {
2534
2545
  let position = 0;
2535
2546
  let linesStreamed = 0;
2536
2547
  let closeReason = 'end';
2548
+ // Task 2557 — when this stream last put a byte on the wire. Every real
2549
+ // write stamps it; the poll loop below sends a keepalive once the gap
2550
+ // reaches the threshold, so an idle session never looks dead to undici.
2551
+ let lastWriteAt = Date.now();
2537
2552
  const countNewlines = (buf) => {
2538
2553
  let n = 0;
2539
2554
  for (let i = 0; i < buf.length; i++)
@@ -2548,6 +2563,7 @@ export function buildHttpApp(deps) {
2548
2563
  const buf = chunk;
2549
2564
  linesStreamed += countNewlines(buf);
2550
2565
  await out.write(buf);
2566
+ lastWriteAt = Date.now();
2551
2567
  }
2552
2568
  position = initialSize;
2553
2569
  }
@@ -2587,12 +2603,25 @@ export function buildHttpApp(deps) {
2587
2603
  const buf = chunk;
2588
2604
  linesStreamed += countNewlines(buf);
2589
2605
  void out.write(buf);
2606
+ lastWriteAt = Date.now();
2590
2607
  }
2591
2608
  });
2592
2609
  };
2593
2610
  watchFile(jsonlPath, { interval: 250 }, listener);
2611
+ const keepaliveMs = deps.logFollowKeepaliveMs ?? LOG_FOLLOW_KEEPALIVE_DEFAULT_MS;
2594
2612
  while (active) {
2595
2613
  await new Promise((resolve) => setTimeout(resolve, 1_000));
2614
+ if (!active)
2615
+ break;
2616
+ if (Date.now() - lastWriteAt < keepaliveMs)
2617
+ continue;
2618
+ // Task 2557 — a bare newline, not a file line: it is excluded from
2619
+ // `linesStreamed` because that counter is what this stream carried from
2620
+ // the JSONL, and the emitter drops it at its own `if (!line) continue`.
2621
+ // Its only job is to be a byte, so undici's bodyTimeout never elapses
2622
+ // on a conversation that is merely quiet.
2623
+ await out.write(Buffer.from('\n'));
2624
+ lastWriteAt = Date.now();
2596
2625
  }
2597
2626
  deps.logger(`log-follow-close sessionId=${sessionId} reason=${closeReason} linesStreamed=${linesStreamed} ms=${Date.now() - followStreamStart}`);
2598
2627
  });