@astrosheep/square 0.3.46 → 0.3.47

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.47",
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.47",
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
  }
@@ -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 }),
@@ -46,15 +46,35 @@ export default function squarePiExtension(pi) {
46
46
  let retryWait;
47
47
  const present = (deliver, signal) => sessionId === undefined ? undefined : presentPendingAtBoundary(sessionId, deliver, undefined, undefined, signal);
48
48
 
49
- const presentAtBoundary = (deliver) => {
49
+ let boundaryPending;
50
+ const boundaryControllers = new Set();
51
+ const presentAtBoundary = async (deliver) => {
52
+ // One slow Square operation must neither hold Pi's prompt nor accumulate
53
+ // another unresolved lookup on every prompt while the first unwinds.
54
+ if (boundaryPending !== undefined || sessionId === undefined) return undefined;
50
55
  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) {
56
+ boundaryControllers.add(controller);
57
+ let timer;
58
+ const expired = new Promise((resolve) => {
59
+ controller.signal.addEventListener('abort', () => resolve(undefined), { once: true });
60
+ timer = setTimeout(() => controller.abort(new Error('Pi boundary presentation timed out')), piBoundaryTimeoutMs());
61
+ });
62
+ try {
63
+ const pending = present((context) => {
64
+ // A lookup may finish after the deadline or after session replacement.
65
+ // Refuse before rendering so that late work cannot acknowledge anything.
66
+ controller.signal.throwIfAborted();
67
+ return deliver(context);
68
+ }, controller.signal);
69
+ boundaryPending = pending;
70
+ void pending?.finally(() => {
71
+ if (boundaryPending === pending) boundaryPending = undefined;
72
+ }).catch(() => undefined);
73
+ return await Promise.race([pending, expired]);
74
+ } finally {
54
75
  clearTimeout(timer);
55
- return undefined;
76
+ boundaryControllers.delete(controller);
56
77
  }
57
- return pending.finally(() => clearTimeout(timer));
58
78
  };
59
79
 
60
80
  const waitForSettled = (serial, signal) => {
@@ -73,6 +93,7 @@ export default function squarePiExtension(pi) {
73
93
  // A transport may keep its promise pending while Pi is shutting down or
74
94
  // replacing a session. Never make a lifecycle hook wait for that transport.
75
95
  const stopWatcher = () => {
96
+ for (const controller of boundaryControllers) controller.abort(new Error('Pi session ended'));
76
97
  watcherAbort?.abort();
77
98
  watcher = undefined;
78
99
  watcherAbort = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.46",
3
+ "version": "0.3.47",
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
- }