@wrongstack/core 0.305.1 → 0.306.2

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 (60) hide show
  1. package/dist/chronicle/index.js +6 -1
  2. package/dist/chronicle/project-server.js +13 -3
  3. package/dist/coordination/agents/index.js +3 -2
  4. package/dist/coordination/agents/types.d.ts +1 -1
  5. package/dist/coordination/index.d.ts +1 -0
  6. package/dist/coordination/index.js +165 -59
  7. package/dist/coordination/mailbox-codecs.d.ts +29 -10
  8. package/dist/coordination/mailbox-constants.d.ts +30 -16
  9. package/dist/coordination/mailbox-health.d.ts +16 -0
  10. package/dist/coordination/mailbox-http-validation.d.ts +2 -1
  11. package/dist/coordination/mailbox-parse-state.d.ts +28 -10
  12. package/dist/coordination/mailbox-project-server.js +98 -4
  13. package/dist/coordination/mailbox-types.d.ts +44 -6
  14. package/dist/coordination/package-outdated-watcher.d.ts +15 -1
  15. package/dist/coordination/sqlite-mailbox-credentials.d.ts +26 -0
  16. package/dist/coordination/sqlite-mailbox.d.ts +25 -0
  17. package/dist/coordination/techstack-mailbox-consumer.d.ts +17 -0
  18. package/dist/core/index.d.ts +2 -1
  19. package/dist/core/index.js +2793 -2634
  20. package/dist/core/system-prompt-blocks.d.ts +1 -1
  21. package/dist/core/system-prompt-builder.d.ts +7 -1
  22. package/dist/core/system-prompt-glossary.d.ts +0 -23
  23. package/dist/defaults/index.js +9 -41
  24. package/dist/execution/index.js +9 -3
  25. package/dist/hq/index.js +6 -39
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.js +1053 -626
  28. package/dist/infrastructure/index.js +6 -39
  29. package/dist/observability/index.js +7 -3
  30. package/dist/plugin/index.d.ts +4 -3
  31. package/dist/plugin/index.js +595 -145
  32. package/dist/plugins/auto-review-plugin.d.ts +14 -7
  33. package/dist/plugins/chimera-plugin.d.ts +15 -1
  34. package/dist/plugins/review-finding-integration.d.ts +15 -3
  35. package/dist/plugins/review-finding-parser.d.ts +36 -0
  36. package/dist/plugins/review-finding-types.d.ts +46 -0
  37. package/dist/plugins/review-finding-verification.d.ts +53 -0
  38. package/dist/plugins/review-report-integration.d.ts +1 -0
  39. package/dist/plugins/review-report-store.d.ts +7 -0
  40. package/dist/plugins/review-report-types.d.ts +14 -0
  41. package/dist/plugins/review-types.d.ts +74 -0
  42. package/dist/replay/replay-provider-runner.d.ts +5 -4
  43. package/dist/security/file-permissions.d.ts +12 -35
  44. package/dist/security/index.js +6 -50
  45. package/dist/session-catalog/project-server.js +6 -39
  46. package/dist/storage/index.js +6 -1
  47. package/dist/tools/fallback-manage-tool-options.d.ts +9 -0
  48. package/dist/tools/index.js +91 -38
  49. package/dist/tools/one-shot-llm-tool.d.ts +6 -0
  50. package/dist/types/blocks.d.ts +10 -0
  51. package/dist/utils/index.d.ts +1 -0
  52. package/dist/utils/index.js +30 -9
  53. package/dist/utils/memory-evidence-fence.d.ts +47 -0
  54. package/instructions/agents/browser.md +1 -0
  55. package/instructions/agents/e2e.md +2 -0
  56. package/instructions/llm/chimera-review.md +52 -1
  57. package/instructions/system-lite.md +17 -6
  58. package/instructions/system-pro.md +25 -20
  59. package/instructions/system.md +25 -12
  60. package/package.json +3 -3
@@ -19,20 +19,11 @@ export declare const CLIENT_STALE_MS = 60000;
19
19
  /** Heartbeat updates are throttled to at most this interval (per agent/client). */
20
20
  export declare const HEARTBEAT_THROTTLE_MS = 5000;
21
21
  /**
22
- * How long a read may be served from the in-process registry cache before
23
- * re-reading the shared file. Kept well below HEARTBEAT_THROTTLE_MS so
24
- * cross-process registrations become visible promptly.
22
+ * JSONL line separator. Still live: the one-shot legacy import
23
+ * (`SqliteMailbox.migrateLegacyFiles`) reads `_mailbox.jsonl` through
24
+ * `mailbox-message-codec.ts` / `mailbox-parse-state.ts`.
25
25
  */
26
- export declare const REGISTRY_CACHE_TTL_MS = 2000;
27
- /** JSONL line separator. */
28
26
  export declare const LINE_SEPARATOR = "\n";
29
- /**
30
- * Soft cap on the in-memory message cache. The cache mirrors the JSONL
31
- * message file; under normal load it stays well under this. If a pathological
32
- * mailbox exceeds the cap we fall back to reading from disk rather than
33
- * holding an unbounded buffer in memory.
34
- */
35
- export declare const MESSAGE_CACHE_MAX_ENTRIES = 10000;
36
27
  /** Background mailbox awareness polling interval (cross-process fallback). */
37
28
  export declare const MAILBOX_AWARENESS_INTERVAL_MS = 30000;
38
29
  /** Agent heartbeat interval in the attach layer. */
@@ -98,8 +89,31 @@ export declare const AUTO_COMPACT_DEFAULT_TTL_MS = 86400000;
98
89
  * {@link AUTO_COMPACT_DEFAULT_TTL_MS}.
99
90
  */
100
91
  export declare const AUTO_COMPACT_TYPE_TTL_MS: Readonly<Record<string, number>>;
101
- /** Maximum requests per minute from a single external agent (bearer token). */
102
- export declare const HTTP_RATE_LIMIT_PER_MINUTE = 120;
103
- /** Window size for the sliding-window rate limiter. */
104
- export declare const HTTP_RATE_LIMIT_WINDOW_MS = 60000;
92
+ /**
93
+ * Ceiling on `limit` for any query arriving from an untrusted boundary.
94
+ *
95
+ * `limit` used to be validated as "a positive integer" and nothing else, so a
96
+ * caller could ask for `1e9`. Read paths fan a query out across every
97
+ * recipient address the caller answers to and pass the limit straight through,
98
+ * and the store pre-limits in SQL — so an absurd limit is not clamped
99
+ * anywhere: it materializes every matching row (a `JSON.parse` plus a receipt
100
+ * fold each) once per address.
101
+ *
102
+ * 500 is far above what any real reader asks for — the agent loop uses 10,
103
+ * `mail_inbox` defaults to 20, the HQ snapshot to 50.
104
+ */
105
+ export declare const MAILBOX_MAX_QUERY_LIMIT = 500;
106
+ /**
107
+ * Ceiling on batch acknowledgement size from an untrusted boundary.
108
+ *
109
+ * `Mailbox.ackMany` applies the whole batch inside ONE `BEGIN IMMEDIATE` and
110
+ * does a message lookup per entry. Unbounded (except by a 256 KB body cap that
111
+ * still fits roughly 4,700 acks), a single request meant ~9,400 statements
112
+ * holding the project's only write lock while every other surface — agent
113
+ * loop, TUI, WebUI — waited out `busy_timeout` and then failed.
114
+ *
115
+ * The same ceiling applies to read limits because a `check` acks what it
116
+ * returns: an uncapped limit there is an uncapped ack batch.
117
+ */
118
+ export declare const MAILBOX_MAX_ACK_BATCH = 500;
105
119
  //# sourceMappingURL=mailbox-constants.d.ts.map
@@ -24,6 +24,22 @@
24
24
  * The watchdog is a passive observer: it does NOT start the bridge.
25
25
  * Starting the bridge is the user's job (`wstack mailbox serve` or
26
26
  * `/mailbox-serve`). The watchdog then reports on what the user did.
27
+ *
28
+ * ## Scope: the HTTP bridge, NOT the project mailbox owner
29
+ *
30
+ * Do not reach for this to check whether the mailbox is up. The two processes
31
+ * are unrelated:
32
+ *
33
+ * - The **project mailbox owner** (`mailbox-project-server.ts`) is required
34
+ * and self-healing — clients spawn it on demand and it idles out after five
35
+ * minutes. Its liveness is `MailboxProjectServerConnection.probeStatus()`,
36
+ * which is what the TUI/WebUI connections-health surfaces call.
37
+ * - The **HTTP bridge** this watchdog probes is an optional façade that exists
38
+ * so EXTERNAL agents can reach the mailbox over HTTP. Since 2026-08-07 its
39
+ * feature gate (`features.mailboxBridge`) defaults to `'off'`, so on a
40
+ * default install there is nothing here to watch — which is why this class
41
+ * has no production caller. It stays exported for operators who turn the
42
+ * bridge on; construct it only alongside a bridge you actually started.
27
43
  */
28
44
  import type { Mailbox, MailboxSendInput } from './mailbox-types.js';
29
45
  export interface MailboxHealthWatchdogOptions {
@@ -1,5 +1,6 @@
1
1
  import type { AgentHeartbeatInput, AgentRegistrationInput, ClientHeartbeatInput, ClientRegistrationInput, MailboxAckBatchInput, MailboxAckInput, MailboxActorContext, MailboxMessage, MailboxQuery, MailboxSendInput } from './mailbox-types.js';
2
2
  export declare const MAILBOX_HTTP_MAX_AGE_CEILING_MS: number;
3
+ export { MAILBOX_MAX_ACK_BATCH, MAILBOX_MAX_QUERY_LIMIT, } from './mailbox-constants.js';
3
4
  export declare class MailboxHttpValidationError extends Error {
4
5
  }
5
6
  export interface MailboxCheckInput {
@@ -40,7 +41,7 @@ export declare function requireString(object: unknown, key: string): string;
40
41
  */
41
42
  export declare function parseSinceMs(url: string, defaultMaxAgeMs: number | undefined): SinceResolution;
42
43
  export declare function filterMailboxMessagesByTimestamp(messages: readonly MailboxMessage[], minTimestampIso: string | undefined): MailboxMessage[];
43
- export declare function validateSend(body: unknown, actorId?: string): MailboxSendInput;
44
+ export declare function validateSend(body: unknown, actorId?: string, actorSessionId?: string): MailboxSendInput;
44
45
  export declare function validateQuery(body: unknown): MailboxQuery;
45
46
  export declare function validateCheck(body: unknown, actorId?: string): MailboxCheckInput;
46
47
  export declare function validateAck(body: unknown, actorId?: string): MailboxAckInput;
@@ -1,16 +1,34 @@
1
1
  /**
2
- * Incremental parse state for the mailbox JSONL file.
2
+ * Parse state for the legacy mailbox JSONL file.
3
3
  *
4
- * `parseMailboxFile()` is a whole-file operation: it JSON-parses every line and
5
- * re-projects every message. That is the correct shape for a one-shot read, but
6
- * the read path is anything but one-shot — `unreadCount()`/`query()` consult the
7
- * cache on every tool call, and any append by another session invalidates it.
8
- * On a mailbox holding a day of fleet traffic (~3 MB / ~2.8k lines) that turned
9
- * into the single largest allocation source in the whole TUI process: ~78% of
10
- * all bytes allocated while idle, which V8 then let pile up as garbage until a
11
- * major GC — read as "RAM keeps growing and /clear doesn't help".
4
+ * ## Where this runs today
12
5
  *
13
- * This module keeps enough state alongside the projections that an APPEND can
6
+ * ONE caller: `SqliteMailbox.migrateLegacyFiles()` via `parseMailboxFile()`,
7
+ * i.e. the one-shot import of a pre-SQLite `_mailbox.jsonl` that runs at most
8
+ * once per project and is then fenced off by the `legacy_files_imported`
9
+ * marker. Nothing reads JSONL on the live path any more: the detached owner
10
+ * holds the only handle and every query is SQL.
11
+ *
12
+ * The incremental half of this module — `createMailboxParseState` +
13
+ * `ingestMailboxChunk` called with an appended chunk — has NO production
14
+ * caller. `parseMailboxFile()` is implemented on top of it (one fold, not
15
+ * two), so the code runs, but the append path and its invariants (stale-index
16
+ * tracking, `firstNewIndex`, duplicate-id fan-out) are exercised only by
17
+ * tests. Treat a change there as unvalidated by production traffic.
18
+ *
19
+ * ## Why it was built this way
20
+ *
21
+ * Historical, and worth keeping because the fold logic is still the thing
22
+ * that has to be exact: when the mailbox WAS a JSONL file, the read path was
23
+ * anything but one-shot — `unreadCount()`/`query()` consulted a cache on every
24
+ * tool call and any append by another session invalidated it. On a mailbox
25
+ * holding a day of fleet traffic (~3 MB / ~2.8k lines) a full re-parse per
26
+ * read became the single largest allocation source in the TUI process: ~78% of
27
+ * all bytes allocated while idle, piling up as garbage until a major GC — read
28
+ * as "RAM keeps growing and /clear doesn't help". That pressure is gone with
29
+ * the store; the exactness requirement is not.
30
+ *
31
+ * The module keeps enough state alongside the projections that an APPEND can
14
32
  * be folded in without touching the bytes that were already parsed:
15
33
  *
16
34
  * - `messages` — base messages in file order, v1 acks already folded
@@ -251,6 +251,14 @@ function makePatternMatcher(pattern) {
251
251
  return (e) => e === pattern;
252
252
  }
253
253
 
254
+ // src/security/file-permissions.ts
255
+ import {
256
+ restrictDirPermissions,
257
+ restrictFilePermissions,
258
+ SECRET_DIR_MODE,
259
+ SECRET_FILE_MODE
260
+ } from "@wrongstack/persistence";
261
+
254
262
  // src/utils/atomic-write.ts
255
263
  import {
256
264
  createPersistencePrimitives
@@ -2191,17 +2199,24 @@ function credentialRevoke(db, credentialId, reason, by) {
2191
2199
  function credentialRotate(db, transaction, credentialId, options) {
2192
2200
  const old = credentialGet(db, credentialId);
2193
2201
  if (old === null) return null;
2202
+ if (old.status === "revoked") return null;
2194
2203
  return credentialIssue(db, transaction, {
2195
2204
  principalId: old.principalId,
2196
2205
  projectId: old.projectId ?? options?.projectId,
2197
2206
  kind: old.kind,
2198
2207
  capabilities: options?.capabilities ?? old.capabilities,
2199
- ttlMs: options?.ttlMs ?? MAX_CREDENTIAL_TTL[old.kind],
2208
+ ttlMs: options?.ttlMs ?? previousCredentialTtlMs(old),
2200
2209
  notBefore: options?.notBefore,
2201
2210
  supersedes: credentialId,
2202
2211
  issuedBy: options?.issuedBy
2203
2212
  });
2204
2213
  }
2214
+ function previousCredentialTtlMs(credential) {
2215
+ const issuedAt = new Date(credential.issuedAt).getTime();
2216
+ const expiresAt = new Date(credential.expiresAt).getTime();
2217
+ const ttlMs = expiresAt - issuedAt;
2218
+ return Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : MAX_CREDENTIAL_TTL[credential.kind];
2219
+ }
2205
2220
 
2206
2221
  // src/coordination/sqlite-mailbox-schema.ts
2207
2222
  import * as fs2 from "node:fs";
@@ -2660,9 +2675,14 @@ var SqliteMailbox = class {
2660
2675
  const type = query.type === void 0 ? void 0 : normalizeMailboxMessageType(query.type);
2661
2676
  const priorityRank = { low: 0, normal: 1, high: 2 };
2662
2677
  const minimumRank = query.minPriority === void 0 ? 0 : priorityRank[query.minPriority];
2678
+ if (query.ids !== void 0 && query.ids.length === 0) return [];
2663
2679
  const statuses = query.unreadBy === void 0 ? await this.getAgentStatuses() : void 0;
2664
2680
  const where = [];
2665
2681
  const params = [];
2682
+ if (query.ids !== void 0) {
2683
+ where.push(`id IN (${query.ids.map(() => "?").join(", ")})`);
2684
+ params.push(...query.ids);
2685
+ }
2666
2686
  if (query.to !== void 0) {
2667
2687
  where.push("(to_id = ? OR to_id = ?)");
2668
2688
  params.push(query.to, "*");
@@ -2735,7 +2755,9 @@ var SqliteMailbox = class {
2735
2755
  params.push(query.limit ?? 50);
2736
2756
  }
2737
2757
  const rows = this.stmt(sql).all(...params);
2758
+ const idFilter = query.ids === void 0 ? void 0 : new Set(query.ids);
2738
2759
  const messages = this.materializeMessageRows(rows).filter((message) => {
2760
+ if (idFilter !== void 0 && !idFilter.has(message.id)) return false;
2739
2761
  if (query.to !== void 0 && message.to !== query.to && message.to !== "*") return false;
2740
2762
  if (query.from !== void 0 && message.from !== query.from) return false;
2741
2763
  if (query.sessionId !== void 0 && message.senderSessionId !== query.sessionId)
@@ -2840,11 +2862,76 @@ var SqliteMailbox = class {
2840
2862
  }
2841
2863
  return updated;
2842
2864
  }
2865
+ /**
2866
+ * Count the messages this actor has neither read nor completed.
2867
+ *
2868
+ * Pushed into SQL rather than filtering `readMessages()`. The pre-tool hook
2869
+ * asks for this repeatedly — `mailbox-hooks.ts` throttles it to once a
2870
+ * second, which bounds the frequency but not the cost — and the JS form
2871
+ * materialized EVERY row in `messages`, joined the whole `message_receipts`
2872
+ * table, and folded per-actor receipt state across all of them just to
2873
+ * return an integer.
2874
+ *
2875
+ * The predicate is deliberately the same one {@link query} builds for
2876
+ * `{ unreadBy, incompleteOnly }`, because it has to agree exactly with
2877
+ * `isMessageCompletedForActor` and `isMailboxMessageVisibleTo`:
2878
+ *
2879
+ * - **unread** — no per-actor receipt carrying `read_at`, and no legacy
2880
+ * `readBy` key. Both are checked: `ackMany` writes the receipt row AND
2881
+ * mirrors the timestamp into the message's `readBy` JSON.
2882
+ * - **incomplete** — not `legacy_global_completion`, no per-actor receipt
2883
+ * carrying `completed_at`, and the aggregate `completed` flag counts only
2884
+ * when the message has no receipts at all (once any actor has a receipt,
2885
+ * completion is per-actor and the aggregate flag is not authoritative).
2886
+ * - **audience** — `leaders` mail is invisible unless the actor's base
2887
+ * identity is `leader`. This call path carries no role, matching the
2888
+ * `isMailboxMessageVisibleTo(message, forAgentId)` it replaces.
2889
+ */
2843
2890
  async unreadCount(forAgentId, sessionId) {
2844
2891
  const sessionAddress = sessionId === void 0 ? void 0 : sessionRecipient(sessionId);
2845
- return this.readMessages().filter(
2846
- (message) => (message.to === forAgentId || message.to === "*" || message.to === sessionAddress) && isMailboxMessageVisibleTo(message, forAgentId) && !(forAgentId in message.readBy) && !isMessageCompletedForActor(message, forAgentId) && message.deletedAt === void 0
2847
- ).length;
2892
+ const where = [];
2893
+ const params = [];
2894
+ const recipients = ["to_id = ?", "to_id = '*'"];
2895
+ params.push(forAgentId);
2896
+ if (sessionAddress !== void 0) {
2897
+ recipients.push("to_id = ?");
2898
+ params.push(sessionAddress);
2899
+ }
2900
+ where.push(`(${recipients.join(" OR ")})`);
2901
+ where.push("deleted_at IS NULL");
2902
+ if (!isMailboxLeader(forAgentId)) {
2903
+ where.push("COALESCE(json_extract(data, '$.audience'), 'all') <> 'leaders'");
2904
+ }
2905
+ where.push(`NOT EXISTS (
2906
+ SELECT 1 FROM message_receipts AS read_receipt
2907
+ WHERE read_receipt.message_id = messages.id
2908
+ AND read_receipt.actor_id = ?
2909
+ AND read_receipt.read_at IS NOT NULL
2910
+ )`);
2911
+ params.push(forAgentId);
2912
+ where.push(`NOT EXISTS (
2913
+ SELECT 1 FROM json_each(json_extract(data, '$.readBy')) AS legacy_read
2914
+ WHERE legacy_read.key = ?
2915
+ )`);
2916
+ params.push(forAgentId);
2917
+ where.push("legacy_global_completion = 0");
2918
+ where.push(`NOT EXISTS (
2919
+ SELECT 1 FROM message_receipts AS completed_receipt
2920
+ WHERE completed_receipt.message_id = messages.id
2921
+ AND completed_receipt.actor_id = ?
2922
+ AND completed_receipt.completed_at IS NOT NULL
2923
+ )`);
2924
+ params.push(forAgentId);
2925
+ where.push(`(
2926
+ completed = 0 OR EXISTS (
2927
+ SELECT 1 FROM message_receipts AS any_receipt
2928
+ WHERE any_receipt.message_id = messages.id
2929
+ )
2930
+ )`);
2931
+ const row = this.stmt(
2932
+ `SELECT COUNT(*) AS total FROM messages WHERE ${where.join(" AND ")}`
2933
+ ).get(...params);
2934
+ return Number(row?.total ?? 0);
2848
2935
  }
2849
2936
  async softDelete(mailId, by) {
2850
2937
  const message = this.findMessage(mailId);
@@ -3402,6 +3489,13 @@ async function writeMetadata() {
3402
3489
  const metadata = { ...serverStatus(), authToken };
3403
3490
  await atomicWrite(metadataPath, `${JSON.stringify(metadata, null, 2)}
3404
3491
  `, { mode: 384 });
3492
+ await restrictFilePermissions(metadataPath, {
3493
+ label: "mailbox-server-metadata",
3494
+ // stdio is 'ignore' for this detached process; stderr is where its other
3495
+ // diagnostics go, so a failed hardening is at least consistent with them.
3496
+ warn: (message) => process.stderr.write(`${message}
3497
+ `)
3498
+ });
3405
3499
  }
3406
3500
  async function removeOwnedMetadata() {
3407
3501
  try {
@@ -105,12 +105,14 @@ export { MAILBOX_TYPE_PROPERTIES, type MailboxMessageType, type MailboxTypeCateg
105
105
  * 6. **Awareness polling**: `btw` messages intercepted by background
106
106
  * polling are queued via `setBtwNote()` for injection at a safe loop
107
107
  * boundary, not folded inline.
108
- * 7. **Agent registry**: `getAgentStatuses()` reads the dedicated agent
109
- * registry (`_mailbox.registry.json`), not mailbox message content. The
110
- * registry is populated by agent heartbeat calls (not by `status`-type
111
- * messages). `Mailbox.getAgentStatuses()` derives
112
- * a registry snapshot from `status`-type messages as a fallback when no
113
- * shared registry file exists.
108
+ * 7. **Agent registry**: `getAgentStatuses()` reads the dedicated `agents`
109
+ * table, not mailbox message content. That table is populated by
110
+ * `registerAgent` / `heartbeat` never by `status`-type messages, which
111
+ * are ordinary mail and carry no presence meaning. There is no fallback
112
+ * that derives presence from message content: the registry is either the
113
+ * owner's table or nothing. (It was `_mailbox.registry.json` before the
114
+ * SQLite cutover; both that file and the derive-from-messages fallback are
115
+ * gone.)
114
116
  * 8. **Request-scoped context**: delivered raw mailbox blocks are removed
115
117
  * after one successful provider evaluation. Durable assistant/tool/task
116
118
  * consequences remain; routine mail does not occupy later requests.
@@ -132,6 +134,26 @@ export type MailboxAudience = 'all' | 'leaders';
132
134
  export declare function mailboxIdentityBase(agentId: string): string;
133
135
  /** Whether a mailbox identity belongs to the session's main/leader agent. */
134
136
  export declare function isMailboxLeader(agentId: string, role?: string): boolean;
137
+ /**
138
+ * Whether a sender identity belongs to a named agent family.
139
+ *
140
+ * Matches the family id exactly, or as the `<family>-<suffix>` prefix that a
141
+ * spawned worker carries. Both forms occur in practice for the same logical
142
+ * sender: a pipeline agent may post under its plain id (`dep-watcher`, which
143
+ * `makeDependencyWatcherConfig` sends as), or a subagent may post under the
144
+ * name it was spawned with — `host-subagent-factory` sets `ctx.agentId` from
145
+ * the spawn name, so the tech-stack worker spawned as
146
+ * `tech-stack-package.json` writes mail from `tech-stack-package.json@<tag>`.
147
+ * A consumer gating on the plain family id with `===` would silently reject
148
+ * its own pipeline.
149
+ *
150
+ * Same shape as the existing `chimera` / `chimera-*` check in
151
+ * `applyMailboxSendPolicy` and `host-subagent-factory`.
152
+ *
153
+ * Session/process qualifiers are stripped first, so `<family>@<tag>` and
154
+ * `<family>#<pid>` match too.
155
+ */
156
+ export declare function isMailboxSenderInFamily(senderId: string, family: string): boolean;
135
157
  /** Whether a message may be consumed by the supplied agent identity. */
136
158
  export declare function isMailboxMessageVisibleTo(message: Pick<MailboxMessage, 'audience'>, agentId: string, role?: string): boolean;
137
159
  /**
@@ -381,6 +403,22 @@ export interface MailboxAgentStatus {
381
403
  source?: 'cli' | 'webui' | 'mcp' | 'acp' | 'http' | undefined;
382
404
  }
383
405
  export interface MailboxQuery {
406
+ /**
407
+ * Restrict the result to these message ids.
408
+ *
409
+ * Exists so a caller that already knows which messages it cares about can
410
+ * ask about exactly those instead of pulling a set and scanning it. The
411
+ * HTTP bridge's per-actor visibility checks did the latter — answering
412
+ * "is this one message mine?" by materializing every message addressed to
413
+ * the actor, which on a busy project meant a full table read (plus the
414
+ * whole receipt table, plus a retention projection per row) for every
415
+ * single `ack`.
416
+ *
417
+ * An empty array matches nothing. Untrusted request codecs deliberately do
418
+ * NOT accept this field — `validateQuery` whitelists, so it stays a
419
+ * server-side narrowing rather than something a caller can widen.
420
+ */
421
+ ids?: readonly string[] | undefined;
384
422
  /** Filter by recipient agent id. */
385
423
  to?: string | undefined;
386
424
  /** Filter by sender agent id. */
@@ -58,7 +58,21 @@ export interface PackageOutdatedWatcherOptions {
58
58
  pollIntervalMs?: number | undefined;
59
59
  /** Agent id that runs this watcher. Default: 'pkg-outdated-watcher'. */
60
60
  watcherAgentId?: string | undefined;
61
- /** Agent id of the tech-stack agent to watch for results. Default: 'tech-stack'. */
61
+ /**
62
+ * The only sender whose `result` messages this watcher acts on. Default:
63
+ * `'tech-stack'`. Matched as an agent family, so the worker the tech-stack
64
+ * consumer spawns as `tech-stack-<manifest>` also passes — `ctx.agentId`
65
+ * comes from the spawn name, so its mail arrives from
66
+ * `tech-stack-package.json@<tag>`, not from a bare `tech-stack`.
67
+ *
68
+ * This option was declared and documented from the start but never read:
69
+ * it appeared exactly once in the codebase, in this interface. The watcher
70
+ * therefore acted on a `result` addressed to it from ANY sender, and the
71
+ * body chose both the package names and — through `getPackageAuthor` —
72
+ * whether the resulting HIGH-priority notification went to one agent or was
73
+ * broadcast to `*`. Any agent on the project could use it to push
74
+ * attacker-chosen text to everyone, under a watcher's identity.
75
+ */
62
76
  techStackAgentId?: string | undefined;
63
77
  /** Called to send a notification to an agent. */
64
78
  onNotify: (msg: OutdatedNotifyMessage) => Promise<void>;
@@ -29,6 +29,32 @@ export declare function credentialIssue(db: DatabaseSync, transaction: <T>(run:
29
29
  */
30
30
  export declare function credentialVerify(db: DatabaseSync, credentialId: string, secret: string): CredentialValidation;
31
31
  export declare function credentialRevoke(db: DatabaseSync, credentialId: string, reason?: string, by?: string): boolean;
32
+ /**
33
+ * Rotate a credential: mint a successor for the same principal and mark the
34
+ * predecessor `rotated_out` with an overlap window.
35
+ *
36
+ * Two rules that are easy to get wrong, and were:
37
+ *
38
+ * 1. **A revoked credential is not rotatable.** Rotation has the same
39
+ * capability requirement as issuance, so this is not a privilege boundary —
40
+ * but it IS an audit and intent boundary. Rotating a revoked credential
41
+ * minted a fresh ACTIVE credential carrying the revoked one's principal and
42
+ * capabilities, and recorded it as an ordinary rotation
43
+ * (`supersedes: <revoked-id>`). Revocation is the emergency lever; undoing
44
+ * it must be an explicit re-issue that reads like one in the log, not a
45
+ * rotate that reads like routine hygiene. `rotated_out` and expired-but-
46
+ * active credentials stay rotatable: "my credential lapsed, roll it" is a
47
+ * legitimate flow, and neither state asserts that the holder was distrusted.
48
+ *
49
+ * 2. **Rotation preserves the credential's lifetime**, it does not reset it to
50
+ * the maximum for the kind. Defaulting to `MAX_CREDENTIAL_TTL` meant a
51
+ * deliberately short-lived credential — a 5-minute agent token minted for
52
+ * one task — silently became a 7-day one the first time it was rotated,
53
+ * with nothing in the call expressing that intent. The original duration is
54
+ * recoverable from the record (`expiresAt - issuedAt`), and
55
+ * `createMailboxCredential` still clamps it to the per-kind maximum, so
56
+ * preserving it can only ever narrow the grant.
57
+ */
32
58
  export declare function credentialRotate(db: DatabaseSync, transaction: <T>(run: () => T) => T, credentialId: string, options?: Partial<IssueCredentialOptions>): {
33
59
  credential: MailboxCredential;
34
60
  secret: string;
@@ -33,6 +33,31 @@ export declare class SqliteMailbox implements Mailbox {
33
33
  query(query: MailboxQuery): Promise<MailboxMessage[]>;
34
34
  ack(input: MailboxAckInput): Promise<MailboxMessage | null>;
35
35
  ackMany(input: MailboxAckBatchInput): Promise<MailboxMessage[]>;
36
+ /**
37
+ * Count the messages this actor has neither read nor completed.
38
+ *
39
+ * Pushed into SQL rather than filtering `readMessages()`. The pre-tool hook
40
+ * asks for this repeatedly — `mailbox-hooks.ts` throttles it to once a
41
+ * second, which bounds the frequency but not the cost — and the JS form
42
+ * materialized EVERY row in `messages`, joined the whole `message_receipts`
43
+ * table, and folded per-actor receipt state across all of them just to
44
+ * return an integer.
45
+ *
46
+ * The predicate is deliberately the same one {@link query} builds for
47
+ * `{ unreadBy, incompleteOnly }`, because it has to agree exactly with
48
+ * `isMessageCompletedForActor` and `isMailboxMessageVisibleTo`:
49
+ *
50
+ * - **unread** — no per-actor receipt carrying `read_at`, and no legacy
51
+ * `readBy` key. Both are checked: `ackMany` writes the receipt row AND
52
+ * mirrors the timestamp into the message's `readBy` JSON.
53
+ * - **incomplete** — not `legacy_global_completion`, no per-actor receipt
54
+ * carrying `completed_at`, and the aggregate `completed` flag counts only
55
+ * when the message has no receipts at all (once any actor has a receipt,
56
+ * completion is per-actor and the aggregate flag is not authoritative).
57
+ * - **audience** — `leaders` mail is invisible unless the actor's base
58
+ * identity is `leader`. This call path carries no role, matching the
59
+ * `isMailboxMessageVisibleTo(message, forAgentId)` it replaces.
60
+ */
36
61
  unreadCount(forAgentId: string, sessionId?: string): Promise<number>;
37
62
  softDelete(mailId: string, by: string): Promise<MailboxMessage | null>;
38
63
  restore(mailId: string): Promise<MailboxMessage | null>;
@@ -24,6 +24,23 @@ export interface TechStackConsumerOptions {
24
24
  }>;
25
25
  /** Agent id that the consumer watches for. Default: 'tech-stack'. */
26
26
  targetAgent?: string | undefined;
27
+ /**
28
+ * The only sender whose `assign` messages may spawn an agent. Default:
29
+ * `'dep-watcher'` — the `watcherAgentId` default of
30
+ * {@link attachDepWatcherBridge}, which is the pipeline this consumer exists
31
+ * to serve. Matched on the base identity, so a session-qualified
32
+ * `dep-watcher@<tag>` also passes.
33
+ *
34
+ * Without this the consumer acted on an `assign` addressed to `tech-stack`
35
+ * from ANY sender. The mailbox is a shared bus: every agent on the project
36
+ * can send one with `mail_send`, and so can any external credential holding
37
+ * `mail.send.actionable` when the HTTP bridge is enabled. The message body
38
+ * then chose a file path and was pasted verbatim into the task of a freshly
39
+ * spawned subagent holding `read`, `fetch` and `mailbox` — a peer-writable
40
+ * path into an agent that can read files, reach the network, and broadcast
41
+ * to everyone. Restricting the sender is what makes the spawn intentional.
42
+ */
43
+ senderAgentId?: string | undefined;
27
44
  /** Agent id that sends the completion ack. Default: 'tech-stack-consumer'. */
28
45
  consumerAgentId?: string | undefined;
29
46
  /** Polling interval in ms. Default: 5000. */
@@ -13,5 +13,6 @@ export { formatModelRef, type ModelRef, normalizeModelRef, parseModelRef, } from
13
13
  export { clearPendingNextSteps, hasNextStepsTag, MAX_PENDING_NEXT_STEPS, maybeAppendPendingNextSteps, type PendingNextStep, readPendingNextSteps, renderNextStepsBlock, writePendingNextSteps, } from './next-steps-slot.js';
14
14
  export { runProviderWithRetry } from './provider-runner.js';
15
15
  export { setQueuedMessagesSnapshot } from './queued-messages.js';
16
- export { DefaultSystemPromptBuilder, type DefaultSystemPromptBuilderOptions, type SystemBlockSource, } from './system-prompt-builder.js';
16
+ export { buildIdentityLayer, DefaultSystemPromptBuilder, type DefaultSystemPromptBuilderOptions, type SystemBlockSource, } from './system-prompt-builder.js';
17
+ export { type DomainGlossaryOptions, renderDomainGlossary, } from './system-prompt-glossary.js';
17
18
  //# sourceMappingURL=index.d.ts.map