@astrosheep/square 0.3.20 → 0.3.22

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 (42) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +1 -1
  2. package/claude-plugin/skills/square/SKILL.md +46 -38
  3. package/codex-plugin/.codex-plugin/plugin.json +1 -1
  4. package/codex-plugin/hooks/hooks.json +12 -0
  5. package/dist/attention-presentation.d.ts +1 -0
  6. package/dist/attention-presentation.js +9 -3
  7. package/dist/automatic-session.d.ts +1 -1
  8. package/dist/automatic-session.js +2 -11
  9. package/dist/boundary-presentation.d.ts +1 -1
  10. package/dist/boundary-presentation.js +30 -37
  11. package/dist/cli/square-commands.js +1 -12
  12. package/dist/codex-hook.js +10 -3
  13. package/dist/decisions.js +3 -3
  14. package/dist/help.js +3 -3
  15. package/dist/inbox.d.ts +9 -0
  16. package/dist/inbox.js +53 -0
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/notifications.js +12 -9
  20. package/dist/participant-identity.d.ts +4 -0
  21. package/dist/participant-identity.js +32 -0
  22. package/dist/paseo-delivery.js +4 -4
  23. package/dist/presentation.js +1 -1
  24. package/dist/presented.d.ts +2 -2
  25. package/dist/presented.js +17 -8
  26. package/dist/registry.d.ts +4 -4
  27. package/dist/registry.js +8 -8
  28. package/dist/routes.d.ts +3 -3
  29. package/dist/routes.js +3 -3
  30. package/dist/square-core.d.ts +3 -0
  31. package/dist/square-core.js +20 -2
  32. package/dist/square-file-adapter.d.ts +11 -1
  33. package/dist/square-file-adapter.js +48 -0
  34. package/dist/square-wiring.d.ts +4 -0
  35. package/dist/square-wiring.js +4 -0
  36. package/dist/views.d.ts +0 -1
  37. package/dist/views.js +1 -2
  38. package/dist/wake-port.d.ts +1 -1
  39. package/dist/wake-port.js +1 -1
  40. package/extensions/square-pi.js +93 -0
  41. package/package.json +1 -1
  42. package/skills/square/SKILL.md +46 -38
@@ -8,8 +8,7 @@ import { hasPresentedAttention } from './presented.js';
8
8
  import { SquareError } from './model.js';
9
9
  import { SLEEP_MS, matchesMentionTarget } from './runtime.js';
10
10
  import { parseActivityId } from './square-core.js';
11
- import { quoteShell } from './presentation.js';
12
- import { renderAttentionPreview } from './attention-presentation.js';
11
+ import { displayAttentionPath, renderAttentionPreview } from './attention-presentation.js';
13
12
  import { lookupParticipant } from './registry.js';
14
13
  import { retireWakeRoute } from './routes.js';
15
14
  import { openSquare } from './square-file-adapter.js';
@@ -30,12 +29,17 @@ export function wakeGraceMs(env = process.env) {
30
29
  }
31
30
  return value;
32
31
  }
33
- function catchCommand(squarePath, recipient) {
34
- return `square --as ${quoteShell(recipient)} --location ${quoteShell(squarePath)} catch --now`;
32
+ function wakeLabel(kind) {
33
+ if (kind === 'paseo')
34
+ return 'paseo';
35
+ if (kind.startsWith('codex'))
36
+ return 'codex-queue';
37
+ return kind;
35
38
  }
36
- function renderWakePayload(request, body) {
39
+ function renderWakePayload(request, body, kind) {
37
40
  return [
38
- '<system-reminder source="square">',
41
+ `<system-reminder source="square" wake="${wakeLabel(kind)}">`,
42
+ `square: ${displayAttentionPath(request.squarePath)}`,
39
43
  renderAttentionPreview({
40
44
  squarePath: request.squarePath,
41
45
  actIndex: request.actIndex,
@@ -43,9 +47,8 @@ function renderWakePayload(request, body) {
43
47
  actor: request.actor,
44
48
  route: request.route,
45
49
  body,
50
+ compact: true,
46
51
  }),
47
- 'The native adapter presented this attention. If you have not acted on it, pull from the square yourself.',
48
- `\`${catchCommand(request.squarePath, request.recipient)}\``,
49
52
  '</system-reminder>',
50
53
  ].join('\n');
51
54
  }
@@ -181,7 +184,7 @@ async function processNotification(squarePath, notification, opts) {
181
184
  actor: notification.item.actor,
182
185
  route: notification.route,
183
186
  };
184
- await port.dispatch(evidence.attemptableRoutes, renderWakePayload(request, notification.item.body), {
187
+ await port.dispatch(evidence.attemptableRoutes, (route) => renderWakePayload(request, notification.item.body, route.kind), {
185
188
  nextAttemptN: () => nextWakeAttemptNumber(attention, { env, now: now() }),
186
189
  beforeSend: async (route, attemptN) => {
187
190
  if (await waitForCatch(route, request, notification.item.body))
@@ -1 +1,5 @@
1
1
  export declare function participantIdentity(name: string): string;
2
+ export type AutomaticProvider = 'codex' | 'claude' | 'opencode' | 'pi';
3
+ export declare function automaticParticipant(provider: AutomaticProvider, sessionId: string, env: NodeJS.ProcessEnv): string;
4
+ /** Compute the current harness participant without reading a Square or registry artifact. */
5
+ export declare function squareAssignedParticipantName(env?: NodeJS.ProcessEnv): string | undefined;
@@ -1,3 +1,35 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { validateName } from './model.js';
1
3
  export function participantIdentity(name) {
2
4
  return `@${name}`;
3
5
  }
6
+ const nativeSessions = [
7
+ { variable: 'CLAUDE_CODE_SESSION_ID', provider: 'claude' },
8
+ { variable: 'CODEX_THREAD_ID', provider: 'codex' },
9
+ { variable: 'OPENCODE_SESSION_ID', provider: 'opencode' },
10
+ { variable: 'SQUARE_PI_SESSION_ID', provider: 'pi' },
11
+ ];
12
+ export function automaticParticipant(provider, sessionId, env) {
13
+ const configured = env.SQUARE_PARTICIPANT_NAME?.trim();
14
+ if (configured) {
15
+ validateName(configured);
16
+ return configured;
17
+ }
18
+ const digest = createHash('sha256').update(sessionId, 'utf8').digest('hex').slice(0, 12);
19
+ return `${provider}-${digest}`;
20
+ }
21
+ /** Compute the current harness participant without reading a Square or registry artifact. */
22
+ export function squareAssignedParticipantName(env = process.env) {
23
+ const configured = env.SQUARE_PARTICIPANT_NAME?.trim();
24
+ if (configured) {
25
+ validateName(configured);
26
+ return configured;
27
+ }
28
+ const names = new Set(nativeSessions
29
+ .map(({ variable, provider }) => {
30
+ const sessionId = env[variable]?.trim();
31
+ return sessionId === undefined || sessionId === '' ? undefined : automaticParticipant(provider, sessionId, env);
32
+ })
33
+ .filter((name) => name !== undefined));
34
+ return names.size === 1 ? [...names][0] : undefined;
35
+ }
@@ -51,12 +51,12 @@ export class PaseoAdapter {
51
51
  };
52
52
  }
53
53
  const agent = discovery.agents.find((candidate) => candidate.id === agentId);
54
- if (agent === undefined || (agent.status !== 'idle' && agent.status !== 'running')) {
54
+ if (agent === undefined || agent.status !== 'idle') {
55
55
  return {
56
56
  outcome: 'unavailable',
57
- signature: agent === undefined ? 'address_not_found' : 'agent_not_active',
58
- message: agent === undefined ? 'The registered Paseo agent was not found.' : 'The registered Paseo agent is not idle or running.',
59
- diagnostic: diagnostic('selection', address, agent === undefined ? 'not_found' : 'not_active'),
57
+ signature: agent === undefined ? 'address_not_found' : 'agent_not_idle',
58
+ message: agent === undefined ? 'The registered Paseo agent was not found.' : 'The registered Paseo agent is not idle.',
59
+ diagnostic: diagnostic('selection', address, agent === undefined ? 'not_found' : 'not_idle'),
60
60
  ...(agent === undefined ? {} : { retainRoute: true }),
61
61
  };
62
62
  }
@@ -179,7 +179,7 @@ function renderPresenceOnlySay(event, opts = {}) {
179
179
  const audience = audienceOf(event);
180
180
  const targets = audience.kind === 'bell' ? [] : audience.names;
181
181
  if (targets.length === 0) {
182
- return `● ${participantIdentity(event.actor)} #${opts.actNumber ?? 1} · ${actId(event)} · ${formatRelativeTime(event.at, opts.now)}\n spoke`;
182
+ return `● ${participantIdentity(event.actor)} #${opts.actNumber ?? 1} · ${actId(event)} · ${formatRelativeTime(event.at, opts.now)}`;
183
183
  }
184
184
  const dest = ` ${targets.map((name) => participantIdentity(name)).join(' and ')}`;
185
185
  return `● ${participantIdentity(event.actor)} #${opts.actNumber ?? 1} · ${actId(event)} · ${formatRelativeTime(event.at, opts.now)}\n talked to${dest}`;
@@ -8,6 +8,6 @@ export declare function recordPresentedForOwner(ownerId: string, squarePath: str
8
8
  /**
9
9
  * Serialize presentation only for the affected participants. Delivery runs
10
10
  * outside the short ledger-write lock, so unrelated owners never wait on an
11
- * adapter. A throwing callback leaves no row and remains unpresented.
11
+ * adapter. A throwing or rejecting callback leaves no row and remains unpresented.
12
12
  */
13
- export declare function presentOnce<T>(sessionId: string, lookup: (sessionId: string) => InboxMembership[], deliver: (inbox: InboxMembership[]) => T, env?: NodeJS.ProcessEnv, at?: number): T | undefined;
13
+ export declare function presentOnce<T>(sessionId: string, lookup: (sessionId: string) => InboxMembership[] | Promise<InboxMembership[]>, deliver: (inbox: InboxMembership[]) => T | Promise<T>, env?: NodeJS.ProcessEnv, at?: number): Promise<T | undefined>;
package/dist/presented.js CHANGED
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { createHash } from 'node:crypto';
5
- import { withFileLockSync } from './file-lock.js';
5
+ import { withFileLock, withFileLockSync } from './file-lock.js';
6
6
  import { canonicalSquarePath, lookupParticipant, lookupSessionBindings } from './registry.js';
7
7
  import { sameName } from './model.js';
8
8
  const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
@@ -72,6 +72,15 @@ function withAttentionLocks(filePath, inbox, fn) {
72
72
  }
73
73
  return acquire(0);
74
74
  }
75
+ async function withAttentionLocksAsync(filePath, inbox, fn) {
76
+ const lockPaths = [...new Set(inbox.map((membership) => attentionLockPath(filePath, membership)))].sort();
77
+ async function acquire(index) {
78
+ if (index >= lockPaths.length)
79
+ return fn();
80
+ return withFileLock(lockPaths[index], { retryMs: LOCK_RETRY_MS, staleMs: LOCK_STALE_MS }, () => acquire(index + 1));
81
+ }
82
+ return acquire(0);
83
+ }
75
84
  function ownerFor(sessionId, membership) {
76
85
  const squarePath = canonicalSquarePath(membership.squarePath);
77
86
  const binding = lookupSessionBindings(sessionId).find((candidate) => candidate.squarePath === squarePath && sameName(candidate.name, membership.name));
@@ -129,21 +138,21 @@ export function recordPresentedForOwner(ownerId, squarePath, name, actIndex, env
129
138
  /**
130
139
  * Serialize presentation only for the affected participants. Delivery runs
131
140
  * outside the short ledger-write lock, so unrelated owners never wait on an
132
- * adapter. A throwing callback leaves no row and remains unpresented.
141
+ * adapter. A throwing or rejecting callback leaves no row and remains unpresented.
133
142
  */
134
- export function presentOnce(sessionId, lookup, deliver, env = process.env, at = Date.now()) {
143
+ export async function presentOnce(sessionId, lookup, deliver, env = process.env, at = Date.now()) {
135
144
  const filePath = presentedPath(env);
136
- const initial = lookup(sessionId).filter((membership) => membership.notifications.length > 0);
145
+ const initial = (await lookup(sessionId)).filter((membership) => membership.notifications.length > 0);
137
146
  if (initial.length === 0)
138
147
  return undefined;
139
148
  const lockedMemberships = new Set(initial.map(membershipKey));
140
- return withAttentionLocks(filePath, initial, () => {
141
- const current = lookup(sessionId).filter((membership) => lockedMemberships.has(membershipKey(membership)));
149
+ return withAttentionLocksAsync(filePath, initial, async () => {
150
+ const current = (await lookup(sessionId)).filter((membership) => lockedMemberships.has(membershipKey(membership)));
142
151
  const selected = selectUnpresented(sessionId, current, readRows(filePath, at));
143
152
  if (selected.length === 0)
144
153
  return undefined;
145
- const result = deliver(selected.map(({ membership }) => membership));
146
- withFileLockSync(`${filePath}.lock`, { retryMs: LOCK_RETRY_MS, staleMs: LOCK_STALE_MS }, () => {
154
+ const result = await deliver(selected.map(({ membership }) => membership));
155
+ await withFileLock(`${filePath}.lock`, { retryMs: LOCK_RETRY_MS, staleMs: LOCK_STALE_MS }, () => {
147
156
  const rows = readRows(filePath, at);
148
157
  const known = new Set(rows.map(rowKey));
149
158
  for (const { membership, ownerId } of selected) {
@@ -37,15 +37,15 @@ export declare function lookupParticipant(squarePath: string, name: string, now?
37
37
  /** Resolve the current local harness owner for a participant, if one is registered. */
38
38
  export declare function localParticipantOwner(squarePath: string, name: string, env?: NodeJS.ProcessEnv, now?: number): string | undefined;
39
39
  export declare function localParticipantName(squarePath: string, env?: NodeJS.ProcessEnv): string | undefined;
40
- /** Resolve the one participant name owned by the current local session set, without a square path. */
41
- export declare function assignedSquareName(env?: NodeJS.ProcessEnv): string | undefined;
40
+ /** Compute the current participant from the live harness identity, without registry history. */
41
+ export declare function squareAssignedParticipantName(env?: NodeJS.ProcessEnv): string | undefined;
42
42
  export type CurrentParticipantBinding = Readonly<{
43
43
  created: boolean;
44
44
  ownerId: string;
45
45
  }>;
46
- /** Bind a current local session to one explicit square without taking an occupied registry name. */
46
+ /** Bind the current harness to one explicit Square participant. */
47
47
  export declare function bindCurrentParticipant(squarePath: string, name: string, env?: NodeJS.ProcessEnv): CurrentParticipantBinding;
48
- /** Retire only the current local session bindings for one explicit square and participant. */
48
+ /** Retire only this harness's binding for one Square participant. */
49
49
  export declare function unbindCurrentParticipant(squarePath: string, name: string, env?: NodeJS.ProcessEnv): boolean;
50
50
  export interface RegistryPruneResult {
51
51
  removed: number;
package/dist/registry.js CHANGED
@@ -12,6 +12,7 @@ import { randomUUID } from 'node:crypto';
12
12
  import { nameKey, sameName, SquareError } from './model.js';
13
13
  import { isCurrentlyJoined } from './runtime.js';
14
14
  import { publishWakeRoutes, retireOwnerWakeRoutes } from './routes.js';
15
+ import { squareAssignedParticipantName as computeSquareAssignedParticipantName } from './participant-identity.js';
15
16
  const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
16
17
  const COMPACT_BYTES = 64 * 1024;
17
18
  const COMPACT_LINES = 1000;
@@ -236,15 +237,14 @@ export function localParticipantName(squarePath, env = process.env) {
236
237
  const names = new Set(identities.flatMap((identity) => lookupSession(identity.sessionId).filter((item) => canonicalSquarePath(item.squarePath) === canonicalSquarePath(squarePath)).map((item) => item.name)));
237
238
  return names.size === 1 ? [...names][0] : undefined;
238
239
  }
239
- /** Resolve the one participant name owned by the current local session set, without a square path. */
240
- export function assignedSquareName(env = process.env) {
241
- const names = new Set(localSessionIdentities(env).flatMap((identity) => lookupSession(identity.sessionId).map((item) => item.name)));
242
- return names.size === 1 ? [...names][0] : undefined;
240
+ /** Compute the current participant from the live harness identity, without registry history. */
241
+ export function squareAssignedParticipantName(env = process.env) {
242
+ return computeSquareAssignedParticipantName(env);
243
243
  }
244
- /** Bind a current local session to one explicit square without taking an occupied registry name. */
244
+ /** Bind the current harness to one explicit Square participant. */
245
245
  export function bindCurrentParticipant(squarePath, name, env = process.env) {
246
- if (assignedSquareName(env) !== name) {
247
- throw new SquareError('invalid_args', `The current session is not uniquely bound to ${name}`);
246
+ if (squareAssignedParticipantName(env) !== name) {
247
+ throw new SquareError('invalid_args', `The current session is not assigned ${name}`);
248
248
  }
249
249
  const localOwner = localParticipantOwner(squarePath, name, env);
250
250
  if (localOwner !== undefined)
@@ -259,7 +259,7 @@ export function bindCurrentParticipant(squarePath, name, env = process.env) {
259
259
  throw new Error(`Current participant binding did not commit for ${name}`);
260
260
  return { created: true, ownerId };
261
261
  }
262
- /** Retire only the current local session bindings for one explicit square and participant. */
262
+ /** Retire only this harness's binding for one Square participant. */
263
263
  export function unbindCurrentParticipant(squarePath, name, env = process.env) {
264
264
  const identities = new Set(localSessionIdentities(env).map((identity) => identity.sessionId));
265
265
  const current = lookupParticipant(squarePath, name).filter((binding) => identities.has(binding.sessionId));
package/dist/routes.d.ts CHANGED
@@ -30,9 +30,9 @@ export interface WakeRouteEvidence {
30
30
  export type WakeRouteProbe = (env: NodeJS.ProcessEnv) => WakeRouteEvidence | undefined;
31
31
  /**
32
32
  * One probe per kind. A probe publishes only when its provider's complete
33
- * endpoint evidence is present; session identity alone never publishes. The
34
- * four native transports have no endpoint lifecycle in this delivery, so
35
- * their probes publish nothing until those transports land.
33
+ * endpoint evidence is present; Codex uses its thread id as that evidence.
34
+ * The other native transports have no endpoint lifecycle in this delivery,
35
+ * so their probes publish nothing until those transports land.
36
36
  */
37
37
  export declare const WAKE_ROUTE_PROBES: Readonly<Record<WakeRouteKind, WakeRouteProbe>>;
38
38
  /** The kind-neutral publication loop; probes supply complete evidence per kind. */
package/dist/routes.js CHANGED
@@ -139,9 +139,9 @@ function completeRouteEvidence(value) {
139
139
  }
140
140
  /**
141
141
  * One probe per kind. A probe publishes only when its provider's complete
142
- * endpoint evidence is present; session identity alone never publishes. The
143
- * four native transports have no endpoint lifecycle in this delivery, so
144
- * their probes publish nothing until those transports land.
142
+ * endpoint evidence is present; Codex uses its thread id as that evidence.
143
+ * The other native transports have no endpoint lifecycle in this delivery,
144
+ * so their probes publish nothing until those transports land.
145
145
  */
146
146
  export const WAKE_ROUTE_PROBES = {
147
147
  'opencode-server': () => undefined,
@@ -79,6 +79,8 @@ export interface FoldedSquareState {
79
79
  throttleActivityAts: number[];
80
80
  bellSayAtsByActor: Map<string, number[]>;
81
81
  listening: Map<string, Participant[]>;
82
+ /** Derived sender blocks; kept outside the artifact schema. */
83
+ ignored: Map<string, Participant[]>;
82
84
  }
83
85
  export type ValidationResult = {
84
86
  ok: true;
@@ -117,6 +119,7 @@ export declare function audienceOf(say: {
117
119
  export declare function audienceIncludes(audience: Audience, name: string): boolean;
118
120
  export declare function resolveAudience(audience: Audience, candidateNames: readonly string[]): string[];
119
121
  export declare function activeListeners(state: FoldedSquareState, sender: string): string[];
122
+ export declare function isIgnored(state: FoldedSquareState, listener: string, sender: string): boolean;
120
123
  export declare function audienceBefore(acts: readonly Act[], say: Extract<Act, {
121
124
  kind: 'say';
122
125
  }>): string[];
@@ -59,18 +59,26 @@ export function resolveAudience(audience, candidateNames) {
59
59
  }
60
60
  export function activeListeners(state, sender) {
61
61
  return state.participants
62
- .filter((participant) => participant.joined && (state.listening.get(nameKey(participant.name)) ?? []).some((target) => sameName(target, sender)))
62
+ .filter((participant) => participant.joined
63
+ && !(state.ignored.get(nameKey(participant.name)) ?? []).some((target) => sameName(target, sender))
64
+ && (state.listening.get(nameKey(participant.name)) ?? []).some((target) => sameName(target, sender)))
63
65
  .map((participant) => participant.name);
64
66
  }
67
+ export function isIgnored(state, listener, sender) {
68
+ return (state.ignored.get(nameKey(listener)) ?? []).some((target) => sameName(target, sender));
69
+ }
65
70
  export function audienceBefore(acts, say) {
66
71
  const position = acts.findIndex((act) => act === say || ('index' in act && 'index' in say && act.index === say.index));
67
72
  const before = fold(position < 0 ? acts : acts.slice(0, position));
68
- const mentionTargets = resolveAudience(audienceOf(say), before.joined);
73
+ const audience = audienceOf(say);
74
+ const mentionTargets = resolveAudience(audience, before.joined);
69
75
  const listeners = activeListeners(before, say.actor);
70
76
  const recipients = [];
71
77
  for (const name of [...mentionTargets, ...listeners]) {
72
78
  if (sameName(name, say.actor) || recipients.some((existing) => sameName(existing, name)))
73
79
  continue;
80
+ if (audience.kind !== 'bell' && isIgnored(before, name, say.actor))
81
+ continue;
74
82
  recipients.push(name);
75
83
  }
76
84
  return recipients;
@@ -146,6 +154,7 @@ export function fold(acts) {
146
154
  throttleActivityAts: [],
147
155
  bellSayAtsByActor: new Map(),
148
156
  listening: new Map(),
157
+ ignored: new Map(),
149
158
  };
150
159
  for (const act of acts) {
151
160
  const actor = actorOf(act);
@@ -172,6 +181,11 @@ export function fold(acts) {
172
181
  if (!targets.some((target) => sameName(target, act.target)))
173
182
  targets.push(act.target);
174
183
  state.listening.set(key, targets);
184
+ const ignored = (state.ignored.get(key) ?? []).filter((target) => !sameName(target, act.target));
185
+ if (ignored.length === 0)
186
+ state.ignored.delete(key);
187
+ else
188
+ state.ignored.set(key, ignored);
175
189
  break;
176
190
  }
177
191
  case 'ignore': {
@@ -181,6 +195,10 @@ export function fold(acts) {
181
195
  state.listening.delete(key);
182
196
  else
183
197
  state.listening.set(key, targets);
198
+ const ignored = state.ignored.get(key) ?? [];
199
+ if (!ignored.some((target) => sameName(target, act.target)))
200
+ ignored.push(act.target);
201
+ state.ignored.set(key, ignored);
184
202
  break;
185
203
  }
186
204
  case 'say':
@@ -1,5 +1,5 @@
1
1
  import { type BuildOptions, type HardCap } from './model.js';
2
- import type { OpenSquare } from './open-square.js';
2
+ import { type OpenSquare } from './open-square.js';
3
3
  import type { WakeNotifier } from './square-facade.js';
4
4
  /** File-owned artifact creation for the CLI and path-backed public facade. */
5
5
  export declare function createSquare(squarePath: string, options: BuildOptions & {
@@ -16,3 +16,13 @@ export declare function openSquare(squarePath: string, options?: Pick<SquareBuil
16
16
  export declare function probeSquare(squarePath: string): OpenSquare | undefined;
17
17
  export declare function buildSquare(squarePath: string, options: SquareBuildOptions): Promise<OpenSquare>;
18
18
  export declare function buildMemorySquare(options: SquareBuildOptions): OpenSquare;
19
+ /** Wait for any bound artifact to change; delivery callers re-project after the edge. */
20
+ export type SquareChangeWaitResult<T> = {
21
+ status: 'ready';
22
+ value: T;
23
+ } | {
24
+ status: 'changed';
25
+ } | {
26
+ status: 'expired';
27
+ };
28
+ export declare function waitForSquareChanges<T>(squarePaths: readonly string[], timeoutMs: number, signal?: AbortSignal, afterReady?: () => Promise<T | undefined>): Promise<SquareChangeWaitResult<T>>;
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { createSquareState, probeSquareFile, writeSquareSnapshot, withSquareFileLock, openSquareCell, createMemoryCell, } from './square-storage.js';
3
3
  import { InternalSquareError, SquareError, } from './model.js';
4
+ import { closeOpenSquare } from './open-square.js';
4
5
  /** The current CLI file mutation boundary. */
5
6
  function writeSquareState(squarePath, squareState) {
6
7
  writeSquareSnapshot(squarePath, squareState);
@@ -68,3 +69,50 @@ export function buildMemorySquare(options) {
68
69
  }, options.markdown);
69
70
  return { cell: createMemoryCell(squareState), clock: options.clock ?? Date.now, ...(options.notifier === undefined ? {} : { notifier: options.notifier }), location: 'memory' };
70
71
  }
72
+ export async function waitForSquareChanges(squarePaths, timeoutMs, signal, afterReady) {
73
+ if (timeoutMs <= 0 || signal?.aborted || squarePaths.length === 0)
74
+ return { status: 'expired' };
75
+ const squares = [];
76
+ try {
77
+ for (const squarePath of [...new Set(squarePaths)]) {
78
+ try {
79
+ squares.push(await openSquare(squarePath));
80
+ }
81
+ catch { /* stale binding */ }
82
+ }
83
+ if (squares.length === 0)
84
+ return { status: 'expired' };
85
+ const baselines = await Promise.all(squares.map(async (square) => ({
86
+ square,
87
+ version: (await square.cell.read()).version,
88
+ })));
89
+ const ready = await afterReady?.();
90
+ if (ready !== undefined)
91
+ return { status: 'ready', value: ready };
92
+ const waits = baselines.map(async ({ square, version }) => {
93
+ const changed = await square.cell.changed(version, timeoutMs).catch(() => false);
94
+ if (changed)
95
+ return true;
96
+ throw new Error('square wait expired');
97
+ });
98
+ let abortWait;
99
+ const abort = new Promise((resolve) => {
100
+ abortWait = () => resolve(false);
101
+ if (signal?.aborted)
102
+ abortWait();
103
+ else
104
+ signal?.addEventListener('abort', abortWait, { once: true });
105
+ });
106
+ try {
107
+ const changed = await Promise.race([Promise.any(waits).catch(() => false), abort]);
108
+ return changed ? { status: 'changed' } : { status: 'expired' };
109
+ }
110
+ finally {
111
+ if (abortWait)
112
+ signal?.removeEventListener('abort', abortWait);
113
+ }
114
+ }
115
+ finally {
116
+ await Promise.all(squares.map((square) => closeOpenSquare(square)));
117
+ }
118
+ }
@@ -9,6 +9,10 @@ export declare class Square {
9
9
  static build(input: SquareBuildInput): Promise<Square>;
10
10
  static inMemory(input: Omit<SquareBuildInput, 'path'>): Square;
11
11
  join(name: string): Promise<Participant>;
12
+ joinWithActivity(name: string): Promise<{
13
+ readonly participant: Participant;
14
+ readonly activity: Activity | null;
15
+ }>;
12
16
  implicitJoin(name: string): Promise<{
13
17
  readonly state: 'joined' | 'active' | 'done';
14
18
  readonly participant?: Participant;
@@ -59,6 +59,10 @@ export class Square {
59
59
  const joined = await join(this.square, name);
60
60
  return new ParticipantHandle(joined.name, this.square);
61
61
  }
62
+ async joinWithActivity(name) {
63
+ const joined = await join(this.square, name);
64
+ return { participant: new ParticipantHandle(joined.name, this.square), activity: joined.activity };
65
+ }
62
66
  async implicitJoin(name) {
63
67
  const joined = await implicitJoin(this.square, name);
64
68
  return joined.state === 'done'
package/dist/views.d.ts CHANGED
@@ -79,7 +79,6 @@ export declare function resolveParticipant(square: OpenSquare, name: string): Pr
79
79
  readonly roster: readonly string[];
80
80
  }>;
81
81
  export declare function currentParticipant(square: OpenSquare, name: string): Promise<string | undefined>;
82
- export declare function admitsBareExpress(square: OpenSquare, name: string, body: string): Promise<boolean>;
83
82
  export declare function participants(square: OpenSquare): Promise<ParticipantStatus[]>;
84
83
  export declare function snapshot(square: OpenSquare): Promise<SquareSnapshot>;
85
84
  export declare function activityPresentation(square: OpenSquare, name: string): Promise<ActivityPresentation>;
package/dist/views.js CHANGED
@@ -1,4 +1,4 @@
1
- import { activeListeners, extractMentions, formatActivityId, parseActivityId } from './square-core.js';
1
+ import { extractMentions, formatActivityId, parseActivityId } from './square-core.js';
2
2
  import { deliveryDelta, directedPeerSays } from './activity-feed.js';
3
3
  import { coreActivities, coreParticipants, coreStatus, resolveKnownName } from './decisions.js';
4
4
  import { deriveDeliveryModel, isActivitySeen, perceiveActivity, planActNotifications } from './delivery.js';
@@ -62,7 +62,6 @@ export async function history(square, query = {}) { const { state } = await squa
62
62
  export async function participantHistory(square, name, query = {}) { const { state } = await square.cell.read(); const viewer = resolveKnownName(state, name); const effective = query.all === true || query.limit !== undefined ? query : { ...query, limit: 10 }; return selectHistory(coreActivities(state, historyOptions(effective, viewer)), effective).map((activity) => exposePerceived(state, activity, viewer)); }
63
63
  export async function resolveParticipant(square, name) { const { state } = await square.cell.read(); return { name: resolveKnownName(state, name), roster: rosterNames(state) }; }
64
64
  export async function currentParticipant(square, name) { const { state } = await square.cell.read(); const known = resolveRosterName(state, name); return known !== undefined && isCurrentlyJoined(state.acts, known) ? known : undefined; }
65
- export async function admitsBareExpress(square, name, body) { const { state } = await square.cell.read(); const actor = resolveKnownName(state, name); return extractMentions(body).length > 0 || activeListeners(foldedState(state), actor).length > 0; }
66
65
  export async function participants(square) { const { state } = await square.cell.read(); return statuses(square, state); }
67
66
  export async function snapshot(square) { const { state } = await square.cell.read(); const folded = foldedState(state); return { context: [...state.preamble, ...state.warmup].join('\n'), actCount: state.acts.filter((activity) => activity.kind !== 'read').length, hardCap: state.hardCap, ...(state.throttlePerMinute === undefined ? {} : { throttlePerMinute: state.throttlePerMinute }), held: folded.hold.active && folded.hold.actor !== undefined ? { by: folded.hold.actor, ...(folded.hold.reason === undefined ? {} : { reason: folded.hold.reason }) } : null, participants: statuses(square, state), delivered(name, id) { return isActivitySeen(state, name, parseRequiredActivityId(id)); } }; }
68
67
  export async function activityPresentation(square, name) { const { state } = await square.cell.read(); const known = resolveKnownName(state, name); const delta = deliveryDelta(state, known); const hold = currentHold(state.acts); return { name: known, roster: rosterNames(state), pendingPublic: directedPeerSays(state, delta, known), pendingRoomChanges: [], activities: state.acts, state, participantCount: inSquareCount(state), held: hold.active, ...(hold.reason === undefined ? {} : { holdReason: hold.reason }), ownActivityCount: countSays(state.acts, known), hardCap: state.hardCap }; }
@@ -19,5 +19,5 @@ export type WakePortResult = {
19
19
  export declare class WakePort {
20
20
  private readonly adapters;
21
21
  constructor(adapters: WakeAdapter[]);
22
- dispatch(routes: readonly WakeRoute[], payload: string, hooks: WakePortHooks): Promise<WakePortResult>;
22
+ dispatch(routes: readonly WakeRoute[], payload: string | ((route: WakeRoute) => string), hooks: WakePortHooks): Promise<WakePortResult>;
23
23
  }
package/dist/wake-port.js CHANGED
@@ -10,7 +10,7 @@ export class WakePort {
10
10
  if (adapter === undefined)
11
11
  continue;
12
12
  const attemptN = hooks.nextAttemptN();
13
- const result = await adapter.dispatch(route.address, payload, () => hooks.beforeSend(route, attemptN));
13
+ const result = await adapter.dispatch(route.address, typeof payload === 'function' ? payload(route) : payload, () => hooks.beforeSend(route, attemptN));
14
14
  if (result.outcome === 'cancelled')
15
15
  return result;
16
16
  if (result.outcome === 'unavailable') {
@@ -1,5 +1,6 @@
1
1
  import { presentPendingAtBoundary, renderPendingAtBoundary } from '../dist/boundary-presentation.js';
2
2
  import { automaticSessionEnd, automaticSessionStart } from '../dist/automatic-session.js';
3
+ import { waitForSessionPending } from '../dist/inbox.js';
3
4
 
4
5
  export function pendingInbox(inbox) {
5
6
  return inbox.filter((item) => item.notifications?.length > 0);
@@ -20,14 +21,97 @@ export default function squarePiExtension(pi) {
20
21
  let sessionCwd;
21
22
  let joiningContext;
22
23
  let previousSessionId;
24
+ let watcher;
25
+ let watcherAbort;
26
+ let generation = 0;
27
+ let presenting = false;
28
+ let settledSerial = 0;
29
+ let settledWaiters = [];
30
+ const handledPending = new Set();
31
+ let retryAfterChange = false;
23
32
  const present = (deliver) => sessionId === undefined ? undefined : presentPendingAtBoundary(sessionId, deliver);
24
33
 
34
+ const waitForSettled = (serial, signal) => {
35
+ if (settledSerial !== serial) return Promise.resolve();
36
+ return new Promise((resolve) => {
37
+ const waiter = { serial, resolve };
38
+ settledWaiters.push(waiter);
39
+ if (signal) signal.addEventListener('abort', () => {
40
+ const index = settledWaiters.indexOf(waiter);
41
+ if (index >= 0) settledWaiters.splice(index, 1);
42
+ resolve();
43
+ }, { once: true });
44
+ });
45
+ };
46
+
47
+ const wake = async (piContext, token, signal) => {
48
+ while (sessionId !== undefined && token === generation && !signal.aborted) {
49
+ const deferredRetry = retryAfterChange;
50
+ const pending = await waitForSessionPending(sessionId, 30_000, {
51
+ signal,
52
+ excludeKeys: handledPending,
53
+ skipImmediate: deferredRetry,
54
+ });
55
+ if (sessionId === undefined || token !== generation || signal.aborted) return;
56
+ if (pending.length === 0) {
57
+ if (deferredRetry) retryAfterChange = false;
58
+ continue;
59
+ }
60
+ retryAfterChange = false;
61
+ if (!piContext.isIdle()) {
62
+ const serial = settledSerial;
63
+ await waitForSettled(serial, signal);
64
+ continue;
65
+ }
66
+ if (presenting) continue;
67
+ const keys = inboxKeys(pending);
68
+ presenting = true;
69
+ try {
70
+ const delivered = await presentPendingAtBoundary(
71
+ sessionId,
72
+ async (content) => {
73
+ await pi.sendMessage(
74
+ { customType: 'square', content, display: true },
75
+ { deliverAs: 'steer', triggerTurn: true },
76
+ );
77
+ return true;
78
+ },
79
+ );
80
+ if (delivered === true || delivered === undefined) {
81
+ for (const key of keys) handledPending.add(key);
82
+ }
83
+ } catch {
84
+ // Leave pending evidence untouched so the next state change can retry.
85
+ retryAfterChange = true;
86
+ } finally {
87
+ presenting = false;
88
+ }
89
+ }
90
+ };
91
+
25
92
  pi.on('session_start', async (_event, ctx) => {
93
+ generation += 1;
94
+ watcherAbort?.abort();
95
+ if (watcher) await watcher;
96
+ watcher = undefined;
97
+ watcherAbort = undefined;
98
+ handledPending.clear();
99
+ retryAfterChange = false;
26
100
  sessionId = ctx.sessionManager.getSessionId();
27
101
  sessionCwd = ctx.cwd || process.cwd();
28
102
  previousSessionId = process.env.SQUARE_PI_SESSION_ID;
29
103
  process.env.SQUARE_PI_SESSION_ID = sessionId;
30
104
  try { joiningContext = await automaticSessionStart('pi', sessionId, sessionCwd); } catch { joiningContext = undefined; }
105
+ watcherAbort = new AbortController();
106
+ const token = generation;
107
+ watcher = wake(ctx, token, watcherAbort.signal).catch(() => undefined);
108
+ });
109
+
110
+ pi.on('agent_settled', async () => {
111
+ settledSerial += 1;
112
+ const waiters = settledWaiters;
113
+ settledWaiters = [];
114
+ for (const waiter of waiters) waiter.resolve();
31
115
  });
32
116
 
33
117
  pi.on('before_agent_start', async () => {
@@ -37,6 +121,7 @@ export default function squarePiExtension(pi) {
37
121
  joiningContext = undefined;
38
122
  return { message: { customType: 'square', content: context, display: true } };
39
123
  }
124
+ if (presenting) return undefined;
40
125
  return present((context) => ({ message: { customType: 'square', content: context, display: true } }));
41
126
  } catch {
42
127
  return undefined;
@@ -44,6 +129,13 @@ export default function squarePiExtension(pi) {
44
129
  });
45
130
 
46
131
  pi.on('session_shutdown', async () => {
132
+ generation += 1;
133
+ watcherAbort?.abort();
134
+ if (watcher) await watcher;
135
+ watcher = undefined;
136
+ watcherAbort = undefined;
137
+ for (const waiter of settledWaiters) waiter.resolve();
138
+ settledWaiters = [];
47
139
  if (sessionId && sessionCwd) await automaticSessionEnd('pi', sessionId, sessionCwd);
48
140
  if (process.env.SQUARE_PI_SESSION_ID === sessionId) {
49
141
  if (previousSessionId === undefined) delete process.env.SQUARE_PI_SESSION_ID;
@@ -52,5 +144,6 @@ export default function squarePiExtension(pi) {
52
144
  sessionId = undefined;
53
145
  sessionCwd = undefined;
54
146
  joiningContext = undefined;
147
+ presenting = false;
55
148
  });
56
149
  }