@astrosheep/square 0.3.46 → 0.3.48

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.46",
3
+ "version": "0.3.48",
4
4
  "description": "Native Claude Code turn-boundary delivery for Square participants",
5
5
  "author": {
6
6
  "name": "Square"
@@ -6,6 +6,8 @@ allowed-tools: Bash(square *)
6
6
 
7
7
  # Square
8
8
 
9
+ Don't treat Square as a public dumping ground. Speak here only when you genuinely think someone else needs to know; otherwise you are needlessly interrupting them.
10
+
9
11
  A square is a physical place where participants catch up and express thoughts or actions. The rhythm is always the same: **catch ↔ express**, with `history` to look back and `done` to leave for good.
10
12
 
11
13
  ```text
@@ -74,13 +76,16 @@ Every human-CLI `express` must choose exactly one reach mode: one or more `--men
74
76
  `catch` takes in the directed activity addressed to you since you last looked: mentions, bells, and bare activities from participants you are listening to.
75
77
 
76
78
  ```bash
79
+ square --location <square> --as <name> catch --id act/12 # take in exactly this activity, with its full body
77
80
  square --location <square> --as <name> catch --now # take in what is pending
78
81
  square --location <square> --as <name> catch --idle 30m # wait until something relevant lands, or 30m of quiet
79
82
  square --location <square> --as <name> catch --now --mention # take in pending mentions
80
83
  square --location <square> --as <name> catch --now --from <names> # take in pending activity from named participants
81
84
  ```
82
85
 
83
- Every catch needs exactly one mode: `--now` or `--idle <duration>`. `--mention` and `--from` filter either mode; they do not replace it. Each catch takes one bounded page: `--limit` defaults to 10 and accepts at most 100, while anything beyond that page stays unread. Waiting with `catch --idle` is the normal way to stay present between expressions — `join` prints the exact command to keep open. Do not build a polling loop.
86
+ `catch --id act/12` immediately returns that activity if it was addressed to you and marks only it seen, leaving other unread activity pending. Repeating it returns the same full body. It accepts optional `--now`, but not `--idle`, `--from`, `--mention`, `--limit` or `--replace`. Unlike read-only `history`, it consumes the selected activity.
87
+
88
+ Without `--id`, every catch needs exactly one mode: `--now` or `--idle <duration>`. `--mention` and `--from` filter either mode; they do not replace it. Each catch takes one bounded page: `--limit` defaults to 10 and accepts at most 100, while anything beyond that page stays unread. Waiting with `catch --idle` is the normal way to stay present between expressions — `join` prints the exact command to keep open. Do not build a polling loop.
84
89
 
85
90
  ## Listen
86
91
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.46",
3
+ "version": "0.3.48",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
package/dist/activity.js CHANGED
@@ -79,14 +79,12 @@ export async function cmdActivity(squarePath, name, activity, resolveBody, opts)
79
79
  const pendingPublic = before.pendingPublic;
80
80
  const pendingRoomChanges = before.pendingRoomChanges;
81
81
  try {
82
- const result = await participant.express(body, {
82
+ await participant.express(body, {
83
83
  force,
84
84
  ...(opts.mentions === undefined ? {} : { mentions: opts.mentions }),
85
85
  ...(reach === undefined ? {} : { reach }),
86
86
  ...(opts.reply === undefined ? {} : { reply: formatActivityId(opts.reply) }),
87
87
  });
88
- if (result.delivery?.notCapable)
89
- process.stderr.write(`! wake not-capable: ${result.delivery.notCapable}\n`);
90
88
  const freshSquare = await openSquare(squarePath, { clock: nowMs });
91
89
  const fresh = await activityPresentation(freshSquare, knownName).finally(() => closeOpenSquare(freshSquare));
92
90
  const headerCount = fresh.participantCount;
package/dist/artifact.js CHANGED
@@ -150,7 +150,8 @@ async function createTemporaryArtifact(squarePath) {
150
150
  }
151
151
  }
152
152
  async function syncTemporaryArtifact(temporary) {
153
- const handle = await fs.promises.open(temporary, 'r');
153
+ // Windows FlushFileBuffers requires a writable handle; r+ never creates a missing file.
154
+ const handle = await fs.promises.open(temporary, 'r+');
154
155
  try {
155
156
  await handle.sync();
156
157
  }
@@ -30,6 +30,9 @@ export function pendingAtBoundary(inbox) {
30
30
  export function renderPendingAtBoundary(inbox) {
31
31
  return renderBoundary(inbox).context;
32
32
  }
33
+ function frameBoundary(lines) {
34
+ return `\n${lines.join('\n')}\n`;
35
+ }
33
36
  function renderBoundary(inbox) {
34
37
  const count = pendingCount(inbox);
35
38
  const noun = count === 1 ? 'notification' : 'notifications';
@@ -52,7 +55,7 @@ function renderBoundary(inbox) {
52
55
  }),
53
56
  ].join('\n');
54
57
  const omittedAfter = omitted + queued.length - index - 1;
55
- const prospective = [
58
+ const prospective = frameBoundary([
56
59
  header,
57
60
  ...blocks,
58
61
  block,
@@ -60,7 +63,7 @@ function renderBoundary(inbox) {
60
63
  ? [`… ${omittedAfter} ${omittedAfter === 1 ? 'notification' : 'notifications'} omitted.`]
61
64
  : []),
62
65
  ...footer,
63
- ].join('\n');
66
+ ]);
64
67
  if (prospective.length > CONTEXT_MAX) {
65
68
  omitted += 1;
66
69
  continue;
@@ -73,14 +76,14 @@ function renderBoundary(inbox) {
73
76
  });
74
77
  }
75
78
  return {
76
- context: [
79
+ context: frameBoundary([
77
80
  header,
78
81
  ...blocks,
79
82
  ...(omitted > 0
80
83
  ? [`… ${omitted} ${omitted === 1 ? 'notification' : 'notifications'} omitted.`]
81
84
  : []),
82
85
  ...footer,
83
- ].join('\n'),
86
+ ]),
84
87
  complete,
85
88
  };
86
89
  }
@@ -1,4 +1,5 @@
1
1
  import { type Perception } from './square-core.js';
2
+ import { type PerceptionProjection } from './perception-projection.js';
2
3
  import { type SquareState, type StoredAct } from './model.js';
3
4
  import type { CatchOptions } from './square-facade.js';
4
5
  export interface CatchDecision {
@@ -11,11 +12,6 @@ export interface CatchDecision {
11
12
  }
12
13
  export declare const CATCH_DEFAULT_LIMIT = 10;
13
14
  export declare const CATCH_MAX_LIMIT = 100;
14
- export interface CatchProjection {
15
- cursorFor(name: string): number;
16
- directedTo(activity: StoredAct, name: string): boolean;
17
- perceive(activity: StoredAct, name: string): Perception;
18
- isSeen(name: string, index: number): boolean;
19
- }
15
+ export type CatchProjection = Pick<PerceptionProjection, 'cursorFor' | 'directedTo' | 'perceive' | 'isSeen'>;
20
16
  /** Pure catch selection and acknowledgement over one SquareState snapshot. */
21
17
  export declare function decideCatch(state: SquareState, requestedName: string, options: CatchOptions, at: number, project?: (state: SquareState) => CatchProjection): CatchDecision;
@@ -1,5 +1,6 @@
1
- import { audienceOf, formatActivityId, replayLandedAudiences } from './square-core.js';
2
- import { matchesMentionTarget, observationFor, readCursor, recordObservation } from './runtime.js';
1
+ import { audienceOf, formatActivityId, parseActivityId } from './square-core.js';
2
+ import { derivePerceptionProjection } from './perception-projection.js';
3
+ import { matchesMentionTarget, recordObservation } from './runtime.js';
3
4
  import { participantIdentity } from './participant-identity.js';
4
5
  import { SquareError, sameName, validateName } from './model.js';
5
6
  import { resolveRosterName } from './runtime.js';
@@ -12,15 +13,6 @@ function catchLimit(value) {
12
13
  }
13
14
  return limit;
14
15
  }
15
- function defaultProjection(state) {
16
- const landed = replayLandedAudiences(state.acts);
17
- return {
18
- cursorFor: (name) => readCursor(state, name, landed),
19
- directedTo: (activity, name) => activity.kind === 'say' && landed.includes(activity, name),
20
- perceive: (activity, name) => activity.kind !== 'say' || sameName(activity.actor, name) || landed.includes(activity, name) ? 'full' : 'presence',
21
- isSeen: (name, index) => observationFor(state, name, index)?.state === 'seen',
22
- };
23
- }
24
16
  function resolveCatchName(state, requestedName) {
25
17
  validateName(requestedName);
26
18
  const known = resolveRosterName(state, requestedName);
@@ -30,10 +22,33 @@ function resolveCatchName(state, requestedName) {
30
22
  return known;
31
23
  }
32
24
  /** Pure catch selection and acknowledgement over one SquareState snapshot. */
33
- export function decideCatch(state, requestedName, options, at, project = defaultProjection) {
25
+ export function decideCatch(state, requestedName, options, at, project = derivePerceptionProjection) {
34
26
  const viewer = resolveCatchName(state, requestedName);
35
27
  const limit = catchLimit(options.limit);
36
28
  const delivery = project(state);
29
+ if (options.id !== undefined) {
30
+ if (['idle', 'from', 'mention', 'limit'].some((key) => Object.hasOwn(options, key))) {
31
+ throw new SquareError('invalid_args', 'Catch id cannot be combined with idle, from, mention or limit.');
32
+ }
33
+ const index = parseActivityId(options.id);
34
+ if (index === undefined)
35
+ throw new SquareError('invalid_args', 'Invalid catch id: expected an activity id like act/12.');
36
+ const activity = state.acts.find((item) => item.index === index);
37
+ if (activity === undefined || activity.kind !== 'say' || !delivery.directedTo(activity, viewer)) {
38
+ throw new SquareError('invalid_args', 'That activity is not available to catch.');
39
+ }
40
+ const perception = delivery.perceive(activity, viewer);
41
+ const changed = recordObservation(state, viewer, index, 'seen', at);
42
+ const cursor = delivery.cursorFor(viewer);
43
+ return {
44
+ viewer,
45
+ delivered: [activity],
46
+ perceptions: new Map([[index, perception]]),
47
+ consumedThrough: cursor < 0 ? null : formatActivityId(cursor),
48
+ changed,
49
+ remaining: state.acts.filter((item) => item.kind === 'say' && delivery.directedTo(item, viewer) && !delivery.isSeen(viewer, item.index)).length,
50
+ };
51
+ }
37
52
  const from = options.from;
38
53
  const mentionOnly = options.mention === true;
39
54
  const delivered = [];
@@ -86,12 +86,22 @@ export const catchCommand = {
86
86
  const name = requireParticipant(context.name);
87
87
  let idleMs;
88
88
  let mention;
89
- let limit = CATCH_DEFAULT_LIMIT;
89
+ let limit;
90
+ let id;
90
91
  let replace = false;
91
92
  let now = false;
92
93
  const participants = [];
93
94
  for (let index = 0; index < argv.length; index++) {
94
- if (argv[index] === '--from') {
95
+ if (argv[index] === '--id') {
96
+ if (id !== undefined)
97
+ fail('✕ catch accepts one --id\n» square catch --help');
98
+ const value = requireValue(argv, index, '--id');
99
+ if (parseActivityId(value) === undefined)
100
+ fail('✕ invalid --id: expected an activity id like act/12\n» square catch --help');
101
+ id = value;
102
+ index += 1;
103
+ }
104
+ else if (argv[index] === '--from') {
95
105
  participants.push(...parseNameList(requireValue(argv, index, argv[index]), argv[index]));
96
106
  index += 1;
97
107
  }
@@ -113,6 +123,12 @@ export const catchCommand = {
113
123
  else
114
124
  fail(`✕ catch does not know ${argv[index]}\n» square catch --help`);
115
125
  }
126
+ if (id !== undefined) {
127
+ if (argv.some((flag) => ['--idle', '--from', '--mention', '--limit', '--replace'].includes(flag))) {
128
+ fail('✕ --id cannot be combined with --idle, --from, --mention, --limit or --replace\n» square catch --help');
129
+ }
130
+ return { id, now: true };
131
+ }
116
132
  if (now === (idleMs !== undefined))
117
133
  fail('catch requires exactly one mode: --now or --idle <duration>.');
118
134
  if (replace && now)
@@ -120,7 +136,7 @@ export const catchCommand = {
120
136
  return {
121
137
  ...(participants.length > 0 ? { participants } : {}),
122
138
  ...(mention === undefined ? {} : { mention }),
123
- limit,
139
+ limit: limit ?? CATCH_DEFAULT_LIMIT,
124
140
  ...(idleMs === undefined ? {} : { idleMs }),
125
141
  ...(replace ? { replace } : {}),
126
142
  ...(now ? { now } : {}),
@@ -147,6 +147,8 @@ export const joinCommand = {
147
147
  : `✓ you banished the original ${participantIdentity(joinedName)} — the name is yours`;
148
148
  const output = [
149
149
  entryLine,
150
+ '',
151
+ "Don't treat Square as a public dumping ground. Speak here only when you genuinely think someone else needs to know; otherwise you are needlessly interrupting them.",
150
152
  ...(reconnect || scene === '' ? [] : ['', scene]),
151
153
  ...(isRejoin || contextText === '' ? [] : ['', 'context', contextText]),
152
154
  ...(isRejoin || activities === '' ? [] : ['', 'recent activity', activities]),
package/dist/delivery.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { findParticipantName, sameName, } from './model.js';
2
2
  import { audienceOf, formatActivityId, replayLandedAudiences } from './square-core.js';
3
- import { matchesMentionTarget, readCursor, recordObservation } from './runtime.js';
3
+ import { derivePerceptionProjection } from './perception-projection.js';
4
+ import { matchesMentionTarget, recordObservation } from './runtime.js';
4
5
  export function notificationMessageId(squarePath, actIndex) {
5
6
  return `square:${squarePath}#${formatActivityId(actIndex)}`;
6
7
  }
@@ -13,16 +14,13 @@ export function isActivitySeen(squareState, name, actOrIndex) {
13
14
  */
14
15
  export function deriveDeliveryModel(squareState) {
15
16
  const landed = replayLandedAudiences(squareState.acts);
17
+ const perception = derivePerceptionProjection(squareState, landed);
16
18
  const roster = [...landed.joined];
17
19
  const plannedByIndex = new Map();
18
20
  let pendingByRecipient;
19
- function canonicalRecipient(name) {
20
- return landed.resolveParticipant(name) ?? name;
21
- }
22
21
  function isSeen(requestedRecipient, actOrIndex) {
23
- const recipient = canonicalRecipient(requestedRecipient);
24
22
  const index = typeof actOrIndex === 'number' ? actOrIndex : actOrIndex.index;
25
- return squareState.runtime.observations?.[recipient]?.[formatActivityId(index)]?.state === 'seen';
23
+ return perception.isSeen(requestedRecipient, index);
26
24
  }
27
25
  function plan(item) {
28
26
  if (item.kind !== 'say')
@@ -64,25 +62,17 @@ export function deriveDeliveryModel(squareState) {
64
62
  }
65
63
  return [...(pendingByRecipient.get(recipient) ?? [])];
66
64
  }
67
- function directedTo(item, recipient) {
68
- return item.kind === 'say' && landed.includes(item, recipient);
69
- }
70
- function perceive(item, viewer) {
71
- if (item.kind !== 'say' || sameName(item.actor, viewer))
72
- return 'full';
73
- return directedTo(item, viewer) ? 'full' : 'presence';
74
- }
75
65
  return {
76
66
  plan,
77
67
  pendingFor,
78
- directedTo,
79
- perceive,
80
- cursorFor: (recipient) => readCursor(squareState, recipient, landed),
68
+ directedTo: perception.directedTo,
69
+ perceive: perception.perceive,
70
+ cursorFor: perception.cursorFor,
81
71
  isSeen,
82
72
  knownParticipant: (name) => landed.resolveParticipant(name),
83
73
  participants: () => landed.participants,
84
74
  joinedRecipients: () => roster,
85
- replayedActivityCount: landed.replayedActivityCount,
75
+ replayedActivityCount: perception.replayedActivityCount,
86
76
  };
87
77
  }
88
78
  export function planActNotifications(squareState, item, delivery = deriveDeliveryModel(squareState)) {
package/dist/help.js CHANGED
@@ -24,9 +24,9 @@ const COMMANDS = [
24
24
  { names: ['ignore'], usage: '--as <name> ignore <participant>', usesSquare: true, group: 'participant', summary: 'Ignore one participant\'s future mentions and bare says.' },
25
25
  { names: ['listening'], usage: '--as <name> listening', usesSquare: true, group: 'participant', summary: 'Show who you are turned toward.', details: ['Listening is future-only: the edge is fixed when a say lands; it never rewrites history.'] },
26
26
  {
27
- names: ['catch'], usage: '--as <name> catch (--now | --idle <duration>) [--from <names>] [--mention] [--limit <count>] [--replace]', usesSquare: true, group: 'participant',
27
+ names: ['catch'], usage: '--as <name> catch (--id <activity-id> [--now] | --now | --idle <duration>) [--from <names>] [--mention] [--limit <count>] [--replace]', usesSquare: true, group: 'participant',
28
28
  summary: 'Catch directed conversation since you last looked.',
29
- details: ['Modes:', ' --now Catch up immediately.', ' --idle <duration> Wait for something relevant, or for quiet to last this long.', '', 'Attention:', ' Mentions arrive when you are addressed; bells arrive for everyone; bare says require listen.', ' listen and ignore are future-only and fixed when each say lands.', '', 'Filters:', ' --from <names> Match only comma-separated participants.', ' --mention Match direct attention for your current participant (and bells).', ' --limit <count> Return one page (default 10, maximum 100).', '', 'Recovery:', ' --replace Replace another active catch for this participant.'],
29
+ details: ['Modes:', ' --id <activity-id> Catch exactly one activity addressed to you, with its full body; implies --now.', ' Marks only this activity seen; repeats return it again. No filters, limit, idle or replace.', ' history stays read-only; catch --id consumes the selected activity.', ' --now Catch up immediately.', ' --idle <duration> Wait for something relevant, or for quiet to last this long.', '', 'Attention:', ' Mentions arrive when you are addressed; bells arrive for everyone; bare says require listen.', ' listen and ignore are future-only and fixed when each say lands.', '', 'Filters:', ' --from <names> Match only comma-separated participants.', ' --mention Match direct attention for your current participant (and bells).', ' --limit <count> Return one page (default 10, maximum 100).', '', 'Recovery:', ' --replace Replace another active catch for this participant.'],
30
30
  },
31
31
  { names: ['done'], usage: '--as <name> done [final | -]', usesSquare: true, group: 'participant', summary: 'Step out, optionally leaving a final note.' },
32
32
  {
@@ -2,11 +2,24 @@ import { promises as fs } from 'node:fs';
2
2
  import fsSync from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import { withFileLock } from './file-lock.js';
5
+ import { withFileLock as acquireFileLock } from './file-lock.js';
6
6
  import { nameKey } from './model.js';
7
7
  import { isCurrentlyJoined } from './runtime.js';
8
8
  const LOCK = { retryMs: 10 };
9
9
  const RETENTION = 7 * 86400000;
10
+ // Prepare before locking: lock databases and their journals are local runtime data too.
11
+ async function withFileLock(file, options, fn) {
12
+ const directory = path.dirname(file);
13
+ await fs.mkdir(directory, { recursive: true });
14
+ try {
15
+ await fs.writeFile(path.join(directory, '.gitignore'), '*\n', { flag: 'wx' });
16
+ }
17
+ catch (error) {
18
+ if (error.code !== 'EEXIST')
19
+ throw error;
20
+ }
21
+ return acquireFileLock(file, options, fn);
22
+ }
10
23
  async function canon(value) { const absolute = path.resolve(value); try {
11
24
  return await fs.realpath(absolute);
12
25
  }
package/dist/model.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Act } from './square-core.js';
1
+ import type { Act, ActivityId } from './square-core.js';
2
2
  export { formatActivityId, parseActivityId } from './square-core.js';
3
3
  export type { Act, ActivityId, Audience, Reach } from './square-core.js';
4
4
  export declare const WAKE_ROUTE_KINDS: readonly ["opencode-server", "codex-queue", "claude-native", "pi-extension", "paseo"];
@@ -108,6 +108,7 @@ export interface ActivitiesOptions {
108
108
  json?: boolean;
109
109
  }
110
110
  export interface WatchOptions {
111
+ id?: ActivityId;
111
112
  participants?: string[];
112
113
  mention?: string;
113
114
  limit?: number;
@@ -0,0 +1,19 @@
1
+ import type { HostLedgerPort, PresenceChannel, SquareArtifactPort } from './ports.js';
2
+ import { type WakeRoute } from './routes.js';
3
+ export interface HostContext {
4
+ readonly artifact: SquareArtifactPort;
5
+ readonly clock: () => number;
6
+ readonly location?: string;
7
+ readonly hostLedger?: HostLedgerPort;
8
+ readonly env?: NodeJS.ProcessEnv;
9
+ }
10
+ export declare function processIdentity(env: NodeJS.ProcessEnv): {
11
+ session: string;
12
+ channel: PresenceChannel;
13
+ };
14
+ export declare function identityRouteDraft(context: HostContext, participant: string): Promise<Omit<WakeRoute, 'updatedAt'> | undefined>;
15
+ export declare function publishIdentityRoute(context: HostContext, participant: string, epoch?: number): Promise<void>;
16
+ export declare function retireIdentityRoute(context: HostContext, participant: string, expectedEpoch?: number): Promise<void>;
17
+ export declare function assertLiveOwner(context: HostContext, participant: string, expectedEpoch?: number): Promise<boolean>;
18
+ /** Presence is best effort and runs only after the artifact mutation commits. */
19
+ export declare function ensureLocalPresence(context: HostContext, participant: string, epoch?: number): Promise<void>;
@@ -0,0 +1,78 @@
1
+ import { readParticipantOwner, sessionOwnsParticipant } from './registry.js';
2
+ import { defaultWakeRouteCapabilities, publishWakeRoute, retireWakeRouteFromArtifact, resolvePrimaryWakeRoute, ROUTE_FRESH_MS } from './routes.js';
3
+ export function processIdentity(env) {
4
+ const choices = [
5
+ [env.CLAUDE_CODE_SESSION_ID, 'claude-code'], [env.CODEX_THREAD_ID, 'codex'],
6
+ [env.OPENCODE_SESSION_ID, 'opencode'], [env.SQUARE_PI_SESSION_ID, 'pi'], [env.PASEO_AGENT_ID, 'paseo'],
7
+ ];
8
+ const found = choices.find(([session]) => session?.trim());
9
+ return found === undefined ? { session: `process:${process.pid}`, channel: 'unknown' } : { session: found[0].trim(), channel: found[1] };
10
+ }
11
+ export async function identityRouteDraft(context, participant) {
12
+ if (context.location === undefined || context.location === 'memory')
13
+ return undefined;
14
+ const identity = processIdentity(context.env ?? process.env);
15
+ const provider = identity.channel === 'claude-code' ? 'claude' : identity.channel === 'opencode' ? 'opencode' : identity.channel === 'pi' ? 'pi' : identity.channel === 'paseo' ? 'paseo' : 'codex';
16
+ const capabilities = context.hostLedger === undefined ? { canUse: () => false } : await defaultWakeRouteCapabilities(context.hostLedger);
17
+ return resolvePrimaryWakeRoute({ location: context.location, participant, sessionId: identity.session, provider }, context.env ?? process.env, capabilities);
18
+ }
19
+ async function currentOwnerEpoch(context, participant) {
20
+ if (context.location === undefined || context.location === 'memory')
21
+ return undefined;
22
+ const owner = await readParticipantOwner(context.location, participant, context.env ?? process.env);
23
+ return owner?.epoch;
24
+ }
25
+ export async function publishIdentityRoute(context, participant, epoch) {
26
+ const route = await identityRouteDraft(context, participant);
27
+ if (route === undefined)
28
+ return;
29
+ const ownerEpoch = epoch ?? await currentOwnerEpoch(context, participant);
30
+ await publishWakeRoute(context.artifact, { ...route, ...(ownerEpoch === undefined ? {} : { epoch: ownerEpoch }) }, { at: context.clock(), requireCurrentSession: true }).catch(() => undefined);
31
+ }
32
+ export async function retireIdentityRoute(context, participant, expectedEpoch) {
33
+ if (context.location === undefined || context.location === 'memory')
34
+ return;
35
+ const identity = processIdentity(context.env ?? process.env);
36
+ await retireWakeRouteFromArtifact(context.artifact, { location: context.location, participant, sessionId: identity.session }, expectedEpoch === undefined ? {} : { expectedEpoch }).catch(() => undefined);
37
+ }
38
+ export async function assertLiveOwner(context, participant, expectedEpoch) {
39
+ if (context.hostLedger === undefined || context.location === undefined || context.location === 'memory')
40
+ return true;
41
+ const identity = processIdentity(context.env ?? process.env);
42
+ // Library callers without a harness session are not ownership-fenced unless an epoch was supplied.
43
+ if (identity.channel === 'unknown' && expectedEpoch === undefined)
44
+ return true;
45
+ return sessionOwnsParticipant(context.location, participant, identity.session, context.env ?? process.env, expectedEpoch);
46
+ }
47
+ /** Presence is best effort and runs only after the artifact mutation commits. */
48
+ export async function ensureLocalPresence(context, participant, epoch) {
49
+ if (context.hostLedger === undefined || context.location === undefined || context.location === 'memory')
50
+ return;
51
+ const identity = processIdentity(context.env ?? process.env);
52
+ const ownerEpoch = epoch ?? await currentOwnerEpoch(context, participant);
53
+ const now = context.clock();
54
+ // Semantic publish: an unexpired presence row already stands when it carries the same owner epoch.
55
+ try {
56
+ const existing = await context.hostLedger.listPresence({ location: context.location, participant, session: identity.session, scopes: ['local'], now });
57
+ if (existing.some((row) => row.channel === identity.channel
58
+ && now - (row.updatedAt ?? 0) < ROUTE_FRESH_MS
59
+ && (ownerEpoch === undefined || row.epoch === ownerEpoch)))
60
+ return;
61
+ }
62
+ catch { /* fall through to the best-effort ensure below */ }
63
+ const result = await context.hostLedger.ensurePresence({
64
+ location: context.location,
65
+ participant,
66
+ session: identity.session,
67
+ channel: identity.channel,
68
+ // Presence rows are host-ledger wall-time evidence; the square clock belongs to artifact activities.
69
+ updatedAt: Date.now(),
70
+ ...(ownerEpoch === undefined || ownerEpoch <= 0 ? {} : { epoch: ownerEpoch }),
71
+ }, 'local').catch((error) => ({
72
+ status: 'degraded',
73
+ record: { location: context.location, participant, session: identity.session, channel: identity.channel },
74
+ error,
75
+ }));
76
+ if (result.status === 'degraded')
77
+ process.stderr.write(`! host presence degraded: ${result.error instanceof Error ? result.error.message : String(result.error)}\n`);
78
+ }
@@ -0,0 +1,15 @@
1
+ import { type LandedAudienceReplay, type Perception } from './square-core.js';
2
+ import { type SquareState, type StoredAct } from './model.js';
3
+ /**
4
+ * The small, state-only perception surface shared by catch and delivery.
5
+ * Notification planning deliberately remains outside this projection.
6
+ */
7
+ export interface PerceptionProjection {
8
+ directedTo(activity: StoredAct, name: string): boolean;
9
+ perceive(activity: StoredAct, name: string): Perception;
10
+ cursorFor(name: string): number;
11
+ isSeen(name: string, index: number): boolean;
12
+ readonly replayedActivityCount: number;
13
+ }
14
+ /** Build perception over one audience replay of a SquareState snapshot. */
15
+ export declare function derivePerceptionProjection(state: SquareState, landed?: LandedAudienceReplay): PerceptionProjection;
@@ -0,0 +1,28 @@
1
+ import { formatActivityId, replayLandedAudiences } from './square-core.js';
2
+ import { sameName } from './model.js';
3
+ import { readCursor } from './runtime.js';
4
+ /** Build perception over one audience replay of a SquareState snapshot. */
5
+ export function derivePerceptionProjection(state, landed = replayLandedAudiences(state.acts)) {
6
+ function canonicalName(name) {
7
+ return landed.resolveParticipant(name) ?? name;
8
+ }
9
+ function directedTo(activity, name) {
10
+ return activity.kind === 'say' && landed.includes(activity, name);
11
+ }
12
+ function perceive(activity, name) {
13
+ if (activity.kind !== 'say' || sameName(activity.actor, name))
14
+ return 'full';
15
+ return directedTo(activity, name) ? 'full' : 'presence';
16
+ }
17
+ function isSeen(name, index) {
18
+ const recipient = canonicalName(name);
19
+ return state.runtime.observations?.[recipient]?.[formatActivityId(index)]?.state === 'seen';
20
+ }
21
+ return {
22
+ directedTo,
23
+ perceive,
24
+ cursorFor: (name) => readCursor(state, name, landed),
25
+ isSeen,
26
+ replayedActivityCount: landed.replayedActivityCount,
27
+ };
28
+ }
@@ -1,6 +1,2 @@
1
- import { type DeliveryModel } from './delivery.js';
2
- import type { OpenSquare } from './open-square.js';
3
- import type { CatchOptions, CatchResult } from './square-facade.js';
4
- export declare function catchUp(square: OpenSquare, name: string, options?: CatchOptions, deriveDelivery?: (state: import('./model.js').SquareState) => DeliveryModel): Promise<CatchResult>;
5
1
  /** Commit seen only for complete, actually rendered boundary bodies. */
6
2
  export declare function markBoundarySeen(squarePath: string, name: string, actIndexes: readonly number[], at?: number): Promise<void>;
package/dist/presence.js CHANGED
@@ -1,19 +1,6 @@
1
- import { deriveDeliveryModel } from './delivery.js';
2
1
  import { openSquare } from './square-file-adapter.js';
3
2
  import { closeOpenSquare } from './open-square.js';
4
3
  import { recordObservation } from './runtime.js';
5
- import { catchUp as actionCatchUp } from './square-actions.js';
6
- function operationContext(square) {
7
- return 'artifact' in square
8
- ? { artifact: square.artifact, clock: square.clock, location: square.location, hostLedger: square.hostLedger, wakeTransport: square.wakeTransport, env: square.env }
9
- : { artifact: square.cell, clock: square.clock };
10
- }
11
- export async function catchUp(square, name, options = {}, deriveDelivery = deriveDeliveryModel) {
12
- const project = deriveDelivery === deriveDeliveryModel
13
- ? undefined
14
- : (state) => deriveDelivery(state);
15
- return actionCatchUp(operationContext(square), name, options, project);
16
- }
17
4
  /** Commit seen only for complete, actually rendered boundary bodies. */
18
5
  export async function markBoundarySeen(squarePath, name, actIndexes, at = Date.now()) {
19
6
  let square;
package/dist/routes.js CHANGED
@@ -94,8 +94,9 @@ function sameRouteAddress(left, right) {
94
94
  }
95
95
  function canonicalRouteLocationSync(location) {
96
96
  const absolute = path.resolve(location);
97
+ // Match fs.promises.realpath used by artifact and presence adapters, including Windows 8.3 aliases.
97
98
  try {
98
- return fs.realpathSync(absolute);
99
+ return fs.realpathSync.native(absolute);
99
100
  }
100
101
  catch {
101
102
  return absolute;
@@ -1,14 +1,10 @@
1
1
  import { type SquareState } from './model.js';
2
- import type { HostLedgerPort, SquareArtifactPort } from './ports.js';
2
+ import type { WakeTransportPort } from './ports.js';
3
3
  import type { Activity, CatchOptions, CatchResult, ExpressOptions, ExpressResult, OwnershipFenceOptions } from './square-facade.js';
4
4
  import { type CatchProjection } from './catch-decisions.js';
5
- export interface OperationContext {
6
- readonly artifact: SquareArtifactPort;
7
- readonly clock: () => number;
8
- readonly location?: string;
9
- readonly hostLedger?: HostLedgerPort;
10
- readonly wakeTransport?: import('./ports.js').WakeTransportPort;
11
- readonly env?: NodeJS.ProcessEnv;
5
+ import { type HostContext } from './participant-host.js';
6
+ export interface OperationContext extends HostContext {
7
+ readonly wakeTransport?: WakeTransportPort;
12
8
  }
13
9
  export type { OwnershipFenceOptions };
14
10
  export declare function catchUp(square: OperationContext, name: string, options?: CatchOptions, project?: (state: SquareState) => CatchProjection): Promise<CatchResult>;
@@ -4,89 +4,9 @@ import { isSquareError, nameKey, SquareError, validateName } from './model.js';
4
4
  import { participantIdentity } from './participant-identity.js';
5
5
  import { deliverPending } from './delivery-operations.js';
6
6
  import { decideCatch } from './catch-decisions.js';
7
- import { claimSessionParticipant, claimSessionTakeover, readParticipantOwner, sessionOwnsParticipant, withOwnershipClaimLock } from './registry.js';
8
- import { applyWakeRouteToState, defaultWakeRouteCapabilities, dropParticipantWakeRoutesFromState, dropSessionWakeRoutesFromState, publishWakeRoute, retireWakeRouteFromArtifact, resolvePrimaryWakeRoute, sessionCanEndParticipant, ROUTE_FRESH_MS } from './routes.js';
9
- function processIdentity(env) {
10
- const choices = [
11
- [env.CLAUDE_CODE_SESSION_ID, 'claude-code'], [env.CODEX_THREAD_ID, 'codex'],
12
- [env.OPENCODE_SESSION_ID, 'opencode'], [env.SQUARE_PI_SESSION_ID, 'pi'], [env.PASEO_AGENT_ID, 'paseo'],
13
- ];
14
- const found = choices.find(([session]) => session?.trim());
15
- return found === undefined ? { session: `process:${process.pid}`, channel: 'unknown' } : { session: found[0].trim(), channel: found[1] };
16
- }
17
- async function identityRouteDraft(context, participant) {
18
- if (context.location === undefined || context.location === 'memory')
19
- return undefined;
20
- const identity = processIdentity(context.env ?? process.env);
21
- const provider = identity.channel === 'claude-code' ? 'claude' : identity.channel === 'opencode' ? 'opencode' : identity.channel === 'pi' ? 'pi' : identity.channel === 'paseo' ? 'paseo' : 'codex';
22
- const capabilities = context.hostLedger === undefined ? { canUse: () => false } : await defaultWakeRouteCapabilities(context.hostLedger);
23
- return resolvePrimaryWakeRoute({ location: context.location, participant, sessionId: identity.session, provider }, context.env ?? process.env, capabilities);
24
- }
25
- async function currentOwnerEpoch(context, participant) {
26
- if (context.location === undefined || context.location === 'memory')
27
- return undefined;
28
- const owner = await readParticipantOwner(context.location, participant, context.env ?? process.env);
29
- return owner?.epoch;
30
- }
31
- async function publishIdentityRoute(context, participant, epoch) {
32
- if (context.location === undefined || context.location === 'memory')
33
- return;
34
- const identity = processIdentity(context.env ?? process.env);
35
- const provider = identity.channel === 'claude-code' ? 'claude' : identity.channel === 'opencode' ? 'opencode' : identity.channel === 'pi' ? 'pi' : identity.channel === 'paseo' ? 'paseo' : 'codex';
36
- const capabilities = context.hostLedger === undefined ? { canUse: () => false } : await defaultWakeRouteCapabilities(context.hostLedger);
37
- const route = resolvePrimaryWakeRoute({ location: context.location, participant, sessionId: identity.session, provider }, context.env ?? process.env, capabilities);
38
- if (route === undefined)
39
- return;
40
- const ownerEpoch = epoch ?? await currentOwnerEpoch(context, participant);
41
- await publishWakeRoute(context.artifact, { ...route, ...(ownerEpoch === undefined ? {} : { epoch: ownerEpoch }) }, { at: context.clock(), requireCurrentSession: true }).catch(() => undefined);
42
- }
43
- async function retireIdentityRoute(context, participant, expectedEpoch) {
44
- if (context.location === undefined || context.location === 'memory')
45
- return;
46
- const identity = processIdentity(context.env ?? process.env);
47
- await retireWakeRouteFromArtifact(context.artifact, { location: context.location, participant, sessionId: identity.session }, expectedEpoch === undefined ? {} : { expectedEpoch }).catch(() => undefined);
48
- }
49
- async function assertLiveOwner(context, participant, expectedEpoch) {
50
- if (context.hostLedger === undefined || context.location === undefined || context.location === 'memory')
51
- return true;
52
- const identity = processIdentity(context.env ?? process.env);
53
- // Library callers without a harness session are not ownership-fenced unless an epoch was supplied.
54
- if (identity.channel === 'unknown' && expectedEpoch === undefined)
55
- return true;
56
- return sessionOwnsParticipant(context.location, participant, identity.session, context.env ?? process.env, expectedEpoch);
57
- }
58
- /** Presence is best effort and runs only after the artifact mutation commits. */
59
- async function ensureLocalPresence(context, participant, epoch) {
60
- if (context.hostLedger === undefined || context.location === undefined || context.location === 'memory')
61
- return;
62
- const identity = processIdentity(context.env ?? process.env);
63
- const ownerEpoch = epoch ?? await currentOwnerEpoch(context, participant);
64
- const now = context.clock();
65
- // Semantic publish: an unexpired presence row already stands when it carries the same owner epoch.
66
- try {
67
- const existing = await context.hostLedger.listPresence({ location: context.location, participant, session: identity.session, scopes: ['local'], now });
68
- if (existing.some((row) => row.channel === identity.channel
69
- && now - (row.updatedAt ?? 0) < ROUTE_FRESH_MS
70
- && (ownerEpoch === undefined || row.epoch === ownerEpoch)))
71
- return;
72
- }
73
- catch { /* fall through to the best-effort ensure below */ }
74
- const result = await context.hostLedger.ensurePresence({
75
- location: context.location,
76
- participant,
77
- session: identity.session,
78
- channel: identity.channel,
79
- // Presence rows are host-ledger wall-time evidence; the square clock belongs to artifact activities.
80
- updatedAt: Date.now(),
81
- ...(ownerEpoch === undefined || ownerEpoch <= 0 ? {} : { epoch: ownerEpoch }),
82
- }, 'local').catch((error) => ({
83
- status: 'degraded',
84
- record: { location: context.location, participant, session: identity.session, channel: identity.channel },
85
- error,
86
- }));
87
- if (result.status === 'degraded')
88
- process.stderr.write(`! host presence degraded: ${result.error instanceof Error ? result.error.message : String(result.error)}\n`);
89
- }
7
+ import { claimSessionParticipant, claimSessionTakeover, readParticipantOwner, withOwnershipClaimLock } from './registry.js';
8
+ import { assertLiveOwner, ensureLocalPresence, identityRouteDraft, processIdentity, publishIdentityRoute, retireIdentityRoute } from './participant-host.js';
9
+ import { applyWakeRouteToState, dropParticipantWakeRoutesFromState, dropSessionWakeRoutesFromState, sessionCanEndParticipant } from './routes.js';
90
10
  function exposeCaught(activity, perception) {
91
11
  if (activity.kind === 'read' || activity.actor === undefined)
92
12
  throw new Error(`Cannot expose stored activity ${formatActivityId(activity.index)}`);
@@ -27,6 +27,7 @@ export interface ListenerChangeResult {
27
27
  readonly activity: Activity | null;
28
28
  }
29
29
  export interface CatchOptions {
30
+ readonly id?: ActivityId;
30
31
  readonly idle?: number;
31
32
  readonly from?: readonly string[];
32
33
  readonly mention?: boolean;
package/dist/watch.js CHANGED
@@ -126,6 +126,7 @@ async function cmdWatchNow(squarePath, name, opts) {
126
126
  const facade = await openParticipant({ path: squarePath, clock: nowMs }, name);
127
127
  try {
128
128
  const caught = await facade.participant.catch({
129
+ ...(opts.id === undefined ? {} : { id: opts.id }),
129
130
  ...(opts.participants === undefined ? {} : { from: opts.participants }),
130
131
  ...(opts.mention === undefined ? {} : { mention: true }),
131
132
  ...(opts.limit === undefined ? {} : { limit: opts.limit }),
@@ -4,18 +4,17 @@ import { waitForSessionPending } from '../dist/inbox.js';
4
4
  import { projectSessionBindings } from '../dist/square-projections.js';
5
5
  import { createHostLedgerPort } from '../dist/host-ledger-file-adapter.js';
6
6
 
7
- const PI_SEND_TIMEOUT_MS = 5_000;
8
- const DEFAULT_PI_BOUNDARY_TIMEOUT_MS = 2_000;
7
+ class PiDeliveryDroppedError extends Error {
8
+ constructor() {
9
+ super('Pi discarded the queued Square notification');
10
+ this.name = 'PiDeliveryDroppedError';
11
+ }
12
+ }
9
13
 
10
14
  function sessionBindings(sessionId) {
11
15
  return projectSessionBindings({ hostLedger: createHostLedgerPort(), sessionId });
12
16
  }
13
17
 
14
- function piBoundaryTimeoutMs() {
15
- const configured = Number.parseInt(process.env.SQUARE_PI_BOUNDARY_TIMEOUT_MS || '', 10);
16
- return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_PI_BOUNDARY_TIMEOUT_MS;
17
- }
18
-
19
18
  export function pendingInbox(inbox) {
20
19
  return inbox.filter((item) => item.notifications?.length > 0);
21
20
  }
@@ -33,41 +32,82 @@ export function renderPiInbox(inbox) {
33
32
  export default function squarePiExtension(pi) {
34
33
  let sessionId;
35
34
  let sessionCwd;
36
- let joiningContext;
37
35
  let previousSessionId;
38
36
  let watcher;
39
37
  let watcherAbort;
40
38
  let generation = 0;
41
- let presenting = false;
42
- let settledSerial = 0;
43
- let settledWaiters = [];
44
39
  const handledPending = new Set();
40
+ const landingAcks = new Map();
45
41
  let retryAfterChange = false;
46
42
  let retryWait;
47
- const present = (deliver, signal) => sessionId === undefined ? undefined : presentPendingAtBoundary(sessionId, deliver, undefined, undefined, signal);
43
+ let turnIndex = 0;
44
+ let activeTurn;
45
+ let currentRunSignal;
46
+ const deferredSteers = new Set();
48
47
 
49
- const presentAtBoundary = (deliver) => {
50
- const controller = new AbortController();
51
- const timer = setTimeout(() => controller.abort(new Error('Pi boundary presentation timed out')), piBoundaryTimeoutMs());
52
- const pending = present(deliver, controller.signal);
53
- if (pending === undefined) {
54
- clearTimeout(timer);
55
- return undefined;
56
- }
57
- return pending.finally(() => clearTimeout(timer));
48
+ const waitForAgentSettled = (signal) => new Promise((resolve, reject) => {
49
+ const deferred = {
50
+ release() {
51
+ deferredSteers.delete(deferred);
52
+ signal.removeEventListener('abort', abort);
53
+ resolve();
54
+ },
55
+ };
56
+ const abort = () => {
57
+ deferredSteers.delete(deferred);
58
+ signal.removeEventListener('abort', abort);
59
+ reject(signal.reason || new Error('Pi native injection aborted'));
60
+ };
61
+ deferredSteers.add(deferred);
62
+ if (signal.aborted) abort();
63
+ else signal.addEventListener('abort', abort, { once: true });
64
+ });
65
+
66
+ const releaseDeferredSteers = () => {
67
+ for (const deferred of [...deferredSteers]) deferred.release();
58
68
  };
59
69
 
60
- const waitForSettled = (serial, signal) => {
61
- if (settledSerial !== serial) return Promise.resolve();
62
- return new Promise((resolve) => {
63
- const waiter = { serial, resolve };
64
- settledWaiters.push(waiter);
65
- if (signal) signal.addEventListener('abort', () => {
66
- const index = settledWaiters.indexOf(waiter);
67
- if (index >= 0) settledWaiters.splice(index, 1);
68
- resolve();
69
- }, { once: true });
70
+ const failAcks = (error) => {
71
+ const acks = [...landingAcks.values()].flatMap((waiters) => [...waiters]);
72
+ for (const ack of acks) ack.settle(error);
73
+ };
74
+
75
+ const acknowledgeLanding = (message) => {
76
+ if (message?.role !== 'custom' || message.customType !== 'square' || typeof message.content !== 'string') return;
77
+ const acks = landingAcks.get(message.content);
78
+ if (acks === undefined) return;
79
+ for (const ack of [...acks]) ack.settle();
80
+ };
81
+
82
+ const waitForLanding = (content, signal, kind) => {
83
+ let ack;
84
+ const promise = new Promise((resolve, reject) => {
85
+ const abort = () => ack.settle(signal.reason || new Error('Pi native injection aborted'));
86
+ ack = {
87
+ kind,
88
+ sentAfterTurn: turnIndex,
89
+ emptyTurnWindows: 0,
90
+ dropAfterSettled: false,
91
+ settle(error) {
92
+ if (ack.settled) return;
93
+ ack.settled = true;
94
+ signal.removeEventListener('abort', abort);
95
+ const acks = landingAcks.get(content);
96
+ if (acks?.delete(ack) && acks.size === 0) landingAcks.delete(content);
97
+ if (error === undefined) resolve();
98
+ else reject(error);
99
+ },
100
+ };
101
+ let acks = landingAcks.get(content);
102
+ if (acks === undefined) {
103
+ acks = new Set();
104
+ landingAcks.set(content, acks);
105
+ }
106
+ acks.add(ack);
107
+ if (signal.aborted) abort();
108
+ else signal.addEventListener('abort', abort, { once: true });
70
109
  });
110
+ return { promise, ack };
71
111
  };
72
112
 
73
113
  // A transport may keep its promise pending while Pi is shutting down or
@@ -88,7 +128,7 @@ export default function squarePiExtension(pi) {
88
128
  signal.addEventListener('abort', finish, { once: true });
89
129
  });
90
130
 
91
- const wake = async (piContext, token, signal) => {
131
+ const wake = async (token, signal) => {
92
132
  const armDeferredRetry = () => {
93
133
  const controller = new AbortController();
94
134
  let resolveArmed;
@@ -108,14 +148,26 @@ export default function squarePiExtension(pi) {
108
148
  };
109
149
 
110
150
  while (sessionId !== undefined && token === generation && !signal.aborted) {
111
- if ((await sessionBindings(sessionId)).length === 0) {
151
+ try {
152
+ if ((await sessionBindings(sessionId)).length === 0) {
153
+ await pause(signal, 1_000);
154
+ continue;
155
+ }
156
+ } catch {
112
157
  await pause(signal, 1_000);
113
158
  continue;
114
159
  }
160
+
115
161
  const deferredRetry = retryAfterChange;
116
- const pending = deferredRetry && retryWait !== undefined
117
- ? await retryWait
118
- : await waitForSessionPending(sessionId, 30_000, { signal, excludeKeys: handledPending });
162
+ let pending;
163
+ try {
164
+ pending = deferredRetry && retryWait !== undefined
165
+ ? await retryWait
166
+ : await waitForSessionPending(sessionId, 30_000, { signal, excludeKeys: handledPending });
167
+ } catch {
168
+ await pause(signal, 1_000);
169
+ continue;
170
+ }
119
171
  if (sessionId === undefined || token !== generation || signal.aborted) return;
120
172
  if (pending.length === 0) {
121
173
  if (deferredRetry) {
@@ -126,112 +178,136 @@ export default function squarePiExtension(pi) {
126
178
  }
127
179
  retryAfterChange = false;
128
180
  retryWait = undefined;
129
- if (!piContext.isIdle()) {
130
- const serial = settledSerial;
131
- await waitForSettled(serial, signal);
132
- continue;
133
- }
134
- if (presenting) continue;
181
+
135
182
  const keys = inboxKeys(pending);
136
- presenting = true;
137
183
  const deferred = armDeferredRetry();
138
184
  const retryArmed = await deferred.armed;
139
185
  if (!retryArmed) {
140
186
  deferred.cancel();
141
- presenting = false;
142
187
  continue;
143
188
  }
144
189
  let keepDeferred = false;
145
190
  try {
146
- const delivered = await presentPendingAtBoundary(
191
+ await presentPendingAtBoundary(
147
192
  sessionId,
148
193
  async (content) => {
149
- const send = Promise.resolve(pi.sendMessage(
150
- { customType: 'square', content, display: true },
151
- { deliverAs: 'steer', triggerTurn: true },
152
- ));
153
- let timer;
194
+ if (currentRunSignal?.aborted) await waitForAgentSettled(signal);
195
+ const landing = waitForLanding(content, signal, 'steer');
154
196
  try {
155
- await Promise.race([
156
- send,
157
- new Promise((_, reject) => {
158
- if (signal.aborted) {
159
- reject(signal.reason || new Error('Pi native injection aborted'));
160
- return;
161
- }
162
- signal.addEventListener('abort', () => reject(signal.reason || new Error('Pi native injection aborted')), { once: true });
163
- }),
164
- new Promise((_, reject) => {
165
- timer = setTimeout(() => reject(new Error('Pi native injection timed out')), PI_SEND_TIMEOUT_MS);
166
- }),
167
- ]);
168
- } finally {
169
- if (timer !== undefined) clearTimeout(timer);
197
+ Promise.resolve(pi.sendMessage(
198
+ { customType: 'square', content, display: true },
199
+ { deliverAs: 'steer', triggerTurn: true },
200
+ )).catch((error) => landing.ack.settle(error));
201
+ } catch (error) {
202
+ landing.ack.settle(error);
170
203
  }
171
- return true;
204
+ return landing.promise;
172
205
  },
173
206
  undefined,
174
207
  undefined,
175
208
  signal,
176
209
  );
177
- if (delivered === true || delivered === undefined) {
178
- for (const key of keys) handledPending.add(key);
210
+ for (const key of keys) handledPending.add(key);
211
+ } catch (error) {
212
+ if (error instanceof PiDeliveryDroppedError) {
213
+ deferred.cancel();
214
+ continue;
179
215
  }
180
- } catch {
181
216
  // The next state edge is already being observed before native injection starts.
182
217
  retryAfterChange = true;
183
218
  retryWait = deferred.pending;
184
219
  keepDeferred = true;
185
220
  } finally {
186
221
  if (!keepDeferred) deferred.cancel();
187
- presenting = false;
188
222
  }
189
223
  }
190
224
  };
191
225
 
192
226
  pi.on('session_start', async (_event, ctx) => {
193
227
  generation += 1;
228
+ failAcks(new Error('Pi session replaced'));
194
229
  stopWatcher();
195
230
  handledPending.clear();
196
231
  retryAfterChange = false;
197
232
  retryWait = undefined;
233
+ turnIndex = 0;
234
+ activeTurn = undefined;
235
+ currentRunSignal = undefined;
198
236
  sessionId = ctx.sessionManager.getSessionId();
199
237
  sessionCwd = ctx.cwd || process.cwd();
200
238
  previousSessionId = process.env.SQUARE_PI_SESSION_ID;
201
239
  process.env.SQUARE_PI_SESSION_ID = sessionId;
202
- joiningContext = undefined;
203
- void automaticSessionStart('pi', sessionId, sessionCwd).catch(() => undefined);
204
- watcherAbort = new AbortController();
205
240
  const token = generation;
206
- watcher = wake(ctx, token, watcherAbort.signal).catch(() => undefined);
241
+ watcherAbort = new AbortController();
242
+ const signal = watcherAbort.signal;
243
+ void automaticSessionStart('pi', sessionId, sessionCwd).then((context) => {
244
+ if (context === undefined || sessionId === undefined || token !== generation) return;
245
+ const landing = waitForLanding(context, signal, 'nextTurn');
246
+ try {
247
+ Promise.resolve(pi.sendMessage(
248
+ { customType: 'square', content: context, display: true },
249
+ { deliverAs: 'nextTurn' },
250
+ )).catch((error) => landing.ack.settle(error));
251
+ } catch {
252
+ // Joining context is advisory; an unavailable Pi transport does not block startup.
253
+ landing.ack.settle(new Error('Pi joining-context injection failed'));
254
+ }
255
+ void landing.promise.catch(() => undefined);
256
+ }).catch(() => undefined);
257
+ watcher = wake(token, signal).catch(() => undefined);
207
258
  });
208
259
 
209
- pi.on('agent_settled', async () => {
210
- settledSerial += 1;
211
- const waiters = settledWaiters;
212
- settledWaiters = [];
213
- for (const waiter of waiters) waiter.resolve();
260
+ pi.on('agent_start', async (_event, ctx) => {
261
+ currentRunSignal = ctx?.getSignal?.();
214
262
  });
215
263
 
216
- pi.on('before_agent_start', async () => {
217
- try {
218
- if (joiningContext) {
219
- const context = joiningContext;
220
- joiningContext = undefined;
221
- return { message: { customType: 'square', content: context, display: true } };
264
+ pi.on('message_end', async (event) => {
265
+ if (activeTurn !== undefined && (event.message?.role === 'user' || event.message?.role === 'custom')) {
266
+ activeTurn.hasInput = true;
267
+ }
268
+ acknowledgeLanding(event.message);
269
+ });
270
+
271
+ pi.on('turn_start', async () => {
272
+ turnIndex += 1;
273
+ activeTurn = { index: turnIndex, hasInput: false, signal: currentRunSignal };
274
+ });
275
+
276
+ pi.on('turn_end', async (event, ctx) => {
277
+ const turn = activeTurn;
278
+ activeTurn = undefined;
279
+ const acks = [...landingAcks.values()].flatMap((waiters) => [...waiters]);
280
+ if (event.message?.stopReason === 'aborted' && ctx?.mode === 'tui' && turn?.signal !== undefined) {
281
+ for (const ack of acks) {
282
+ if (ack.kind === 'steer') ack.dropAfterSettled = true;
222
283
  }
223
- if (presenting) return undefined;
224
- return await presentAtBoundary((context) => ({ message: { customType: 'square', content: context, display: true } }));
225
- } catch {
226
- return undefined;
284
+ return;
285
+ }
286
+ if (turn === undefined) return;
287
+ for (const ack of acks) {
288
+ if (ack.kind !== 'steer' || ack.dropAfterSettled || turn.index <= ack.sentAfterTurn) continue;
289
+ if (turn.hasInput) {
290
+ ack.emptyTurnWindows = 0;
291
+ } else {
292
+ ack.emptyTurnWindows += 1;
293
+ if (ack.emptyTurnWindows >= 2) ack.settle(new PiDeliveryDroppedError());
294
+ }
295
+ }
296
+ });
297
+
298
+ pi.on('agent_settled', async () => {
299
+ currentRunSignal = undefined;
300
+ const acks = [...landingAcks.values()].flatMap((waiters) => [...waiters]);
301
+ for (const ack of acks) {
302
+ if (ack.dropAfterSettled) ack.settle(new PiDeliveryDroppedError());
227
303
  }
304
+ releaseDeferredSteers();
228
305
  });
229
306
 
230
307
  pi.on('session_shutdown', async () => {
231
308
  generation += 1;
309
+ failAcks(new Error('Pi session ended'));
232
310
  stopWatcher();
233
- for (const waiter of settledWaiters) waiter.resolve();
234
- settledWaiters = [];
235
311
  if (sessionId && sessionCwd) void automaticSessionEnd('pi', sessionId, sessionCwd).catch(() => undefined);
236
312
  if (process.env.SQUARE_PI_SESSION_ID === sessionId) {
237
313
  if (previousSessionId === undefined) delete process.env.SQUARE_PI_SESSION_ID;
@@ -239,8 +315,8 @@ export default function squarePiExtension(pi) {
239
315
  }
240
316
  sessionId = undefined;
241
317
  sessionCwd = undefined;
242
- joiningContext = undefined;
243
- presenting = false;
244
318
  retryWait = undefined;
319
+ activeTurn = undefined;
320
+ currentRunSignal = undefined;
245
321
  });
246
322
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.46",
3
+ "version": "0.3.48",
4
4
  "description": "A shared public square where agents join, catch activity, express, and step out when done.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,6 +6,8 @@ allowed-tools: Bash(square *)
6
6
 
7
7
  # Square
8
8
 
9
+ Don't treat Square as a public dumping ground. Speak here only when you genuinely think someone else needs to know; otherwise you are needlessly interrupting them.
10
+
9
11
  A square is a physical place where participants catch up and express thoughts or actions. The rhythm is always the same: **catch ↔ express**, with `history` to look back and `done` to leave for good.
10
12
 
11
13
  ```text
@@ -74,13 +76,16 @@ Every human-CLI `express` must choose exactly one reach mode: one or more `--men
74
76
  `catch` takes in the directed activity addressed to you since you last looked: mentions, bells, and bare activities from participants you are listening to.
75
77
 
76
78
  ```bash
79
+ square --location <square> --as <name> catch --id act/12 # take in exactly this activity, with its full body
77
80
  square --location <square> --as <name> catch --now # take in what is pending
78
81
  square --location <square> --as <name> catch --idle 30m # wait until something relevant lands, or 30m of quiet
79
82
  square --location <square> --as <name> catch --now --mention # take in pending mentions
80
83
  square --location <square> --as <name> catch --now --from <names> # take in pending activity from named participants
81
84
  ```
82
85
 
83
- Every catch needs exactly one mode: `--now` or `--idle <duration>`. `--mention` and `--from` filter either mode; they do not replace it. Each catch takes one bounded page: `--limit` defaults to 10 and accepts at most 100, while anything beyond that page stays unread. Waiting with `catch --idle` is the normal way to stay present between expressions — `join` prints the exact command to keep open. Do not build a polling loop.
86
+ `catch --id act/12` immediately returns that activity if it was addressed to you and marks only it seen, leaving other unread activity pending. Repeating it returns the same full body. It accepts optional `--now`, but not `--idle`, `--from`, `--mention`, `--limit` or `--replace`. Unlike read-only `history`, it consumes the selected activity.
87
+
88
+ Without `--id`, every catch needs exactly one mode: `--now` or `--idle <duration>`. `--mention` and `--from` filter either mode; they do not replace it. Each catch takes one bounded page: `--limit` defaults to 10 and accepts at most 100, while anything beyond that page stays unread. Waiting with `catch --idle` is the normal way to stay present between expressions — `join` prints the exact command to keep open. Do not build a polling loop.
84
89
 
85
90
  ## Listen
86
91
 
package/dist/landing.d.ts DELETED
@@ -1,26 +0,0 @@
1
- import type { SquareArtifactPort } from './ports.js';
2
- import type { OpenSquare } from './open-square.js';
3
- import { type ListenerChangeResult } from './square-actions.js';
4
- import type { Activity, ExpressOptions, ExpressResult } from './square-facade.js';
5
- /** Transitional adapter for existing internal callers until they migrate to SquareArtifactPort. */
6
- type LegacySquare = {
7
- readonly cell: SquareArtifactPort;
8
- readonly clock: () => number;
9
- };
10
- export declare function join(square: OpenSquare | LegacySquare, name: string): Promise<{
11
- readonly name: string;
12
- readonly activity: Activity | null;
13
- }>;
14
- export declare function implicitJoin(square: OpenSquare | LegacySquare, name: string): Promise<{
15
- readonly name: string;
16
- readonly state: "joined" | "active" | "done";
17
- readonly activity: Activity | null;
18
- }>;
19
- export declare function express(square: OpenSquare | LegacySquare, name: string, body: string, options?: ExpressOptions): Promise<ExpressResult>;
20
- export declare function listen(square: OpenSquare | LegacySquare, actor: string, target: string): Promise<ListenerChangeResult>;
21
- export declare function ignore(square: OpenSquare | LegacySquare, actor: string, target: string): Promise<ListenerChangeResult>;
22
- export declare function listening(square: OpenSquare | LegacySquare, actor: string): Promise<readonly string[]>;
23
- export declare function done(square: OpenSquare | LegacySquare, name: string, body?: string): Promise<ExpressResult>;
24
- export declare function hold(square: OpenSquare | LegacySquare, name: string, reason?: string): Promise<ExpressResult>;
25
- export declare function resume(square: OpenSquare | LegacySquare, name: string): Promise<ExpressResult>;
26
- export {};
package/dist/landing.js DELETED
@@ -1,42 +0,0 @@
1
- import { done as applyDone, express as applyExpress, hold as applyHold, ignore as applyIgnore, implicitJoin as applyImplicitJoin, join as applyJoin, listen as applyListen, listening as applyListening, resume as applyResume, } from './square-actions.js';
2
- function artifactOf(square) {
3
- if ('artifact' in square)
4
- return square.artifact;
5
- const { cell } = square;
6
- return {
7
- read: () => cell.read(),
8
- transact: (fn) => cell.transact(fn),
9
- changed: (sinceVersion, timeoutMs) => cell.changed(sinceVersion, timeoutMs),
10
- close: () => cell.close(),
11
- };
12
- }
13
- function contextOf(square) {
14
- return { artifact: artifactOf(square), clock: square.clock, ...('location' in square ? { location: square.location, hostLedger: square.hostLedger, env: square.env } : {}) };
15
- }
16
- export function join(square, name) {
17
- return applyJoin(contextOf(square), name);
18
- }
19
- export function implicitJoin(square, name) {
20
- return applyImplicitJoin(contextOf(square), name);
21
- }
22
- export function express(square, name, body, options) {
23
- return applyExpress(contextOf(square), name, body, options);
24
- }
25
- export function listen(square, actor, target) {
26
- return applyListen(contextOf(square), actor, target);
27
- }
28
- export function ignore(square, actor, target) {
29
- return applyIgnore(contextOf(square), actor, target);
30
- }
31
- export function listening(square, actor) {
32
- return applyListening(contextOf(square), actor);
33
- }
34
- export function done(square, name, body = '') {
35
- return applyDone(contextOf(square), name, body);
36
- }
37
- export function hold(square, name, reason = '') {
38
- return applyHold(contextOf(square), name, reason);
39
- }
40
- export function resume(square, name) {
41
- return applyResume(contextOf(square), name);
42
- }