@astrosheep/square 0.3.62 → 0.3.64

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 (168) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +3 -2
  2. package/claude-plugin/hooks/hooks.json +1 -22
  3. package/claude-plugin/hooks/register.js +119 -0
  4. package/claude-plugin/types.d.ts +7 -0
  5. package/codex-plugin/.codex-plugin/plugin.json +1 -1
  6. package/dist/activity-feed.d.ts +1 -10
  7. package/dist/activity-feed.js +1 -27
  8. package/dist/activity.js +57 -25
  9. package/dist/artifact.js +12 -24
  10. package/dist/attention-presentation.js +1 -0
  11. package/dist/automatic-session.d.ts +3 -1
  12. package/dist/automatic-session.js +21 -34
  13. package/dist/boundary-presentation.js +4 -13
  14. package/dist/canonical-path.d.ts +11 -0
  15. package/dist/canonical-path.js +47 -0
  16. package/dist/catch-decisions.js +14 -10
  17. package/dist/catch-selection.d.ts +6 -0
  18. package/dist/catch-selection.js +14 -0
  19. package/dist/claude-delivery.d.ts +11 -0
  20. package/dist/claude-delivery.js +145 -0
  21. package/dist/claude-mod.d.ts +8 -0
  22. package/dist/claude-mod.js +164 -0
  23. package/dist/cli/context.d.ts +0 -1
  24. package/dist/cli/context.js +9 -21
  25. package/dist/cli/harness-command.d.ts +0 -1
  26. package/dist/cli/harness-command.js +0 -6
  27. package/dist/cli/observation-commands.d.ts +1 -1
  28. package/dist/cli/observation-commands.js +31 -58
  29. package/dist/cli/program.js +20 -18
  30. package/dist/cli/registry.js +2 -2
  31. package/dist/cli/square-commands.js +10 -6
  32. package/dist/codex-hook.js +9 -12
  33. package/dist/codex-queue.d.ts +2 -2
  34. package/dist/codex-queue.js +7 -5
  35. package/dist/decisions.d.ts +2 -6
  36. package/dist/decisions.js +16 -17
  37. package/dist/delivery-health.d.ts +1 -2
  38. package/dist/delivery-health.js +4 -5
  39. package/dist/delivery-operations.d.ts +2 -5
  40. package/dist/delivery-operations.js +77 -214
  41. package/dist/delivery.d.ts +25 -47
  42. package/dist/delivery.js +14 -46
  43. package/dist/diagnostic-redaction.js +7 -9
  44. package/dist/file-changes.d.ts +0 -1
  45. package/dist/file-changes.js +2 -19
  46. package/dist/harness-claude.js +8 -6
  47. package/dist/harness-codex.d.ts +0 -2
  48. package/dist/harness-codex.js +0 -2
  49. package/dist/harness-links.js +13 -17
  50. package/dist/harness.js +3 -7
  51. package/dist/help.js +4 -3
  52. package/dist/host-ledger-file-adapter.d.ts +17 -8
  53. package/dist/host-ledger-file-adapter.js +164 -80
  54. package/dist/host-ledger.d.ts +55 -50
  55. package/dist/inbox.d.ts +0 -3
  56. package/dist/inbox.js +13 -30
  57. package/dist/index.d.ts +1 -1
  58. package/dist/index.js +1 -1
  59. package/dist/list.js +1 -4
  60. package/dist/mcp.js +18 -40
  61. package/dist/mention-parse.js +3 -9
  62. package/dist/model.d.ts +11 -2
  63. package/dist/model.js +1 -1
  64. package/dist/notifications.d.ts +3 -16
  65. package/dist/notifications.js +41 -122
  66. package/dist/opencode-delivery.d.ts +14 -0
  67. package/dist/opencode-delivery.js +171 -0
  68. package/dist/opencode.d.ts +39 -18
  69. package/dist/opencode.js +145 -40
  70. package/dist/packages/agent-delivery/src/claude-auth.d.ts +13 -0
  71. package/dist/packages/agent-delivery/src/claude-auth.js +70 -0
  72. package/dist/packages/agent-delivery/src/claude-native.d.ts +28 -0
  73. package/dist/packages/agent-delivery/src/claude-native.js +65 -0
  74. package/dist/packages/agent-delivery/src/claude.d.ts +14 -0
  75. package/dist/packages/agent-delivery/src/claude.js +66 -0
  76. package/dist/packages/agent-delivery/src/deadline.d.ts +11 -0
  77. package/dist/packages/agent-delivery/src/deadline.js +41 -0
  78. package/dist/packages/agent-delivery/src/errors.d.ts +16 -0
  79. package/dist/packages/agent-delivery/src/errors.js +41 -0
  80. package/dist/packages/agent-delivery/src/guards.d.ts +6 -0
  81. package/dist/packages/agent-delivery/src/guards.js +6 -0
  82. package/dist/packages/agent-delivery/src/index.d.ts +83 -0
  83. package/dist/packages/agent-delivery/src/index.js +110 -0
  84. package/dist/packages/agent-delivery/src/local-endpoint.d.ts +26 -0
  85. package/dist/packages/agent-delivery/src/local-endpoint.js +114 -0
  86. package/dist/packages/agent-delivery/src/opencode-native.d.ts +33 -0
  87. package/dist/packages/agent-delivery/src/opencode-native.js +75 -0
  88. package/dist/packages/agent-delivery/src/opencode.d.ts +14 -0
  89. package/dist/packages/agent-delivery/src/opencode.js +127 -0
  90. package/dist/packages/agent-delivery/src/paseo-native.d.ts +58 -0
  91. package/dist/packages/agent-delivery/src/paseo-native.js +122 -0
  92. package/dist/packages/agent-delivery/src/paseo.d.ts +19 -0
  93. package/dist/packages/agent-delivery/src/paseo.js +106 -0
  94. package/dist/packages/agent-delivery/src/pi-client.d.ts +10 -0
  95. package/dist/packages/agent-delivery/src/pi-client.js +106 -0
  96. package/dist/packages/agent-delivery/src/pi-protocol.d.ts +15 -0
  97. package/dist/packages/agent-delivery/src/pi-protocol.js +50 -0
  98. package/dist/packages/agent-delivery/src/pi-send.d.ts +23 -0
  99. package/dist/packages/agent-delivery/src/pi-send.js +4 -0
  100. package/dist/packages/agent-delivery/src/pi.d.ts +25 -0
  101. package/dist/packages/agent-delivery/src/pi.js +209 -0
  102. package/dist/packages/agent-delivery/src/types.d.ts +120 -0
  103. package/dist/participant-host.js +12 -13
  104. package/dist/participant-identity.d.ts +21 -0
  105. package/dist/participant-identity.js +32 -11
  106. package/dist/paseo-delivery.d.ts +2 -2
  107. package/dist/paseo-delivery.js +35 -27
  108. package/dist/paseo-timeline.js +25 -21
  109. package/dist/paseo.d.ts +2 -2
  110. package/dist/paseo.js +1 -2
  111. package/dist/ports.d.ts +7 -20
  112. package/dist/presentation-operations.js +16 -4
  113. package/dist/presentation.d.ts +19 -10
  114. package/dist/presentation.js +99 -177
  115. package/dist/registry.d.ts +15 -66
  116. package/dist/registry.js +45 -123
  117. package/dist/routes.d.ts +1 -28
  118. package/dist/routes.js +22 -104
  119. package/dist/runtime.d.ts +3 -9
  120. package/dist/runtime.js +14 -67
  121. package/dist/search.d.ts +0 -2
  122. package/dist/search.js +3 -10
  123. package/dist/square-actions.js +48 -72
  124. package/dist/square-application.js +25 -23
  125. package/dist/square-core.d.ts +3 -0
  126. package/dist/square-core.js +22 -5
  127. package/dist/square-facade.d.ts +2 -0
  128. package/dist/square-file-adapter.js +4 -7
  129. package/dist/square-projections.d.ts +40 -45
  130. package/dist/square-projections.js +43 -73
  131. package/dist/square-state.js +1 -4
  132. package/dist/square-storage.d.ts +5 -8
  133. package/dist/square-storage.js +13 -36
  134. package/dist/square-wiring.d.ts +0 -5
  135. package/dist/square-wiring.js +3 -21
  136. package/dist/stream.js +4 -6
  137. package/dist/time.js +2 -7
  138. package/dist/views.d.ts +4 -16
  139. package/dist/views.js +12 -19
  140. package/dist/wake-attempts.d.ts +2 -56
  141. package/dist/wake-attempts.js +15 -110
  142. package/dist/wake-eligibility.d.ts +43 -0
  143. package/dist/wake-eligibility.js +106 -0
  144. package/dist/wake-evidence.d.ts +32 -9
  145. package/dist/wake-evidence.js +29 -24
  146. package/dist/wake-sink.d.ts +13 -9
  147. package/dist/wake-sink.js +35 -54
  148. package/dist/watch.js +13 -21
  149. package/extensions/square-pi.js +10 -5
  150. package/guides/architect.md +77 -77
  151. package/guides/brainstorm.md +197 -197
  152. package/guides/claude-native.md +101 -0
  153. package/guides/host-ledger.md +3 -1
  154. package/guides/participant.md +14 -7
  155. package/guides/pi-native-delivery.md +43 -0
  156. package/package.json +4 -3
  157. package/dist/claude-hook.d.ts +0 -10
  158. package/dist/claude-hook.js +0 -60
  159. package/dist/paseo-connection.d.ts +0 -15
  160. package/dist/paseo-connection.js +0 -141
  161. package/dist/presence.d.ts +0 -2
  162. package/dist/presence.js +0 -24
  163. package/dist/presented.d.ts +0 -2
  164. package/dist/presented.js +0 -10
  165. package/dist/state-cell.d.ts +0 -14
  166. package/dist/wake-port.d.ts +0 -23
  167. package/dist/wake-port.js +0 -27
  168. /package/dist/{state-cell.js → packages/agent-delivery/src/types.js} +0 -0
@@ -139,8 +139,11 @@ export declare function audienceIncludes(audience: Audience, name: string): bool
139
139
  export declare function resolveAudience(audience: Audience, candidateNames: readonly string[]): string[];
140
140
  export declare function activeListeners(state: FoldedSquareState, sender: string): string[];
141
141
  export declare function isIgnored(state: FoldedSquareState, listener: string, sender: string): boolean;
142
+ /** The author of every landed activity, keyed by index; a reply audience derives from this. */
143
+ export declare function activityAuthors(acts: readonly Act[]): Map<number, Participant>;
142
144
  export declare function listeningTo(state: FoldedSquareState, listener: string): string[];
143
145
  export declare function isListening(state: FoldedSquareState, listener: string, sender: string): boolean;
146
+ export declare function actorOf(act: Act): Participant | undefined;
144
147
  export declare function fold(acts: readonly Act[]): FoldedSquareState;
145
148
  /** Replay the activity stream once to fix every say's audience at landing. */
146
149
  export declare function replayLandedAudiences(acts: readonly Act[]): LandedAudienceReplay;
@@ -60,12 +60,28 @@ export function activeListeners(state, sender) {
60
60
  export function isIgnored(state, listener, sender) {
61
61
  return (state.ignored.get(nameKey(listener)) ?? []).some((target) => sameName(target, sender));
62
62
  }
63
- function recipientsAtLanding(before, say) {
63
+ /** The author of every landed activity, keyed by index; a reply audience derives from this. */
64
+ export function activityAuthors(acts) {
65
+ const authors = new Map();
66
+ for (const act of acts) {
67
+ const actor = actorOf(act);
68
+ if (actor === undefined || !('index' in act) || typeof act.index !== 'number')
69
+ continue;
70
+ authors.set(act.index, actor);
71
+ }
72
+ return authors;
73
+ }
74
+ function recipientsAtLanding(before, say, authors) {
64
75
  const audience = audienceOf(say);
65
- const mentionTargets = resolveAudience(audience, before.participants.filter((participant) => participant.joined).map((participant) => participant.name));
76
+ const standing = before.participants.filter((participant) => participant.joined).map((participant) => participant.name);
77
+ const mentionTargets = resolveAudience(audience, standing);
78
+ // A reply is directed attention in its own right: it reaches the author of the
79
+ // replied-to activity under the same standing and ignore rules as a mention.
80
+ const replyAuthor = say.reply === undefined ? undefined : authors.get(say.reply);
81
+ const replyTargets = replyAuthor === undefined ? [] : resolveAudience({ kind: 'mentions', names: [replyAuthor] }, standing);
66
82
  const listeners = activeListeners(before, say.actor);
67
83
  const recipients = [];
68
- for (const name of [...mentionTargets, ...listeners]) {
84
+ for (const name of [...mentionTargets, ...replyTargets, ...listeners]) {
69
85
  if (sameName(name, say.actor) || recipients.some((existing) => sameName(existing, name)))
70
86
  continue;
71
87
  if (audience.kind !== 'bell' && isIgnored(before, name, say.actor))
@@ -80,7 +96,7 @@ export function listeningTo(state, listener) {
80
96
  export function isListening(state, listener, sender) {
81
97
  return listeningTo(state, listener).some((target) => sameName(target, sender));
82
98
  }
83
- function actorOf(act) {
99
+ export function actorOf(act) {
84
100
  if ('actor' in act && typeof act.actor === 'string')
85
101
  return act.actor;
86
102
  return undefined;
@@ -238,9 +254,10 @@ export function replayLandedAudiences(acts) {
238
254
  const byActivity = new Map();
239
255
  const byIndex = new Map();
240
256
  const lastJoinByKey = new Map();
257
+ const authors = activityAuthors(acts);
241
258
  for (const act of acts) {
242
259
  if (act.kind === 'say') {
243
- const recipients = recipientsAtLanding(accumulator.state, act);
260
+ const recipients = recipientsAtLanding(accumulator.state, act, authors);
244
261
  byActivity.set(act, recipients);
245
262
  const index = 'index' in act ? act.index : undefined;
246
263
  if (typeof index === 'number')
@@ -24,6 +24,8 @@ export interface ExpressOptions {
24
24
  export interface ExpressResult {
25
25
  readonly activity: Activity;
26
26
  readonly delivery?: DeliveryResult;
27
+ /** The replied-to author was not standing when the reply landed, so no notification reached them. */
28
+ readonly replyAuthorNotHere?: string;
27
29
  }
28
30
  export interface ListenerChangeResult {
29
31
  readonly activity: Activity | null;
@@ -1,6 +1,6 @@
1
1
  export { observeSquareRevision as observeSquareChanges } from './artifact.js';
2
2
  import { hostLedgerRoot } from './host-ledger-root.js';
3
- import { createSquareState, createSquareSnapshot, probeSquareFile, writeSquareSnapshot, openSquareCell, createMemoryCell, } from './square-storage.js';
3
+ import { createSquareState, createSquareSnapshot, probeSquareFile, writeSquareSnapshot, createFileCell, createMemoryCell, } from './square-storage.js';
4
4
  import { InternalSquareError, SquareError, } from './model.js';
5
5
  import { createHostLedgerPort } from './host-ledger-file-adapter.js';
6
6
  /** File-owned artifact creation for the CLI and path-backed public facade. */
@@ -51,7 +51,7 @@ export async function openSquare(squarePath, options = {}) {
51
51
  }
52
52
  export async function probeSquare(squarePath) {
53
53
  const state = await probeSquareFile(squarePath);
54
- return state === undefined ? undefined : { artifact: memoryArtifact(createMemoryCell(state)), clock: Date.now, location: squarePath };
54
+ return state === undefined ? undefined : { artifact: createMemoryCell(state), clock: Date.now, location: squarePath, hostLedger: createHostLedgerPort({ rootPath: hostLedgerRoot(process.env) }) };
55
55
  }
56
56
  export async function buildSquare(squarePath, options) {
57
57
  validateBuildOptions(options);
@@ -77,12 +77,9 @@ export function buildMemorySquare(options) {
77
77
  hardCap: options.hardCap ?? null,
78
78
  ...(options.throttlePerMinute === undefined ? {} : { throttlePerMinute: options.throttlePerMinute }),
79
79
  }, options.markdown);
80
- return { artifact: memoryArtifact(createMemoryCell(squareState)), clock: options.clock ?? Date.now, location: 'memory', hostLedger: options.hostLedger, wakeTransport: options.wakeTransport };
80
+ return { artifact: createMemoryCell(squareState), clock: options.clock ?? Date.now, location: 'memory', hostLedger: options.hostLedger, wakeTransport: options.wakeTransport };
81
81
  }
82
82
  /** A projection reads and validates its snapshot once; no preliminary duplicate read. */
83
83
  export function openSquareArtifact(squarePath, signal) {
84
- return memoryArtifact(openSquareCell(squarePath, signal));
85
- }
86
- function memoryArtifact(cell) {
87
- return { read: (signal) => cell.read(signal), transact: (fn, signal) => cell.transact(fn, signal), changed: (since, timeout, signal) => cell.changed(since, timeout, signal), close: () => cell.close() };
84
+ return createFileCell(squarePath, signal);
88
85
  }
@@ -1,28 +1,56 @@
1
1
  import { type InboxMembership, type SquareState } from './model.js';
2
2
  import type { HostLedgerPort, SquareArtifactPort, PresentationEvidenceProjection, PresentationProjection, SessionBindingProjection } from './ports.js';
3
3
  import { deriveDeliveryModel } from './delivery.js';
4
- import type { WakeRoute, WakeRouteKind } from './model.js';
4
+ import { type WakeEligibility } from './wake-eligibility.js';
5
5
  /** A fresh blocking catch owns only the notifications admitted by its filter. */
6
6
  export declare function pendingAtBoundary(inbox: InboxMembership[]): InboxMembership[];
7
- export declare function projectSessionBindings(input: {
7
+ /**
8
+ * One eligibility projection for a freshly read session inbox: a fresh catch lease owns what it
9
+ * admitted, and a delivered (or clipped) boundary already raised awareness for its activity, so
10
+ * neither belongs at the next boundary. Ordering, session scoping, suppression outcomes and
11
+ * ledger errors stay exactly as the boundary callers read them.
12
+ */
13
+ export declare function projectBoundaryEligibility(input: {
8
14
  readonly hostLedger: HostLedgerPort;
9
15
  readonly sessionId: string;
16
+ readonly inbox: readonly InboxMembership[];
17
+ }): Promise<InboxMembership[]>;
18
+ export declare function projectSessionBindings(input: {
19
+ readonly hostLedger: HostLedgerPort;
20
+ readonly sessionId?: string;
10
21
  readonly location?: string;
11
22
  readonly now?: number;
12
23
  }): Promise<readonly SessionBindingProjection[]>;
13
- export declare function sessionIdsFromEnvironment(env?: NodeJS.ProcessEnv): readonly string[];
14
- export declare function projectLocalParticipantBinding(input: {
15
- readonly hostLedger: HostLedgerPort;
16
- readonly location: string;
17
- readonly participant: string;
18
- readonly sessionIds: readonly string[];
19
- readonly now?: number;
20
- }): Promise<SessionBindingProjection | undefined>;
21
24
  export declare function projectPresentation(input: {
22
25
  readonly artifact: SquareArtifactPort;
23
26
  readonly binding: SessionBindingProjection;
24
27
  readonly now?: number;
25
28
  }): Promise<PresentationProjection>;
29
+ export interface NativePendingPreview {
30
+ readonly payload: string;
31
+ readonly clipped: boolean;
32
+ }
33
+ /** The one native pending preview. `epoch` matches exactly, and each harness pins its own address entry. */
34
+ export type NativePendingPreviewInput = {
35
+ readonly location: string;
36
+ readonly sessionId: string;
37
+ readonly participant: string;
38
+ readonly actIndex: number;
39
+ readonly epoch?: number;
40
+ readonly cancelledThrough?: number;
41
+ readonly now: number;
42
+ } & ({
43
+ readonly routeKind: 'claude-native';
44
+ readonly address: {
45
+ readonly endpoint: string;
46
+ };
47
+ } | {
48
+ readonly routeKind: 'opencode-server';
49
+ readonly address: {
50
+ readonly sessionId: string;
51
+ };
52
+ });
53
+ export declare function nativePendingPreview(state: SquareState, input: NativePendingPreviewInput): NativePendingPreview | undefined;
26
54
  export declare function projectPresentationEvidence(input: {
27
55
  readonly hostLedger: HostLedgerPort;
28
56
  readonly location?: string;
@@ -33,44 +61,11 @@ export declare function projectPresentationEvidence(input: {
33
61
  }): Promise<readonly PresentationEvidenceProjection[]>;
34
62
  /** Successful boundary output, including a clipped preview, suppresses awareness wake. */
35
63
  export declare function presentationSuppressesWake(evidence: readonly Pick<PresentationEvidenceProjection, 'outcome'>[]): boolean;
36
- /** The most recently published session owns wake eligibility for its participant. */
37
- export declare function currentSessionBindings<T extends {
38
- readonly participant: string;
39
- readonly updatedAt?: number;
40
- }>(bindings: readonly T[]): T[];
41
- export interface WakeAttempt {
42
- readonly at: number;
43
- readonly attention: {
44
- readonly squarePath: string;
45
- readonly actIndex: number;
46
- readonly recipient: string;
47
- };
48
- readonly routeKind: WakeRouteKind;
49
- readonly outcome: 'accepted' | 'unknown' | 'failed';
50
- readonly signature?: string;
51
- readonly attemptN: number;
52
- readonly session?: string;
53
- readonly message?: string;
54
- readonly diagnostic?: unknown;
55
- }
56
- export declare function terminalWakeEvidence(attempts: readonly WakeAttempt[]): WakeAttempt | undefined;
57
- export declare function isWakeRouteAttemptable(route: Pick<WakeRoute, 'kind' | 'updatedAt'>, attempts: readonly WakeAttempt[]): boolean;
58
- export declare function hasAttemptableWakeRoute(routes: readonly Pick<WakeRoute, 'kind' | 'updatedAt'>[], attempts: readonly WakeAttempt[]): boolean;
59
- export interface WakeEvidence {
60
- readonly delivered: boolean;
61
- readonly presented: boolean;
62
- readonly attempts: readonly WakeAttempt[];
63
- readonly terminal?: WakeAttempt;
64
- readonly attemptableRoutes: readonly WakeRoute[];
65
- }
66
- export interface WakeEvidenceProjection {
67
- evidence(recipient: string, actIndex: number): WakeEvidence;
68
- }
64
+ /** Read primary evidence once; wake behavior belongs to the pure eligibility projection. */
69
65
  export declare function projectWakeEvidenceFromState(input: {
70
66
  readonly location: string;
71
67
  readonly state: SquareState;
72
68
  readonly hostLedger: HostLedgerPort;
73
69
  readonly now: number;
74
70
  readonly delivery?: ReturnType<typeof deriveDeliveryModel>;
75
- }): Promise<WakeEvidenceProjection>;
76
- export declare function wakeIsEligible(evidence: WakeEvidence): boolean;
71
+ }): Promise<WakeEligibility>;
@@ -1,8 +1,10 @@
1
- import { formatActivityId, parseActivityId } from './square-core.js';
1
+ import { formatActivityId } from './square-core.js';
2
2
  import { nameKey } from './model.js';
3
3
  import { deriveDeliveryModel, leaseOwnsNotification } from './delivery.js';
4
4
  import { freshWatchLease } from './runtime.js';
5
- import { canonicalRouteLocation } from './routes.js';
5
+ import { attentionBodyIsClipped, renderAttentionPreview } from './attention-presentation.js';
6
+ import { canonicalPath } from './canonical-path.js';
7
+ import { projectWakeEligibility } from './wake-eligibility.js';
6
8
  /** A fresh blocking catch owns only the notifications admitted by its filter. */
7
9
  export function pendingAtBoundary(inbox) {
8
10
  return inbox.map((membership) => {
@@ -12,37 +14,33 @@ export function pendingAtBoundary(inbox) {
12
14
  return { ...membership, notifications: membership.notifications.filter((notification) => !leaseOwnsNotification(lease, { ...notification, recipient: membership.name })) };
13
15
  }).filter((membership) => membership.notifications.length > 0);
14
16
  }
15
- function bindingProjection(record) {
16
- return {
17
+ /**
18
+ * One eligibility projection for a freshly read session inbox: a fresh catch lease owns what it
19
+ * admitted, and a delivered (or clipped) boundary already raised awareness for its activity, so
20
+ * neither belongs at the next boundary. Ordering, session scoping, suppression outcomes and
21
+ * ledger errors stay exactly as the boundary callers read them.
22
+ */
23
+ export async function projectBoundaryEligibility(input) {
24
+ const pending = [];
25
+ for (const membership of pendingAtBoundary([...input.inbox])) {
26
+ const evidence = await projectPresentationEvidence({ hostLedger: input.hostLedger, location: membership.squarePath, participant: membership.name, sessionId: input.sessionId });
27
+ const notifications = membership.notifications.filter((notification) => !presentationSuppressesWake(evidence.filter((row) => row.activity === formatActivityId(notification.actIndex))));
28
+ if (notifications.length > 0)
29
+ pending.push({ ...membership, notifications });
30
+ }
31
+ return pending;
32
+ }
33
+ export async function projectSessionBindings(input) {
34
+ const rows = await input.hostLedger.listPresence({ location: input.location, session: input.sessionId, now: input.now });
35
+ return rows.map((record) => ({
17
36
  location: record.location,
18
37
  participant: record.participant,
19
38
  sessionId: record.session,
20
39
  channel: record.channel,
21
- ...(record.route === undefined ? {} : {
22
- route: {
23
- location: record.location,
24
- participant: record.participant,
25
- sessionId: record.session,
26
- channel: record.channel,
27
- kind: record.route.kind,
28
- address: { ...record.route.address },
29
- updatedAt: record.updatedAt ?? 0,
30
- },
31
- }),
40
+ ...(record.epoch === undefined ? {} : { epoch: record.epoch }),
41
+ ...(record.cancelledThrough === undefined ? {} : { cancelledThrough: record.cancelledThrough }),
32
42
  updatedAt: record.updatedAt ?? 0,
33
- };
34
- }
35
- export async function projectSessionBindings(input) {
36
- const rows = await input.hostLedger.listPresence({ location: input.location, session: input.sessionId, now: input.now });
37
- return rows.map(bindingProjection);
38
- }
39
- export function sessionIdsFromEnvironment(env = process.env) {
40
- return [env.CLAUDE_CODE_SESSION_ID, env.CODEX_THREAD_ID, env.OPENCODE_SESSION_ID, env.PI_SESSION_ID, env.PASEO_AGENT_ID]
41
- .map((value) => value?.trim()).filter((value) => Boolean(value));
42
- }
43
- export async function projectLocalParticipantBinding(input) {
44
- const bindings = (await Promise.all(input.sessionIds.map((sessionId) => projectSessionBindings({ hostLedger: input.hostLedger, location: input.location, sessionId, now: input.now })))).flat();
45
- return bindings.find((binding) => binding.participant.toLocaleLowerCase() === input.participant.toLocaleLowerCase());
43
+ }));
46
44
  }
47
45
  export async function projectPresentation(input) {
48
46
  const { state } = await input.artifact.read();
@@ -55,6 +53,18 @@ export async function projectPresentation(input) {
55
53
  const lease = freshWatchLease(state, known, input.now ?? Date.now());
56
54
  return { binding, joined: true, notifications, ...(lease === undefined ? {} : { catchLease: lease }) };
57
55
  }
56
+ export function nativePendingPreview(state, input) {
57
+ const route = state.routes?.find((candidate) => candidate.kind === input.routeKind && candidate.sessionId === input.sessionId
58
+ && candidate.epoch === input.epoch && nameKey(candidate.participant) === nameKey(input.participant)
59
+ && (input.routeKind === 'claude-native' ? candidate.address.endpoint === input.address.endpoint : candidate.address.sessionId === input.address.sessionId));
60
+ if (!route || input.actIndex <= (input.cancelledThrough ?? -1))
61
+ return undefined;
62
+ const notification = deriveDeliveryModel(state).pendingFor(input.participant).find((entry) => entry.item.index === input.actIndex);
63
+ const lease = freshWatchLease(state, input.participant, input.now);
64
+ if (!notification || (lease !== undefined && leaseOwnsNotification(lease, { ...notification.item, recipient: input.participant, route: notification.route })))
65
+ return undefined;
66
+ return { payload: renderAttentionPreview({ squarePath: input.location, recipient: input.participant, actIndex: input.actIndex, actor: notification.item.actor, route: notification.route, body: notification.item.body }), clipped: attentionBodyIsClipped(notification.item.body) };
67
+ }
58
68
  export async function projectPresentationEvidence(input) {
59
69
  const rows = await input.hostLedger.listEvidence({ kind: 'presentation', location: input.location, participant: input.participant, session: input.sessionId, activity: input.activity, now: input.now });
60
70
  return rows.map((row) => ({ location: row.location, participant: row.participant, sessionId: row.session, activity: row.activity, outcome: row.outcome, ...(row.at === undefined ? {} : { at: row.at }) }));
@@ -63,52 +73,12 @@ export async function projectPresentationEvidence(input) {
63
73
  export function presentationSuppressesWake(evidence) {
64
74
  return evidence.some((row) => row.outcome === 'clipped' || row.outcome === 'presented');
65
75
  }
66
- /** The most recently published session owns wake eligibility for its participant. */
67
- export function currentSessionBindings(bindings) {
68
- const latest = new Map();
69
- for (const binding of bindings) {
70
- const participant = nameKey(binding.participant);
71
- latest.set(participant, Math.max(latest.get(participant) ?? Number.NEGATIVE_INFINITY, binding.updatedAt ?? 0));
72
- }
73
- return bindings.filter((binding) => (binding.updatedAt ?? 0) === latest.get(nameKey(binding.participant)));
74
- }
75
- export function terminalWakeEvidence(attempts) { return attempts.findLast((attempt) => attempt.outcome === 'accepted'); }
76
- export function isWakeRouteAttemptable(route, attempts) {
77
- if (terminalWakeEvidence(attempts) !== undefined)
78
- return false;
79
- if (attempts.some((attempt) => attempt.routeKind === route.kind && attempt.outcome === 'unknown'))
80
- return false;
81
- return true;
82
- }
83
- export function hasAttemptableWakeRoute(routes, attempts) { return routes.some((route) => isWakeRouteAttemptable(route, attempts)); }
76
+ /** Read primary evidence once; wake behavior belongs to the pure eligibility projection. */
84
77
  export async function projectWakeEvidenceFromState(input) {
85
- const canonicalLocation = await canonicalRouteLocation(input.location);
78
+ const canonicalLocation = await canonicalPath(input.location);
86
79
  const delivery = input.delivery ?? deriveDeliveryModel(input.state);
87
- const bindings = (input.state.routes ?? [])
88
- .filter((route) => route.location === canonicalLocation || route.location === input.location)
89
- .map((route) => ({ location: route.location, participant: route.participant, sessionId: route.sessionId, channel: route.channel, route: { ...route, address: { ...route.address } }, updatedAt: route.updatedAt }));
80
+ const owners = await input.hostLedger.listPresence({ location: canonicalLocation, now: input.now });
90
81
  const wakeRecords = await input.hostLedger.listEvidence({ location: canonicalLocation, kind: 'wake', now: input.now });
91
- const attemptsByBinding = new Map();
92
- for (const record of wakeRecords) {
93
- const actIndex = parseActivityId(record.activity);
94
- if (actIndex === undefined || record.routeKind === undefined || typeof record.attemptN !== 'number')
95
- continue;
96
- const attempt = { attention: { squarePath: record.location, recipient: record.participant, actIndex }, outcome: record.outcome, at: record.at ?? input.now, routeKind: record.routeKind, attemptN: record.attemptN, ...(record.signature === undefined ? {} : { signature: record.signature }), ...(record.session === undefined ? {} : { session: record.session }), ...(record.message === undefined ? {} : { message: record.message }), ...(record.diagnostic === undefined ? {} : { diagnostic: record.diagnostic }) };
97
- const key = JSON.stringify([nameKey(record.participant), actIndex, record.session]);
98
- const existing = attemptsByBinding.get(key) ?? [];
99
- existing.push(attempt);
100
- attemptsByBinding.set(key, existing);
101
- }
102
- const presentedRows = await projectPresentationEvidence({ hostLedger: input.hostLedger, location: canonicalLocation, now: input.now });
103
- return {
104
- evidence(recipient, actIndex) {
105
- const recipientBindings = currentSessionBindings(bindings.filter((binding) => nameKey(binding.participant) === nameKey(recipient)));
106
- const routes = recipientBindings.flatMap((binding) => binding.route === undefined ? [] : [binding.route]);
107
- const attempts = recipientBindings.flatMap((binding) => attemptsByBinding.get(JSON.stringify([nameKey(recipient), actIndex, binding.sessionId])) ?? []);
108
- const terminal = terminalWakeEvidence(attempts);
109
- const presented = presentedRows.some((row) => row.activity === formatActivityId(actIndex) && row.participant.toLocaleLowerCase() === recipient.toLocaleLowerCase() && recipientBindings.some((binding) => binding.sessionId === row.sessionId) && presentationSuppressesWake([row]));
110
- return { delivered: delivery.isSeen(recipient, actIndex), presented, attempts, ...(terminal === undefined ? {} : { terminal }), attemptableRoutes: terminal === undefined ? routes.filter((route) => isWakeRouteAttemptable(route, attempts)) : [] };
111
- },
112
- };
82
+ const presentations = await projectPresentationEvidence({ hostLedger: input.hostLedger, location: canonicalLocation, now: input.now });
83
+ return projectWakeEligibility({ state: input.state, location: canonicalLocation, owners, wakeRecords, presentations, now: input.now, delivery });
113
84
  }
114
- export function wakeIsEligible(evidence) { return !evidence.delivered && !evidence.presented && evidence.terminal === undefined && evidence.attemptableRoutes.length > 0; }
@@ -25,9 +25,6 @@ function isNonblankString(value) {
25
25
  function isStringArray(value) {
26
26
  return Array.isArray(value) && value.every((item) => typeof item === 'string');
27
27
  }
28
- function validateObservation(value) {
29
- return isObject(value) && hasExactKeys(value, ['state', 'at']) && value.state === 'seen' && isFiniteNumber(value.at);
30
- }
31
28
  function validateWatchLease(value) {
32
29
  if (!isObject(value) || !hasExactKeys(value, ['leaseId', 'heartbeatAt', 'expiresAt'], ['filter'])
33
30
  || !isNonblankString(value.leaseId) || !isFiniteNumber(value.heartbeatAt)
@@ -46,7 +43,7 @@ function validateRuntime(value) {
46
43
  return isObject(value) && hasExactKeys(value, ['nextActIndex', 'observations', 'leases'])
47
44
  && isNonNegativeInteger(value.nextActIndex)
48
45
  && validateRecord(value.observations, (candidate) => isObject(candidate)
49
- && Object.entries(candidate).every(([id, observation]) => parseActivityId(id) !== undefined && validateObservation(observation)))
46
+ && Object.entries(candidate).every(([id, observation]) => parseActivityId(id) !== undefined && isObject(observation) && hasExactKeys(observation, ['state', 'at']) && observation.state === 'seen' && isFiniteNumber(observation.at)))
50
47
  && validateRecord(value.leases, validateWatchLease);
51
48
  }
52
49
  function validateAssignedRuntimeReferences(runtime) {
@@ -1,17 +1,14 @@
1
1
  import { createSquareState, diagnoseSquareFile as diagnoseArtifactFile } from './artifact.js';
2
2
  import type { SquareState } from './model.js';
3
- import type { StateCell } from './state-cell.js';
3
+ import type { SquareArtifactPort } from './ports.js';
4
4
  export { createSquareState };
5
- export declare function readSquareFile(squarePath: string): Promise<SquareState>;
6
5
  export declare function probeSquareFile(squarePath: string): Promise<SquareState | undefined>;
7
6
  export declare function diagnoseSquareFile(squarePath: string): ReturnType<typeof diagnoseArtifactFile>;
8
7
  /** Test-helper snapshot replacement backed by one SQLite artifact. */
9
8
  export declare function writeSquareSnapshot(squarePath: string, squareState: SquareState): Promise<void>;
10
9
  /** Race-safe non-force creation for the file adapter. */
11
10
  export declare function createSquareSnapshot(squarePath: string, squareState: SquareState): Promise<boolean>;
12
- /** In-process cell for fast behavior tests and embedded consumers. */
13
- export declare function createMemoryCell(initial: SquareState): StateCell;
14
- /** SQLite-backed cell. Revisions are read from the authoritative database, not file metadata. */
15
- export declare function createFileCell(squarePath: string, externalSignal?: AbortSignal): StateCell;
16
- /** Consumer-facing file cell factory; keeps SQLite framing behind this module. */
17
- export declare function openSquareCell(squarePath: string, signal?: AbortSignal): StateCell;
11
+ /** In-process artifact for fast behavior tests and embedded consumers. */
12
+ export declare function createMemoryCell(initial: SquareState): SquareArtifactPort;
13
+ /** SQLite-backed artifact. Revisions are read from the authoritative database, not file metadata. */
14
+ export declare function createFileCell(squarePath: string, externalSignal?: AbortSignal): SquareArtifactPort;
@@ -1,23 +1,7 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
1
  import { isDeepStrictEqual } from 'node:util';
4
- import { createSquareFile, createSquareState, diagnoseSquareFile as diagnoseArtifactFile, loadSquare, probeSquare, observeSquareRevision, readSquareSnapshot, transactSquareSnapshot, writeSquareFile, } from './artifact.js';
2
+ import { createSquareFile, createSquareState, diagnoseSquareFile as diagnoseArtifactFile, probeSquare, observeSquareRevision, readSquareSnapshot, transactSquareSnapshot, writeSquareFile, } from './artifact.js';
3
+ import { canonicalPath } from './canonical-path.js';
5
4
  export { createSquareState };
6
- async function canonicalPath(squarePath) {
7
- const absolute = path.resolve(squarePath);
8
- try {
9
- return await fs.promises.realpath(absolute);
10
- }
11
- catch (error) {
12
- if (error.code !== 'ENOENT')
13
- throw error;
14
- const parent = await fs.promises.realpath(path.dirname(absolute)).catch(() => path.dirname(absolute));
15
- return path.join(parent, path.basename(absolute));
16
- }
17
- }
18
- export async function readSquareFile(squarePath) {
19
- return loadSquare(await canonicalPath(squarePath));
20
- }
21
5
  export async function probeSquareFile(squarePath) {
22
6
  if (!squarePath.endsWith('.square'))
23
7
  return undefined;
@@ -39,7 +23,7 @@ function cloneState(squareState) {
39
23
  }
40
24
  function assertCellOpen(closed) {
41
25
  if (closed)
42
- throw new Error('StateCell is closed');
26
+ throw new Error('Square artifact is closed');
43
27
  }
44
28
  function isThenable(value) {
45
29
  return (typeof value === 'object' || typeof value === 'function') && value !== null
@@ -48,7 +32,7 @@ function isThenable(value) {
48
32
  function discardThenable(value) {
49
33
  void Promise.resolve(value).catch(() => undefined);
50
34
  }
51
- /** In-process cell for fast behavior tests and embedded consumers. */
35
+ /** In-process artifact for fast behavior tests and embedded consumers. */
52
36
  export function createMemoryCell(initial) {
53
37
  let state = cloneState(initial);
54
38
  let version = 0;
@@ -69,11 +53,11 @@ export function createMemoryCell(initial) {
69
53
  transact(fn, signal) {
70
54
  assertCellOpen(closed);
71
55
  if (signal?.aborted)
72
- throw signal.reason ?? new Error('StateCell operation aborted');
56
+ throw signal.reason ?? new Error('Square artifact operation aborted');
73
57
  const operation = tail.then(() => {
74
58
  assertCellOpen(closed);
75
59
  if (signal?.aborted)
76
- throw signal.reason ?? new Error('StateCell operation aborted');
60
+ throw signal.reason ?? new Error('Square artifact operation aborted');
77
61
  const current = cloneState(state);
78
62
  const outcome = fn(current, version);
79
63
  if (isThenable(outcome)) {
@@ -96,27 +80,27 @@ export function createMemoryCell(initial) {
96
80
  async read(signal) {
97
81
  assertCellOpen(closed);
98
82
  if (signal?.aborted)
99
- throw signal.reason ?? new Error('StateCell operation aborted');
83
+ throw signal.reason ?? new Error('Square artifact operation aborted');
100
84
  await tail;
101
85
  assertCellOpen(closed);
102
86
  if (signal?.aborted)
103
- throw signal.reason ?? new Error('StateCell operation aborted');
87
+ throw signal.reason ?? new Error('Square artifact operation aborted');
104
88
  return { state: cloneState(state), version };
105
89
  },
106
90
  changed(sinceVersion, timeoutMs, signal) {
107
91
  assertCellOpen(closed);
108
92
  if (signal?.aborted)
109
- return Promise.reject(signal.reason ?? new Error("StateCell operation aborted"));
93
+ return Promise.reject(signal.reason ?? new Error("Square artifact operation aborted"));
110
94
  if (version > sinceVersion)
111
95
  return Promise.resolve(true);
112
96
  if (timeoutMs <= 0)
113
97
  return Promise.resolve(false);
114
98
  return new Promise((resolve, reject) => {
115
99
  if (signal?.aborted) {
116
- reject(signal.reason ?? new Error("StateCell operation aborted"));
100
+ reject(signal.reason ?? new Error("Square artifact operation aborted"));
117
101
  return;
118
102
  }
119
- const abort = () => { waiters.delete(waiter); clearTimeout(waiter.timer); signal?.removeEventListener("abort", abort); reject(signal?.reason ?? new Error("StateCell operation aborted")); };
103
+ const abort = () => { waiters.delete(waiter); clearTimeout(waiter.timer); signal?.removeEventListener("abort", abort); reject(signal?.reason ?? new Error("Square artifact operation aborted")); };
120
104
  const waiter = {
121
105
  since: sinceVersion,
122
106
  resolve,
@@ -142,7 +126,7 @@ export function createMemoryCell(initial) {
142
126
  },
143
127
  };
144
128
  }
145
- /** SQLite-backed cell. Revisions are read from the authoritative database, not file metadata. */
129
+ /** SQLite-backed artifact. Revisions are read from the authoritative database, not file metadata. */
146
130
  export function createFileCell(squarePath, externalSignal) {
147
131
  let closed = false;
148
132
  let storage;
@@ -152,9 +136,6 @@ export function createFileCell(squarePath, externalSignal) {
152
136
  storage ??= canonicalPath(squarePath);
153
137
  return storage;
154
138
  }
155
- function abortError() {
156
- return new Error('StateCell is closed');
157
- }
158
139
  /** Close wins over every operation; a per-call deadline bounds busy retries alongside it. */
159
140
  function operationSignal(signal) {
160
141
  if (signal === undefined && externalSignal === undefined)
@@ -200,12 +181,8 @@ export function createFileCell(squarePath, externalSignal) {
200
181
  if (closed)
201
182
  return;
202
183
  closed = true;
203
- cancel.abort(abortError());
184
+ cancel.abort(new Error('Square artifact is closed'));
204
185
  await tail;
205
186
  },
206
187
  };
207
188
  }
208
- /** Consumer-facing file cell factory; keeps SQLite framing behind this module. */
209
- export function openSquareCell(squarePath, signal) {
210
- return createFileCell(squarePath, signal);
211
- }
@@ -31,8 +31,3 @@ export declare class Square {
31
31
  endOwnedSession(name: string, sessionId: string, expectedEpoch?: number): Promise<ExpressResult | null>;
32
32
  retireEndedSessionRoutes(sessionId: string): Promise<void>;
33
33
  }
34
- export declare function markBoundarySeen(squarePath: string, name: string, actIndexes: readonly number[], at?: number): Promise<void>;
35
- export declare function openParticipant(input: SquareAtInput, name: string): Promise<{
36
- readonly participant: Participant;
37
- close(): Promise<void>;
38
- }>;
@@ -1,10 +1,10 @@
1
1
  import { closeOpenSquare } from './open-square.js';
2
2
  import { buildMemorySquare, buildSquare, openSquare } from './square-file-adapter.js';
3
3
  import { catchUp, done, endOwnedSession, express, hold, ignore, implicitJoin, join, takeover, listen, listening, resume, retireEndedSessionRoutes, } from './square-actions.js';
4
- import { markBoundarySeen as recordBoundarySeen } from './presence.js';
5
- import { history, participantHistory, participants, resolveParticipant, snapshot } from './views.js';
4
+ import { history, participantHistory, participants, snapshot } from './views.js';
6
5
  import { currentParticipant } from './views.js';
7
6
  import { projectSessionBindings } from './square-projections.js';
7
+ import { sessionIdsFromEnvironment } from './participant-identity.js';
8
8
  class ParticipantHandle {
9
9
  name;
10
10
  square;
@@ -90,8 +90,7 @@ export class Square {
90
90
  async recognize(env) {
91
91
  if (this.square.hostLedger === undefined)
92
92
  return null;
93
- const sessions = [env.CLAUDE_CODE_SESSION_ID, env.CODEX_THREAD_ID, env.OPENCODE_SESSION_ID, env.PI_SESSION_ID, env.PASEO_AGENT_ID]
94
- .map((value) => value?.trim()).filter((value) => Boolean(value));
93
+ const sessions = sessionIdsFromEnvironment(env);
95
94
  if (sessions.length === 0)
96
95
  return null;
97
96
  const candidates = (await Promise.all(sessions.map((sessionId) => projectSessionBindings({
@@ -108,20 +107,3 @@ export class Square {
108
107
  endOwnedSession(name, sessionId, expectedEpoch) { return endOwnedSession(this.context, name, sessionId, expectedEpoch); }
109
108
  retireEndedSessionRoutes(sessionId) { return retireEndedSessionRoutes(this.context, sessionId); }
110
109
  }
111
- export function markBoundarySeen(squarePath, name, actIndexes, at) {
112
- return recordBoundarySeen(squarePath, name, actIndexes, at);
113
- }
114
- export async function openParticipant(input, name) {
115
- const square = await openSquare(input.path, input);
116
- try {
117
- const known = await resolveParticipant(square, name);
118
- return {
119
- participant: new ParticipantHandle(known.name, square, square),
120
- close: () => closeOpenSquare(square),
121
- };
122
- }
123
- catch (error) {
124
- await closeOpenSquare(square);
125
- throw error;
126
- }
127
- }
package/dist/stream.js CHANGED
@@ -5,10 +5,6 @@ import { quoteShell } from './presentation.js';
5
5
  import { openSquare, observeSquareChanges } from './square-file-adapter.js';
6
6
  import { closeOpenSquare } from './open-square.js';
7
7
  import { streamProjection, streamTailProjection } from './views.js';
8
- async function writeNdjson(value) {
9
- if (!process.stdout.write(`${JSON.stringify(value)}\n`))
10
- await once(process.stdout, 'drain');
11
- }
12
8
  /** Machine-readable tailing stays available; interactive terminal rendering was retired. */
13
9
  export async function cmdStreamNdjson(squarePath, recipient, start = { kind: 'tail', last: 10 }) {
14
10
  if (!fs.existsSync(squarePath)) {
@@ -31,8 +27,10 @@ export async function cmdStreamNdjson(squarePath, recipient, start = { kind: 'ta
31
27
  ? await streamProjection(square, cursor, recipient)
32
28
  : await streamTailProjection(square, initial.last, recipient);
33
29
  for (const item of projection.activities) {
34
- await writeNdjson({ seq: item.activity.index, square: squarePath, ...item.activity,
35
- ...(item.route === undefined ? {} : { route: item.route }) });
30
+ const line = { seq: item.activity.index, square: squarePath, ...item.activity,
31
+ ...(item.route === undefined ? {} : { route: item.route }) };
32
+ if (!process.stdout.write(`${JSON.stringify(line)}\n`))
33
+ await once(process.stdout, 'drain');
36
34
  }
37
35
  cursor = projection.cursor;
38
36
  initial = undefined;
package/dist/time.js CHANGED
@@ -1,12 +1,6 @@
1
1
  function pad(value, length = 2) {
2
2
  return String(value).padStart(length, '0');
3
3
  }
4
- function formatOffset(date) {
5
- const offsetMinutes = -date.getTimezoneOffset();
6
- const sign = offsetMinutes >= 0 ? '+' : '-';
7
- const abs = Math.abs(offsetMinutes);
8
- return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
9
- }
10
4
  export function formatTimestamp(at) {
11
5
  const date = new Date(at);
12
6
  const time = date.getMilliseconds() !== 0
@@ -14,10 +8,11 @@ export function formatTimestamp(at) {
14
8
  : date.getSeconds() !== 0
15
9
  ? `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
16
10
  : `${pad(date.getHours())}:${pad(date.getMinutes())}`;
11
+ const offsetMinutes = -date.getTimezoneOffset();
17
12
  return [
18
13
  `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
19
14
  time,
20
- formatOffset(date),
15
+ `${offsetMinutes >= 0 ? '+' : '-'}${pad(Math.floor(Math.abs(offsetMinutes) / 60))}:${pad(Math.abs(offsetMinutes) % 60)}`,
21
16
  ].join(' ');
22
17
  }
23
18
  export function formatDuration(ms) {