@sema-agent/core 7.29.0 → 7.30.1

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 (158) hide show
  1. package/CHANGELOG.md +393 -0
  2. package/dist/agents/child-model-seat.d.ts +59 -7
  3. package/dist/agents/child-model-seat.js +33 -7
  4. package/dist/agents/cross-session-judge.d.ts +27 -5
  5. package/dist/agents/cross-session-judge.js +23 -12
  6. package/dist/agents/list-agents-tool.d.ts +31 -6
  7. package/dist/agents/list-agents-tool.js +66 -14
  8. package/dist/agents/peer-admission.d.ts +20 -8
  9. package/dist/agents/peer-admission.js +21 -12
  10. package/dist/agents/peer-directory.d.ts +29 -0
  11. package/dist/agents/peer-directory.js +20 -0
  12. package/dist/agents/peer-held-queue.d.ts +31 -4
  13. package/dist/agents/peer-held-queue.js +32 -1
  14. package/dist/agents/peer-session-drain.d.ts +91 -13
  15. package/dist/agents/peer-session-drain.js +409 -102
  16. package/dist/agents/read-inbox-tool.d.ts +22 -0
  17. package/dist/agents/read-inbox-tool.js +39 -0
  18. package/dist/agents/roster-store.d.ts +2 -4
  19. package/dist/agents/roster-store.js +1 -1
  20. package/dist/agents/send-message-tool.d.ts +4 -4
  21. package/dist/agents/send-message-tool.js +22 -2
  22. package/dist/agents/subagent.js +62 -44
  23. package/dist/agents/team-role.d.ts +34 -0
  24. package/dist/agents/team-role.js +17 -0
  25. package/dist/agents/verify.js +2 -2
  26. package/dist/core/auto-compaction.d.ts +42 -0
  27. package/dist/core/auto-compaction.js +35 -2
  28. package/dist/core/child-clamp-seats.d.ts +11 -0
  29. package/dist/core/child-clamp-seats.js +3 -0
  30. package/dist/core/context-edit.d.ts +3 -2
  31. package/dist/core/context-edit.js +6 -5
  32. package/dist/core/context-guard.d.ts +2 -1
  33. package/dist/core/context-guard.js +3 -2
  34. package/dist/core/context-settings.d.ts +103 -0
  35. package/dist/core/context-settings.js +60 -0
  36. package/dist/core/engine-notice.d.ts +11 -1
  37. package/dist/core/frame-settlement.d.ts +97 -0
  38. package/dist/core/frame-settlement.js +36 -0
  39. package/dist/core/governance-codes.d.ts +1 -1
  40. package/dist/core/governance-codes.js +2 -0
  41. package/dist/core/hooks.d.ts +10 -10
  42. package/dist/core/mailbox-store.d.ts +41 -7
  43. package/dist/core/mailbox-store.js +9 -3
  44. package/dist/core/parent-spec-seat.d.ts +14 -1
  45. package/dist/core/parent-spec-seat.js +6 -0
  46. package/dist/core/permission-rule-model.js +1 -1
  47. package/dist/core/roles.d.ts +20 -0
  48. package/dist/core/roles.js +35 -16
  49. package/dist/core/runner/assemble-result.d.ts +2 -0
  50. package/dist/core/runner/assemble-result.js +1 -1
  51. package/dist/core/runner/compaction-call-options.d.ts +26 -2
  52. package/dist/core/runner/compaction-call-options.js +9 -0
  53. package/dist/core/runner/compaction-seams.d.ts +4 -3
  54. package/dist/core/runner/compaction-seams.js +13 -6
  55. package/dist/core/runner/contracts.d.ts +56 -7
  56. package/dist/core/runner/decide-continuation.d.ts +2 -1
  57. package/dist/core/runner/decide-continuation.js +11 -10
  58. package/dist/core/runner/initial-run-state.js +1 -1
  59. package/dist/core/runner/prepare-caps-and-workflow.js +9 -1
  60. package/dist/core/runner/prepare-config-doors.js +4 -1
  61. package/dist/core/runner/prepare-context-lane.d.ts +9 -2
  62. package/dist/core/runner/prepare-context-lane.js +15 -10
  63. package/dist/core/runner/prepare-delegation-surface.d.ts +3 -2
  64. package/dist/core/runner/prepare-delegation-surface.js +9 -3
  65. package/dist/core/runner/prepare-file-history.js +10 -8
  66. package/dist/core/runner/prepare-gate-stations.d.ts +4 -0
  67. package/dist/core/runner/prepare-gate-stations.js +3 -1
  68. package/dist/core/runner/prepare-inherited-gate.js +1 -0
  69. package/dist/core/runner/prepare-prompt-inputs.d.ts +6 -0
  70. package/dist/core/runner/prepare-prompt-inputs.js +2 -1
  71. package/dist/core/runner/prepare-task.js +22 -9
  72. package/dist/core/runner/prepare-wiring-manifest.d.ts +6 -1
  73. package/dist/core/runner/prepare-wiring-manifest.js +6 -1
  74. package/dist/core/runner/retired-runner-deps.d.ts +3 -1
  75. package/dist/core/runner/retired-runner-deps.js +1 -0
  76. package/dist/core/runner/run-compaction-machinery.d.ts +4 -2
  77. package/dist/core/runner/run-compaction-machinery.js +5 -3
  78. package/dist/core/runner/run-harness-handlers.js +1 -1
  79. package/dist/core/runner/run-identity-wiring.js +19 -1
  80. package/dist/core/runner/run-leg.js +1 -1
  81. package/dist/core/runner/run-notification-lane.js +8 -2
  82. package/dist/core/runner/run-recovery-lanes.js +9 -5
  83. package/dist/core/runner/run-settle-and-teardown.d.ts +4 -22
  84. package/dist/core/runner/run-settle-and-teardown.js +6 -10
  85. package/dist/core/runner/run-stop-and-final-verify.js +1 -0
  86. package/dist/core/runner/run-terminal-adoption.js +5 -5
  87. package/dist/core/runner/run-turn-boundary.js +32 -5
  88. package/dist/core/runner/runtask.d.ts +8 -1
  89. package/dist/core/runner/runtask.js +32 -1
  90. package/dist/core/runner-deps.d.ts +44 -13
  91. package/dist/core/secret-env.js +2 -1
  92. package/dist/core/swappable-deps.d.ts +20 -4
  93. package/dist/core/swappable-deps.js +6 -1
  94. package/dist/core/task-event.d.ts +5 -0
  95. package/dist/core/task-notification.d.ts +144 -2
  96. package/dist/core/task-notification.js +154 -5
  97. package/dist/core/task-result.d.ts +14 -0
  98. package/dist/core/task-spec.d.ts +7 -0
  99. package/dist/core/tool-catalog-entries.js +2 -1
  100. package/dist/core/tool-conformance.js +1 -0
  101. package/dist/core/tool-policy.d.ts +9 -4
  102. package/dist/core/tool-spec.d.ts +14 -0
  103. package/dist/core/wiring-manifest.d.ts +12 -0
  104. package/dist/core/wiring-manifest.js +5 -1
  105. package/dist/engine/harness/agent-harness.d.ts +14 -1
  106. package/dist/engine/harness/agent-harness.js +28 -3
  107. package/dist/engine/harness/types.d.ts +3 -1
  108. package/dist/engine/llm/types.d.ts +8 -7
  109. package/dist/engine/llm/types.js +1 -1
  110. package/dist/engine/lsp/node-lsp-manager.d.ts +3 -0
  111. package/dist/engine/lsp/node-lsp-manager.js +5 -3
  112. package/dist/engine/session/import-validate.js +4 -0
  113. package/dist/index.d.ts +10 -7
  114. package/dist/index.js +7 -5
  115. package/dist/orchestration/run-workflow-tool.d.ts +4 -4
  116. package/dist/orchestration/workflow.d.ts +1 -1
  117. package/dist/orchestration/workflow.js +8 -5
  118. package/dist/shell/lex.d.ts +38 -54
  119. package/dist/shell/lex.js +239 -286
  120. package/dist/stores/cc/mailbox-store.js +3 -2
  121. package/dist/stores/file/corrupt-read-sink.d.ts +28 -2
  122. package/dist/stores/file/file-history-store.d.ts +2 -4
  123. package/dist/stores/file/file-history-store.js +1 -1
  124. package/dist/stores/file/fs-atomic.d.ts +16 -11
  125. package/dist/stores/file/fs-atomic.js +17 -6
  126. package/dist/stores/file/index.d.ts +5 -6
  127. package/dist/stores/file/index.js +1 -1
  128. package/dist/stores/file/mailbox-store.d.ts +4 -9
  129. package/dist/stores/file/mailbox-store.js +29 -30
  130. package/dist/stores/file/session-policy-store.d.ts +2 -0
  131. package/dist/stores/file/session-policy-store.js +1 -1
  132. package/dist/stores/file/session-store.d.ts +11 -7
  133. package/dist/stores/file/session-store.js +23 -16
  134. package/dist/stores/file/shared-ledger.js +3 -14
  135. package/dist/stores/file/tool-result-store.d.ts +2 -0
  136. package/dist/stores/file/tool-result-store.js +5 -2
  137. package/dist/stores/file/workflow-journal-store.js +14 -9
  138. package/dist/tools/fs/bash-program-position.d.ts +15 -24
  139. package/dist/tools/fs/bash-program-position.js +11 -29
  140. package/dist/tools/fs/bash-readonly-classifier.d.ts +131 -117
  141. package/dist/tools/fs/bash-readonly-classifier.js +404 -272
  142. package/dist/tools/fs/shell-read-check.js +3 -3
  143. package/dist/tools/fs/shell-read-establish.d.ts +1 -1
  144. package/dist/tools/fs/shell-read-establish.js +5 -33
  145. package/dist/tools/fs/shell-read-identity.d.ts +13 -0
  146. package/dist/tools/fs/shell-read-identity.js +39 -0
  147. package/dist/tools/fs/shell-read-judge.d.ts +8 -0
  148. package/dist/tools/fs/shell-read-judge.js +7 -0
  149. package/dist/tools/fs/shell-read-plan.d.ts +15 -6
  150. package/dist/tools/fs/shell-read-plan.js +171 -195
  151. package/dist/tools/fs/shell-read-word.d.ts +64 -0
  152. package/dist/tools/fs/shell-read-word.js +94 -0
  153. package/dist/tools/fs/shell-reentry-text.d.ts +10 -0
  154. package/dist/tools/fs/shell-reentry-text.js +73 -0
  155. package/package.json +1 -1
  156. package/test/export-surface.snapshot.json +58 -8
  157. package/wire-changes/core@7.30.0.json +1821 -0
  158. package/wire-changes/core@7.30.1.json +154 -0
@@ -1,10 +1,27 @@
1
1
  import type { AskEffective } from "../core/wiring-manifest.js";
2
2
  import { type PermissionModeClass } from "./cross-session-envelope.js";
3
- /** The explicit values of `crossSessionInbound`. The UI's fourth spelling `"default"` means UNSET
4
- * (mode parity) and is folded to `undefined` by the resolver — it is not a distinct policy. */
5
- export type CrossSessionInboundSetting = "accept" | "hold" | "refuse";
3
+ /**
4
+ * The explicit values of `crossSessionInbound`, loosest first: `wake` (deliver, and the HOST may start
5
+ * a turn on this session when its box grows), `next-turn` (deliver at the session's next turn
6
+ * boundary — the value the pre-7.30 spelling `accept` named), `hold` (park for a human), `refuse`.
7
+ * The UI's spelling `"default"` means UNSET (mode parity) and is folded to `undefined` by the resolver
8
+ * — it is not a distinct policy.
9
+ *
10
+ * @contract cross_session.wake_is_the_hosts_leg — `wake` is judged exactly as `next-turn` at the drain
11
+ * (deliver); what it adds is the HOST's leg: a host that observes a `peer_message` or `engine_frame`
12
+ * record appended to a session whose setting resolves to `wake` may start ONE turn on that session,
13
+ * bounded by `maxWakesPerHour`, and the engine promises that the turn's first drain pass ATTEMPTS the
14
+ * box (a claim that loses, a setting a host cannot read, an admission stop or the pass budget can
15
+ * still leave the record for a later boundary — every such outcome is observable, none is silent).
16
+ * With the budget spent, or on a host that starts no turns, `wake` degrades to `next-turn`: the record
17
+ * stays in the box and the next user turn reads it. The engine never starts a turn itself.
18
+ */
19
+ export type CrossSessionInboundSetting = "wake" | "next-turn" | "hold" | "refuse";
6
20
  export declare const CROSS_SESSION_INBOUND_SETTINGS: readonly CrossSessionInboundSetting[];
7
21
  export declare function isCrossSessionInboundSetting(value: unknown): value is CrossSessionInboundSetting;
22
+ /** The pre-7.30 spelling and the word it now means. A layer carrying the old spelling is READ as the
23
+ * new word (never held, never invalid) and reported in `deprecatedLayers` so a host can say so once. */
24
+ export declare const CROSS_SESSION_INBOUND_DEPRECATED_SPELLINGS: Readonly<Record<string, CrossSessionInboundSetting>>;
8
25
  /** Which layer decided the effective value. `"invalid"` = an unrecognized value was found in some
9
26
  * layer and the fail-closed rule forced `hold` (the loud arm). */
10
27
  export type CrossSessionSettingSource = "managed" | "user" | "repo" | "invalid";
@@ -16,8 +33,9 @@ export interface CrossSessionInboundSettingLayers {
16
33
  /** The user's own layer (CC `userSettings`/`flagSettings`): consulted after managed. */
17
34
  user?: unknown;
18
35
  /** Repository layers (CC `localSettings`/`projectSettings`), in precedence order. A repo layer can
19
- * only TIGHTEN the standing value (accept < hold < refuse), whichever layer set it; it can never
20
- * loosen it. */
36
+ * only TIGHTEN the standing value (wake < next-turn < hold < refuse), whichever layer set it; it can
37
+ * never loosen it — so `wake` written in a repo layer never takes effect (the unset baseline is
38
+ * `next-turn`, and a repo layer cannot loosen past it). */
21
39
  repo?: readonly unknown[];
22
40
  }
23
41
  export interface ResolvedCrossSessionInboundSetting {
@@ -28,6 +46,10 @@ export interface ResolvedCrossSessionInboundSetting {
28
46
  * ⇒ the resolver applied the fail-closed rule (see `decidedBy: "invalid"`) unless the effective
29
47
  * value was already at least as tight as `hold`. Reported so a host can warn — never swallowed. */
30
48
  invalidLayers: Array<"managed" | "user" | "repo">;
49
+ /** Layers that spelled a value the pre-7.30 way (`accept`): read as its current word, and reported
50
+ * here so the drain announces the spelling ONCE per leg — the value in force is never silently
51
+ * different from the value written. Absent from the object when empty. */
52
+ deprecatedLayers?: Array<"managed" | "user" | "repo">;
31
53
  }
32
54
  /**
33
55
  * CC `I` transcribed: managed beats user; repo layers may only tighten the standing value — including a
@@ -1,53 +1,63 @@
1
1
  import { isPermissionModeClass } from "./cross-session-envelope.js";
2
- export const CROSS_SESSION_INBOUND_SETTINGS = Object.freeze(["accept", "hold", "refuse"]);
2
+ export const CROSS_SESSION_INBOUND_SETTINGS = Object.freeze(["wake", "next-turn", "hold", "refuse"]);
3
3
  export function isCrossSessionInboundSetting(value) {
4
4
  return typeof value === "string" && CROSS_SESSION_INBOUND_SETTINGS.includes(value);
5
5
  }
6
- const RANK = { accept: 0, hold: 1, refuse: 2 };
6
+ export const CROSS_SESSION_INBOUND_DEPRECATED_SPELLINGS = Object.freeze({ accept: "next-turn" });
7
+ const RANK = { wake: 0, "next-turn": 1, hold: 2, refuse: 3 };
8
+ const UNSET_BASELINE = "next-turn";
7
9
  function readLayer(v) {
8
10
  if (v === undefined || v === null || v === "default")
9
- return undefined;
10
- return isCrossSessionInboundSetting(v) ? v : "invalid";
11
+ return { value: undefined };
12
+ if (isCrossSessionInboundSetting(v))
13
+ return { value: v };
14
+ const migrated = typeof v === "string" && Object.hasOwn(CROSS_SESSION_INBOUND_DEPRECATED_SPELLINGS, v) ? CROSS_SESSION_INBOUND_DEPRECATED_SPELLINGS[v] : undefined;
15
+ return migrated !== undefined ? { value: migrated, deprecated: true } : { value: "invalid" };
11
16
  }
12
17
  export function resolveCrossSessionInboundSetting(layers) {
13
18
  const invalidLayers = [];
19
+ const deprecatedLayers = [];
14
20
  let value;
15
21
  let decidedBy;
16
22
  for (const [name, raw] of [
17
23
  ["managed", layers.managed],
18
24
  ["user", layers.user],
19
25
  ]) {
20
- const v = readLayer(raw);
26
+ const { value: v, deprecated } = readLayer(raw);
21
27
  if (v === "invalid") {
22
28
  invalidLayers.push(name);
23
29
  continue;
24
30
  }
31
+ if (deprecated === true)
32
+ deprecatedLayers.push(name);
25
33
  if (v !== undefined && value === undefined) {
26
34
  value = v;
27
35
  decidedBy = name;
28
36
  }
29
37
  }
30
38
  for (const raw of layers.repo ?? []) {
31
- const v = readLayer(raw);
39
+ const { value: v, deprecated } = readLayer(raw);
32
40
  if (v === "invalid") {
33
41
  invalidLayers.push("repo");
34
42
  continue;
35
43
  }
44
+ if (deprecated === true)
45
+ deprecatedLayers.push("repo");
36
46
  if (v === undefined)
37
47
  continue;
38
- if (RANK[v] > RANK[value ?? "accept"]) {
48
+ if (RANK[v] > RANK[value ?? UNSET_BASELINE]) {
39
49
  value = v;
40
50
  decidedBy = "repo";
41
51
  }
42
- else if (v !== "accept" && value !== undefined && RANK[v] === RANK[value] && decidedBy !== "managed") {
52
+ else if (v !== UNSET_BASELINE && value !== undefined && RANK[v] === RANK[value] && decidedBy !== "managed") {
43
53
  decidedBy = "repo";
44
54
  }
45
55
  }
46
- if (invalidLayers.length > 0 && RANK[value ?? "accept"] < RANK.hold) {
56
+ if (invalidLayers.length > 0 && RANK[value ?? UNSET_BASELINE] < RANK.hold) {
47
57
  value = "hold";
48
58
  decidedBy = "invalid";
49
59
  }
50
- return { ...(value !== undefined ? { value } : {}), ...(decidedBy !== undefined ? { decidedBy } : {}), invalidLayers };
60
+ return { ...(value !== undefined ? { value } : {}), ...(decidedBy !== undefined ? { decidedBy } : {}), invalidLayers, ...(deprecatedLayers.length > 0 ? { deprecatedLayers } : {}) };
51
61
  }
52
62
  export const CROSS_SESSION_HOLD_CAUSES = Object.freeze([
53
63
  "mode-mismatch",
@@ -71,7 +81,7 @@ export function describeCrossSessionHoldCause(cause) {
71
81
  case "managed-setting":
72
82
  return `Your organization's managed settings set "crossSessionInbound" to "hold".`;
73
83
  case "repo-setting":
74
- return `This repository's settings set "crossSessionInbound" to "hold" (your own "accept" cannot override a repo tightening).`;
84
+ return `This repository's settings set "crossSessionInbound" to "hold" (your own "next-turn" cannot override a repo tightening).`;
75
85
  case "invalid-setting":
76
86
  return 'A settings file has an unrecognized "crossSessionInbound" value (see the settings warning), so messages are held while it is present.';
77
87
  case "bypass-default":
@@ -121,7 +131,8 @@ export function judgeCrossSessionInbound(input) {
121
131
  const { setting, selfModeClass, sender } = input;
122
132
  if (setting.value !== undefined) {
123
133
  switch (setting.value) {
124
- case "accept":
134
+ case "wake":
135
+ case "next-turn":
125
136
  return { verdict: "deliver" };
126
137
  case "refuse":
127
138
  return { verdict: "refuse", cause: "opt-out" };
@@ -1,14 +1,27 @@
1
1
  import { Type } from "typebox";
2
2
  import type { TaskAccess } from "../core/task-registry.js";
3
3
  import { type RosterEntry } from "./roster-store.js";
4
- import { type PeerDirectory } from "./peer-directory.js";
4
+ import { type PeerDirectory, type PeerPresence, type PeerSessionRecord } from "./peer-directory.js";
5
5
  export declare const LIST_AGENTS_TOOL_NAME = "ListAgents";
6
6
  export declare const LIST_AGENTS_TOOL_ALIAS = "ListPeers";
7
7
  /** CC `maxResultSizeChars` for this tool. */
8
8
  export declare const LIST_AGENTS_MAX_RESULT_CHARS = 10000;
9
+ /** The default freshness window (24 h) — one source, the peer lane's knob table. */
10
+ export declare const PEER_LISTING_FRESH_WINDOW_MS: number;
11
+ /**
12
+ * THE freshness predicate: a peer row is listed by default iff it is live AND (its host says it is
13
+ * working — `tempo` active/blocked — OR it changed within the window). One rule over rendering
14
+ * visibility only: refs are minted and names resolve over the FULL set, so a row hidden here is still
15
+ * addressable by name. Dead and stale rows are what `includeAll` adds.
16
+ */
17
+ export declare function isFreshPeerRow(r: Pick<PeerSessionRecord, "liveness" | "tempo" | "updatedAt">, now: number, windowMs?: number): boolean;
18
+ /** `Ns` / `Nm` / `Nh` / `Nd` — a bounded age word for a listing row; a future stamp reads as `0s`. */
19
+ export declare function formatAgo(ms: number): string;
20
+ /** The last two path segments of a working directory (what a person recognizes a project by). */
21
+ export declare function cwdTail(cwd: string): string;
9
22
  export interface ListAgentsToolOptions {
10
- /** The peer-session directory (the mount reason; without it the tool is not mounted). */
11
- peerDirectory: PeerDirectory;
23
+ /** The peer-session directory. Absent ⇒ no peer rows (the two agent lanes still list). */
24
+ peerDirectory?: PeerDirectory;
12
25
  /** This session's own identity — excluded from the peer listing, included in ref disambiguation. */
13
26
  self: {
14
27
  sessionId: string;
@@ -40,8 +53,10 @@ export interface ListAgentsToolOptions {
40
53
  roster?: {
41
54
  list(): RosterEntry[] | Promise<RosterEntry[]>;
42
55
  };
43
- /** Include dead (offline) peer sessions in the listing. Default false (CC observable form). */
44
- includeOffline?: boolean;
56
+ /** The resolved freshness window ({@link isFreshPeerRow}); absent ⇒ {@link PEER_LISTING_FRESH_WINDOW_MS}. */
57
+ freshWindowMs?: number;
58
+ /** The clock (tests). */
59
+ now?: () => number;
45
60
  }
46
61
  /** One rendered row. `kind` names the lane; `address` is what SendMessage accepts. */
47
62
  export interface ListAgentsRow {
@@ -49,6 +64,14 @@ export interface ListAgentsRow {
49
64
  name: string;
50
65
  /** Peer rows only: the listing-minted ref. */
51
66
  ref?: string;
67
+ /** Peer rows, live: when the row last changed (rendered `active <N> ago`). */
68
+ lastActiveAt?: number;
69
+ /** Peer rows, dead: when the host marked it (rendered `offline <N> ago`); a dead row with no stamp renders `offline`. */
70
+ offlineAt?: number;
71
+ /** Peer rows: the last two segments of the session's working directory, when the row carries one. */
72
+ cwd?: string;
73
+ /** Peer rows: the session's self-reported presence, when it reported any. */
74
+ presence?: PeerPresence;
52
75
  /** What SendMessage accepts: on both AGENT lanes the registry `task_id` VERBATIM (no prefixing or
53
76
  * re-minting), never the child's session id — a different domain, tabled in INTEGRATION-CORE §1.6a.
54
77
  * Peer rows carry the `session.<id>` box handle instead. */
@@ -59,4 +82,6 @@ export interface ListAgentsRow {
59
82
  * COMPLETED and nothing else: a model applies the continuable rule to that word. */
60
83
  state: "busy" | "idle" | "offline" | "running" | "parked" | "finished" | "failed" | "killed" | "cancelled" | "unknown";
61
84
  }
62
- export declare function createListAgentsTool(opts: ListAgentsToolOptions): import("../internal/harness-types.js").AgentTool<Type.TObject<{}>, unknown>;
85
+ export declare function createListAgentsTool(opts: ListAgentsToolOptions): import("../internal/harness-types.js").AgentTool<Type.TObject<{
86
+ includeAll: Type.TOptional<Type.TBoolean>;
87
+ }>, unknown>;
@@ -3,12 +3,38 @@ import { defineTool } from "../core/tools.js";
3
3
  import { inlineUntrusted } from "../core/untrusted-text.js";
4
4
  import { formatPeerNameRef } from "./cross-session-ref.js";
5
5
  import { entryAccessible } from "./roster-store.js";
6
- import { mintPeerSessionCandidates, peerSessionBoxHandle } from "./peer-directory.js";
6
+ import { mintPeerSessionCandidates, PEER_PRESENCE_MAX_CHARS, peerSessionBoxHandle } from "./peer-directory.js";
7
+ import { PEER_LANE_DEFAULTS } from "./peer-admission.js";
7
8
  import { toolFace } from "../core/tool-catalog-entries.js";
8
9
  import { thrownText } from "../core/thrown-value.js";
10
+ import { assertNoRetiredKeys } from "../core/retired-keys.js";
9
11
  export const LIST_AGENTS_TOOL_NAME = "ListAgents";
10
12
  export const LIST_AGENTS_TOOL_ALIAS = "ListPeers";
11
13
  export const LIST_AGENTS_MAX_RESULT_CHARS = 10_000;
14
+ export const PEER_LISTING_FRESH_WINDOW_MS = PEER_LANE_DEFAULTS.listingFreshWindowMs;
15
+ export function isFreshPeerRow(r, now, windowMs = PEER_LISTING_FRESH_WINDOW_MS) {
16
+ if (r.liveness !== "live")
17
+ return false;
18
+ if (r.tempo === "active" || r.tempo === "blocked")
19
+ return true;
20
+ return now - r.updatedAt <= windowMs;
21
+ }
22
+ export function formatAgo(ms) {
23
+ const sec = Math.max(0, Math.floor(ms / 1000));
24
+ if (sec < 60)
25
+ return `${sec}s`;
26
+ const min = Math.floor(sec / 60);
27
+ if (min < 60)
28
+ return `${min}m`;
29
+ const h = Math.floor(min / 60);
30
+ if (h < 48)
31
+ return `${h}h`;
32
+ return `${Math.floor(h / 24)}d`;
33
+ }
34
+ export function cwdTail(cwd) {
35
+ const parts = cwd.split(/[\\/]+/).filter((p) => p !== "");
36
+ return parts.slice(-2).join("/");
37
+ }
12
38
  const AGENT_ROW_STATE = {
13
39
  pending: "running",
14
40
  running: "running",
@@ -32,26 +58,46 @@ function peerRowState(r) {
32
58
  const tempo = r.tempo;
33
59
  return tempo !== undefined && Object.hasOwn(PEER_ROW_STATE, tempo) ? PEER_ROW_STATE[tempo] : "unknown";
34
60
  }
61
+ function renderPresence(p) {
62
+ const parts = [...(p.agent !== undefined ? [`agent=${inlineUntrusted(p.agent, PEER_PRESENCE_MAX_CHARS)}`] : []), ...(p.intent !== undefined ? [`intent=${inlineUntrusted(p.intent, PEER_PRESENCE_MAX_CHARS)}`] : [])];
63
+ return `self: ${parts.join(", ")}`;
64
+ }
65
+ const RETIRED_LIST_AGENTS_OPTION_KEYS = {
66
+ includeOffline: "the model asks for stale and offline rows per call with the `includeAll` tool parameter (a superset: stale + offline)",
67
+ };
35
68
  export function createListAgentsTool(opts) {
69
+ assertNoRetiredKeys(opts, RETIRED_LIST_AGENTS_OPTION_KEYS, "ListAgentsToolOptions");
70
+ const peers = opts.peerDirectory !== undefined;
71
+ const windowMs = opts.freshWindowMs ?? PEER_LISTING_FRESH_WINDOW_MS;
36
72
  return defineTool({
37
73
  name: LIST_AGENTS_TOOL_NAME,
38
74
  ...toolFace("list-agents"),
39
75
  isConcurrencySafe: () => true,
40
- description: `List the agents and peer sessions you can message with SendMessage. Peer sessions are other sessions of this ` +
41
- `engine running for the same user on this machine, shown as \`name [ref]\`; send to one with its name (or \`name [ref]\` ` +
42
- `when a name is ambiguous, or its \`session.<id>\` address). A peer that is offline still accepts messages — they are parked ` +
43
- `durably in its session box and delivered when it next runs. Background agents you spawned are listed with their task_id. ` +
76
+ description: `List the agents${peers ? " and peer sessions" : ""} you can message with SendMessage. ` +
77
+ (peers
78
+ ? `Peer sessions are other sessions of this engine running for the same user, shown as \`name [ref] · state · active <N> ago · <directory> · self: agent=…, intent=…\`; send to one with its name (or \`name [ref]\` ` +
79
+ `when a name is ambiguous, or its \`session.<id>\` address). By default only FRESH peers are listed (working now, or active within the last ${formatAgo(windowMs)}); ` +
80
+ `pass includeAll: true to also list stale and offline ones. A peer that is offline or unlisted still accepts messages — they are parked ` +
81
+ `durably in its session box and delivered when it next runs. `
82
+ : `Peer sessions of this engine are not addressable on this mount (no session directory). `) +
83
+ `Background agents you spawned are listed with their task_id. ` +
44
84
  `A ref is only valid against the listing (or disambiguation error) you read it from — do not invent one.`,
45
- parameters: Type.Object({}),
46
- execute: async () => {
85
+ parameters: Type.Object({
86
+ includeAll: Type.Optional(Type.Boolean({ description: "Also list stale (no activity within the freshness window) and offline peer sessions. Default false." })),
87
+ }),
88
+ execute: async (args) => {
89
+ const includeAll = args.includeAll === true;
90
+ const now = opts.now?.() ?? Date.now();
47
91
  const rows = [];
48
92
  let peerRows = [];
49
93
  let directoryFault;
50
- try {
51
- peerRows = await opts.peerDirectory.listPeerSessions({ scope: opts.self.scope });
52
- }
53
- catch (e) {
54
- directoryFault = thrownText(e);
94
+ if (opts.peerDirectory !== undefined) {
95
+ try {
96
+ peerRows = await opts.peerDirectory.listPeerSessions({ scope: opts.self.scope });
97
+ }
98
+ catch (e) {
99
+ directoryFault = thrownText(e);
100
+ }
55
101
  }
56
102
  for (const c of mintPeerSessionCandidates(peerRows, { sessionId: opts.self.sessionId })) {
57
103
  const r = c.record;
@@ -59,14 +105,18 @@ export function createListAgentsTool(opts) {
59
105
  continue;
60
106
  if (r.liveness === "deleted")
61
107
  continue;
62
- if (r.liveness === "dead" && opts.includeOffline !== true)
108
+ if (!includeAll && !isFreshPeerRow(r, now, windowMs))
63
109
  continue;
110
+ const presence = { ...(r.agent !== undefined ? { agent: r.agent } : {}), ...(r.intent !== undefined ? { intent: r.intent } : {}) };
64
111
  rows.push({
65
112
  kind: "peer_session",
66
113
  name: r.name,
67
114
  ref: c.ref,
68
115
  address: peerSessionBoxHandle(r.sessionId),
69
116
  state: peerRowState(r),
117
+ ...(r.liveness === "live" ? { lastActiveAt: r.updatedAt } : r.diedAt !== undefined ? { offlineAt: r.diedAt } : {}),
118
+ ...(r.cwd !== undefined ? { cwd: cwdTail(r.cwd) } : {}),
119
+ ...(presence.agent !== undefined || presence.intent !== undefined ? { presence } : {}),
70
120
  });
71
121
  }
72
122
  let registryCapped = false;
@@ -101,7 +151,9 @@ export function createListAgentsTool(opts) {
101
151
  }
102
152
  const lines = rows.map((r) => {
103
153
  const label = r.kind === "peer_session" ? formatPeerNameRef(inlineUntrusted(r.name, 64), r.ref) : inlineUntrusted(r.name, 64);
104
- return `${label} · ${r.state} · ${r.kind === "peer_session" ? "peer session" : r.kind === "roster" ? "agent (durable record)" : "agent"} · ${r.address}`;
154
+ const age = r.kind !== "peer_session" ? undefined : r.lastActiveAt !== undefined ? `active ${formatAgo(now - r.lastActiveAt)} ago` : r.offlineAt !== undefined ? `offline ${formatAgo(now - r.offlineAt)} ago` : "offline";
155
+ const cols = [label, r.state, age, r.cwd !== undefined ? inlineUntrusted(r.cwd, 64) : undefined, r.presence !== undefined ? renderPresence(r.presence) : undefined, r.kind === "peer_session" ? "peer session" : r.kind === "roster" ? "agent (durable record)" : "agent", r.address];
156
+ return cols.filter((c) => c !== undefined).join(" · ");
105
157
  });
106
158
  const finishedCount = rows.filter((r) => r.state === "finished").length;
107
159
  const header = rows.length === 0 ? "No agents or peer sessions are listed right now." : `${rows.length} listed:`;
@@ -7,7 +7,7 @@ export type PeerAxisTag = "h" | "s" | "t";
7
7
  export type PeerRefusalCode = "rate_limited" | "duplicate" | "hop_loop" | "hop_runaway" | "queue_full";
8
8
  /** Gate verdict subset of {@link PeerRefusalCode} (what `admit` itself can refuse with). */
9
9
  export type PeerAdmissionRefusal = Exclude<PeerRefusalCode, "queue_full">;
10
- export interface PeerAdmissionConfig {
10
+ export interface PeerLaneConfig {
11
11
  /** Per-sender burst budget (bucket size). */
12
12
  bucketCapacity: number;
13
13
  /** Per-sender steady-state refill rate (tokens per second). */
@@ -27,12 +27,24 @@ export interface PeerAdmissionConfig {
27
27
  * senders restart with full buckets — no correctness debt (the gate holds no state that must
28
28
  * survive across messages). */
29
29
  maxTrackedRecipients: number;
30
+ /** Peer WAKE budget, HOST-enforced: how many times per hour a host may start a turn on a session
31
+ * whose `crossSessionInbound` resolves to `wake` because its box grew. The engine spells the
32
+ * number and the contract; the host that starts turns is the executor. Integer. */
33
+ maxWakesPerHour: number;
34
+ /** Peer delivery budget, ENGINE-enforced: how many box RECORDS one drain pass injects (held
35
+ * releases, notice lines, peer messages and engine frames count alike — one counter over every
36
+ * record injection); the rest stay leased for the next boundary, and the pass's own `inbox_notice`
37
+ * line — which is not a record — says how many. Integer. */
38
+ maxMessagesPerRound: number;
39
+ /** The `ListAgents` freshness window: a live row with no activity word is listed by default only
40
+ * while `now − updatedAt` is within it (`includeAll` lists the rest). Milliseconds. */
41
+ listingFreshWindowMs: number;
30
42
  }
31
43
  /** Upstream defaults, adopted verbatim (anchored; deviation would need evidence sema does not have). */
32
- export declare const PEER_ADMISSION_DEFAULTS: Readonly<PeerAdmissionConfig>;
44
+ export declare const PEER_LANE_DEFAULTS: Readonly<PeerLaneConfig>;
33
45
  /** Test seam (mirrors `__resetMaterializeEnvAnnouncements`); never called by production code.
34
46
  * Re-arms BOTH arms (a WeakMap has no clear — it is re-minted). */
35
- export declare function __resetPeerAdmissionRangeAnnouncements(): void;
47
+ export declare function __resetPeerLaneRangeAnnouncements(): void;
36
48
  /**
37
49
  * Resolve a caller's partial config against the defaults, per-field range-clamped-to-default.
38
50
  * There is no off switch: the guard is always on; the ranges ARE the host's tuning space.
@@ -46,7 +58,7 @@ export declare function __resetPeerAdmissionRangeAnnouncements(): void;
46
58
  * the deployment's sink; absent, the announcement still reaches `console.warn` through the shared
47
59
  * delivery throat (an unwired host is not a silent one).
48
60
  */
49
- export declare function resolvePeerAdmissionConfig(overrides?: Partial<PeerAdmissionConfig>, onNotice?: (notice: EngineNotice) => void): PeerAdmissionConfig;
61
+ export declare function resolvePeerLaneConfig(overrides?: Partial<PeerLaneConfig>, onNotice?: (notice: EngineNotice) => void): PeerLaneConfig;
50
62
  /** Hop-chain carrier sliding window: a chain keeps its most recent entries (upstream `Xbr`=32).
51
63
  * The chain grows ONLY at send time (the sender appends its own token); delivery/replay never
52
64
  * appends — a message-driven revival is not a hop. */
@@ -135,10 +147,10 @@ export interface PeerAdmission {
135
147
  * records the body IN THIS CALL (no rollback protocol — a downstream delivery failure does not
136
148
  * refund; the bucket refills on its own). Refusal has zero side effects. Config is read
137
149
  * per call (state is long-lived, values are current — upstream form). */
138
- admit(req: PeerAdmissionRequest, config: PeerAdmissionConfig): PeerAdmissionVerdict;
150
+ admit(req: PeerAdmissionRequest, config: PeerLaneConfig): PeerAdmissionVerdict;
139
151
  /** Hop-only check (the identity-less-sender arm, and any caller that needs the chain verdict
140
152
  * without touching rate/dedup state). */
141
- checkHopChain(prospectiveChain: readonly string[], ownTokens: readonly string[], config: PeerAdmissionConfig): PeerAdmissionVerdict;
153
+ checkHopChain(prospectiveChain: readonly string[], ownTokens: readonly string[], config: PeerLaneConfig): PeerAdmissionVerdict;
142
154
  /** Observation face: senders currently tracked by this recipient instance. */
143
155
  trackedSenderCount(): number;
144
156
  /** Observation face: refusals by reason since this instance was created. */
@@ -149,7 +161,7 @@ export interface PeerAdmissionOptions {
149
161
  now?: () => number;
150
162
  }
151
163
  export declare function createPeerAdmission(options?: PeerAdmissionOptions): PeerAdmission;
152
- export declare function peerAdmissionFor(scope: string | undefined, recipientKey: string, config: PeerAdmissionConfig, options?: PeerAdmissionOptions): PeerAdmission;
164
+ export declare function peerAdmissionFor(scope: string | undefined, recipientKey: string, config: PeerLaneConfig, options?: PeerAdmissionOptions): PeerAdmission;
153
165
  /**
154
166
  * The delivery legs' ONE admission entry: registry seat-commit follows the SAME
155
167
  * refusal-is-side-effect-free rule as the sender table inside `admit` — the instance is looked up
@@ -166,7 +178,7 @@ export declare function peerAdmissionFor(scope: string | undefined, recipientKey
166
178
  * sent would read the send-point body record and refuse its own message as `duplicate`. Two gate
167
179
  * instances, one per stage; the stage-less key is the pre-385 spelling and stays byte-identical. */
168
180
  export type PeerAdmissionStage = "send" | "drain";
169
- export declare function judgePeerAdmission(scope: string | undefined, recipientKey: string, req: PeerAdmissionRequest, config: PeerAdmissionConfig, options?: PeerAdmissionOptions, stage?: PeerAdmissionStage): PeerAdmissionVerdict;
181
+ export declare function judgePeerAdmission(scope: string | undefined, recipientKey: string, req: PeerAdmissionRequest, config: PeerLaneConfig, options?: PeerAdmissionOptions, stage?: PeerAdmissionStage): PeerAdmissionVerdict;
170
182
  /** Test seam ONLY (module-level registry hygiene between test files — same posture as the lane map,
171
183
  * which drains itself; this one is long-lived by design so tests reset it explicitly). */
172
184
  export declare function resetPeerAdmissionRegistryForTests(): void;
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { deliverEngineNotice } from "../core/types.js";
3
- export const PEER_ADMISSION_DEFAULTS = Object.freeze({
3
+ export const PEER_LANE_DEFAULTS = Object.freeze({
4
4
  bucketCapacity: 30,
5
5
  refillPerSecond: 0.5,
6
6
  dedupWindowMs: 30_000,
@@ -9,6 +9,9 @@ export const PEER_ADMISSION_DEFAULTS = Object.freeze({
9
9
  maxTrackedSenders: 256,
10
10
  maxQueuedPeerMessages: 50,
11
11
  maxTrackedRecipients: 256,
12
+ maxWakesPerHour: 6,
13
+ maxMessagesPerRound: 5,
14
+ listingFreshWindowMs: 24 * 60 * 60_000,
12
15
  });
13
16
  const CONFIG_RANGES = Object.freeze({
14
17
  bucketCapacity: [5, 500],
@@ -19,10 +22,14 @@ const CONFIG_RANGES = Object.freeze({
19
22
  maxTrackedSenders: [16, 100_000],
20
23
  maxQueuedPeerMessages: [10, 5_000],
21
24
  maxTrackedRecipients: [16, 100_000],
25
+ maxWakesPerHour: [0, 600],
26
+ maxMessagesPerRound: [1, 500],
27
+ listingFreshWindowMs: [60_000, 30 * 24 * 60 * 60_000],
22
28
  });
29
+ const INTEGER_FIELDS = new Set(["maxWakesPerHour", "maxMessagesPerRound"]);
23
30
  let announcedOutOfRangeBySink = new WeakMap();
24
31
  const announcedOutOfRangeConsole = new Set();
25
- function peerAdmissionRangeLedger(onNotice) {
32
+ function peerLaneRangeLedger(onNotice) {
26
33
  if (typeof onNotice !== "function")
27
34
  return announcedOutOfRangeConsole;
28
35
  let lines = announcedOutOfRangeBySink.get(onNotice);
@@ -32,12 +39,12 @@ function peerAdmissionRangeLedger(onNotice) {
32
39
  }
33
40
  return lines;
34
41
  }
35
- export function __resetPeerAdmissionRangeAnnouncements() {
42
+ export function __resetPeerLaneRangeAnnouncements() {
36
43
  announcedOutOfRangeBySink = new WeakMap();
37
44
  announcedOutOfRangeConsole.clear();
38
45
  }
39
- export function resolvePeerAdmissionConfig(overrides, onNotice) {
40
- const out = { ...PEER_ADMISSION_DEFAULTS };
46
+ export function resolvePeerLaneConfig(overrides, onNotice) {
47
+ const out = { ...PEER_LANE_DEFAULTS };
41
48
  if (overrides === undefined)
42
49
  return out;
43
50
  for (const k of Object.keys(CONFIG_RANGES)) {
@@ -49,12 +56,14 @@ export function resolvePeerAdmissionConfig(overrides, onNotice) {
49
56
  ? "not_a_finite_number"
50
57
  : v < lo || v > hi
51
58
  ? "out_of_range"
52
- : undefined;
59
+ : INTEGER_FIELDS.has(k) && !Number.isInteger(v)
60
+ ? "not_an_integer"
61
+ : undefined;
53
62
  if (reason === undefined) {
54
63
  out[k] = v;
55
64
  continue;
56
65
  }
57
- announcePeerAdmissionFallback(k, v, reason, lo, hi, onNotice);
66
+ announcePeerLaneFallback(k, v, reason, lo, hi, onNotice);
58
67
  }
59
68
  return out;
60
69
  }
@@ -87,18 +96,18 @@ function configValueKey(v) {
87
96
  }
88
97
  return renderConfigValue(v);
89
98
  }
90
- function announcePeerAdmissionFallback(field, given, reason, lo, hi, onNotice) {
91
- const fallback = PEER_ADMISSION_DEFAULTS[field];
99
+ function announcePeerLaneFallback(field, given, reason, lo, hi, onNotice) {
100
+ const fallback = PEER_LANE_DEFAULTS[field];
92
101
  const shown = renderConfigValue(given);
93
102
  const key = JSON.stringify([field, typeof given, configValueKey(given)]);
94
- const ledger = peerAdmissionRangeLedger(onNotice);
103
+ const ledger = peerLaneRangeLedger(onNotice);
95
104
  if (ledger.has(key))
96
105
  return;
97
106
  ledger.add(key);
98
107
  deliverEngineNotice(onNotice, {
99
108
  code: "config.peer_admission_out_of_range",
100
- message: `Peer-admission setting ${field}=${shown} is ` +
101
- (reason === "out_of_range" ? `outside its legal range [${lo}, ${hi}]` : `not a finite number`) +
109
+ message: `Peer-lane setting ${field}=${shown} is ` +
110
+ (reason === "out_of_range" ? `outside its legal range [${lo}, ${hi}]` : reason === "not_an_integer" ? `not a whole number` : `not a finite number`) +
102
111
  `; that field falls back to its default (${fallback}). Every other field is unaffected.`,
103
112
  detail: {
104
113
  field,
@@ -6,6 +6,22 @@ export declare const PEER_SESSION_RECORD_SCHEMA_VERSION = 1;
6
6
  export declare const PEER_SESSION_RECORD_MAX_BYTES = 262144;
7
7
  export type PeerSessionLiveness = "live" | "dead" | "deleted";
8
8
  export type PeerSessionTempo = "active" | "idle" | "blocked";
9
+ /** A session's SELF-REPORTED presence: what it says it is (`agent`) and what it says it is doing
10
+ * (`intent`). Labels a peer controls — rendered bounded and prefixed `self:`, never read by any
11
+ * permission, routing or wake judgment. The same object rides `TaskSpec.peerPresence`, the wiring
12
+ * manifest and the directory row a host copies it into. */
13
+ export interface PeerPresence {
14
+ agent?: string;
15
+ intent?: string;
16
+ }
17
+ /** The read cap of one presence label; a longer value refuses the whole row (one rule, `field_invalid`). */
18
+ export declare const PEER_PRESENCE_MAX_CHARS = 120;
19
+ /** THE rule for one presence label, read by the row reader and by prepare's door on `TaskSpec.peerPresence` (a
20
+ * spec the directory would refuse is refused where it is written, not when the host files the row). */
21
+ export declare function peerPresenceLabelValid(v: unknown): v is string | undefined;
22
+ /** Prepare's door: `TaskSpec.peerPresence` is a plain object whose `agent` / `intent` each pass {@link peerPresenceLabelValid}
23
+ * — else `config.peer_presence_invalid`, naming the field. */
24
+ export declare function assertPeerPresenceValue(v: unknown): void;
9
25
  /** The registration row. Field set = CC's load-bearing subset + sema's `scope` axis + the sema-only
10
26
  * liveness/tombstone members. A host writes it; every terminal reads it through {@link readPeerSessionRecord}. */
11
27
  export interface PeerSessionRecord {
@@ -24,11 +40,24 @@ export interface PeerSessionRecord {
24
40
  procStartMs?: number;
25
41
  cwd?: string;
26
42
  startedAt: number;
43
+ /** When the row last changed (the host's `lastActivityAt` on a server row; `markDead` writes it too). */
27
44
  updatedAt: number;
45
+ /**
46
+ * @contract peer_directory.dead_is_the_hosts_word — `dead` is the HOST's word: a host marks a row dead
47
+ * when the session has no leg in flight AND `updatedAt` is older than the host's retention period, and
48
+ * stamps `diedAt` with the marking time; that retention period is at least the engine's listing
49
+ * freshness window (`listingFreshWindowMs`), so a row the engine would list as fresh is never dead.
50
+ * The engine reads `liveness` and `updatedAt`; it never rewrites `liveness` from a row's age.
51
+ */
28
52
  liveness: PeerSessionLiveness;
29
53
  /** Present iff `liveness !== "live"`: when the row was marked dead (or the tombstone was written). */
30
54
  diedAt?: number;
55
+ /** The host's activity word. A stale `tempo` is the host's to clear: a row that says `active` is
56
+ * listed as fresh regardless of its age. */
31
57
  tempo?: PeerSessionTempo;
58
+ /** Self-reported presence (see {@link PeerPresence}); a host copies it off the session's spec. */
59
+ agent?: string;
60
+ intent?: string;
32
61
  entrypoint?: string;
33
62
  peerProtocol?: number;
34
63
  /** CC capability bits (`/^[a-z0-9_]{1,32}$/`, ≤16); `sema_mailbox_v1` marks a store-lane peer. */
@@ -4,6 +4,18 @@ import { formatPeerNameRef, mintPeerRef, normalizePeerName, parsePeerNameRef, PE
4
4
  import { inlineUntrusted } from "../core/untrusted-text.js";
5
5
  export const PEER_SESSION_RECORD_SCHEMA_VERSION = 1;
6
6
  export const PEER_SESSION_RECORD_MAX_BYTES = 262_144;
7
+ export const PEER_PRESENCE_MAX_CHARS = 120;
8
+ export function peerPresenceLabelValid(v) {
9
+ return v === undefined || (typeof v === "string" && v.length <= PEER_PRESENCE_MAX_CHARS);
10
+ }
11
+ export function assertPeerPresenceValue(v) {
12
+ const bad = v === undefined ? undefined : v === null || typeof v !== "object" || Array.isArray(v) ? "peerPresence" : ["agent", "intent"].find((k) => !peerPresenceLabelValid(v[k]));
13
+ if (bad === undefined)
14
+ return;
15
+ const e = new Error(`TaskSpec.${bad === "peerPresence" ? bad : `peerPresence.${bad}`} must be ${bad === "peerPresence" ? "a plain { agent?, intent? } object" : `a string of at most ${PEER_PRESENCE_MAX_CHARS} characters`} — the peer directory refuses such a row whole, so the run would advertise a presence no listing can show`);
16
+ e.code = "config.peer_presence_invalid";
17
+ throw e;
18
+ }
7
19
  export const SESSION_BOX_PREFIX = "session.";
8
20
  const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,80}$/;
9
21
  export const PEER_SESSION_ID_GRAMMAR = "^[A-Za-z0-9_-]{1,80}$";
@@ -61,6 +73,12 @@ export function readPeerSessionRecord(raw) {
61
73
  return bad("peerProtocol");
62
74
  if (!optString(r["cwd"]))
63
75
  return bad("cwd");
76
+ if (!peerPresenceLabelValid(r["agent"]))
77
+ return bad("agent");
78
+ if (!peerPresenceLabelValid(r["intent"]))
79
+ return bad("intent");
80
+ const agent = typeof r["agent"] === "string" && r["agent"].trim() !== "" ? r["agent"] : undefined;
81
+ const intent = typeof r["intent"] === "string" && r["intent"].trim() !== "" ? r["intent"] : undefined;
64
82
  if (!optString(r["entrypoint"]))
65
83
  return bad("entrypoint");
66
84
  if (!optString(r["sockPath"]))
@@ -88,6 +106,8 @@ export function readPeerSessionRecord(raw) {
88
106
  ...(r["pid"] !== undefined ? { pid: r["pid"] } : {}),
89
107
  ...(r["procStartMs"] !== undefined ? { procStartMs: r["procStartMs"] } : {}),
90
108
  ...(r["cwd"] !== undefined ? { cwd: r["cwd"] } : {}),
109
+ ...(agent !== undefined ? { agent } : {}),
110
+ ...(intent !== undefined ? { intent } : {}),
91
111
  startedAt: r["startedAt"],
92
112
  updatedAt: r["updatedAt"],
93
113
  liveness,