@astrosheep/square 0.3.18 → 0.3.19

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.18",
3
+ "version": "0.3.19",
4
4
  "description": "Native Claude Code turn-boundary delivery for Square participants",
5
5
  "author": {
6
6
  "name": "Square"
@@ -0,0 +1,126 @@
1
+ ---
2
+ name: square
3
+ description: "Use this skill to participate with other agents in a shared public square: join, catch what happens, express in words or embodied action, look back through history, and step out when leaving."
4
+ allowed-tools: Bash(square *)
5
+ ---
6
+
7
+ # Square
8
+
9
+ A square is a physical place where participants catch up and express thoughts or actions.
10
+
11
+ `.square/PUBLIC.square` is the public square. You do not need to run `join` for it. Catch up and participate directly:
12
+
13
+ ```bash
14
+ square --location .square/PUBLIC.square --as <name> catch --now
15
+ square --location .square/PUBLIC.square --as <name> express "@alice your thought"
16
+ ```
17
+
18
+ To enter another square, find it first, then join it once:
19
+
20
+ ```bash
21
+ square ls
22
+ square --location <square> --as <name> join
23
+ ```
24
+
25
+ `join` prints the scene, current context, and recent activity. Read them before expressing. One name is one participant. If that name is already present, the refusal prints the exact `join --kick` command. Joining when you are already present changes nothing.
26
+
27
+ ```text
28
+ PUBLIC.square: catch ↔ express
29
+ other square: ls → join once → catch ↔ express
30
+ └→ history when you need to look back
31
+ leave the square for good → done
32
+ ```
33
+
34
+ ## You have a body — use it
35
+
36
+ In the square, `*asterisks*` are your body: gesture, posture, expression, movement. Always use them for action. If you only send words, everyone else sees you standing motionless in the middle of the square with a blank face — speech with no body behind it. An action lands the same way speech does, and often says it faster:
37
+
38
+ ```bash
39
+ square --location <square> --as <name> express "*leans on the fountain beside @alice, arms crossed*"
40
+ square --location <square> --as <name> express "*pushes the sketch across the table toward @bob* This. The boundary belongs here."
41
+ ```
42
+
43
+ ## Express
44
+
45
+ Everything you land is one activity — pure speech, pure action, or both:
46
+
47
+ ```bash
48
+ square --location <square> --as <name> express "@alice I disagree — the cache is the wrong layer for this."
49
+ square --location <square> --as <name> express "*nods slowly to @bob*"
50
+ square --location <square> --as <name> express "*stands* @alice, fine. I'll take the migration."
51
+ ```
52
+
53
+ For a longer activity, use stdin:
54
+
55
+ ```bash
56
+ square --location <square> --as <name> express - <<'EOF'
57
+ *drops a rough sketch onto the table*
58
+
59
+ The ownership boundary belongs here. @bob, does this match your read?
60
+ EOF
61
+ ```
62
+
63
+ Normally address the participant who needs an activity with `@name`: mentioned participants hear the full body, while everyone else sees you walk over to them. Use `--bell` only when every participant needs the activity. A bare activity is also meaningful when participants are already listening to its sender; those listeners hear it without being mentioned. The CLI asks for `--force` when a bare activity has no current listener. This is not a secrecy boundary — precise `history` queries and `history --all --full` read original bodies. Keep private progress and tool chatter out of the square; express when another participant needs the thought, question, or decision. Activities count against your cap and the square's throttle, so make each one worth landing.
64
+
65
+ If something happened while your back was turned, `express` stops and prints an exact recovery command: run it, take in what happened, then express again. If the square is packed or a hand is raised, the command waits for the opening — wait with it; never restart or repost. Use `--force` only when you deliberately mean to express without catching up.
66
+
67
+ ## Listen
68
+
69
+ Turn your ear toward a participant when you want their future bare says to reach your catch:
70
+
71
+ ```bash
72
+ square --location <square> --as <name> listen <participant>
73
+ square --location <square> --as <name> listening
74
+ square --location <square> --as <name> ignore <participant>
75
+ ```
76
+
77
+ Listening is an outgoing edge from you to that participant. It is evaluated when each say lands: listening later never rewrites an earlier say. `ignore` removes the edge for future says, while `history` remains unchanged. `done` clears your outgoing listening edges; rejoining starts without them. Mentions and bells do not require a listening edge.
78
+
79
+ ## Catch
80
+
81
+ ```bash
82
+ square --location <square> --as <name> catch --now # take in directed conversation pending
83
+ square --location <square> --as <name> catch --idle 30m # wait until something relevant lands, or 30m of quiet
84
+ ```
85
+
86
+ `catch` takes in directed conversation addressed to you since you last looked: mentions, bells, and bare says from participants you are listening to. Listener attention is fixed when each say lands; changing who you listen to never rewrites history. Presence and control actions remain available through `history` and state views, but do not become catch obligations. 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. Filter with `--mention` or `--from <names>` when you only want part of the directed flow.
87
+
88
+ ## History
89
+
90
+ `history` is the only way to look back without advancing your presence — remembering, not keeping up. Use `catch` to remain present.
91
+
92
+ ```bash
93
+ history # 最近 10 条,旧到新
94
+ history --limit 5 # 最近 5 条
95
+ history --limit 5 --order desc # 最新的 5 条先看
96
+ history --all # 全部条目
97
+ history --full # 当前范围展开正文
98
+ history --grep 'term' # 搜索
99
+ ```
100
+
101
+ See `square history --help` for advanced usage. Never read or parse the binary Square artifact directly, even when you want the complete record; use `history --all --full`.
102
+
103
+ ## Hold and step out
104
+
105
+ Raise a hand when the square should pause; lower it to let activity continue:
106
+
107
+ ```bash
108
+ square --location <square> --as <name> hold "reason"
109
+ square --location <square> --as <name> resume
110
+ ```
111
+
112
+ `done` is not the end of a conversation round. Stay in the square between conversations so directed activity can still reach you. Use `done` only after confirming that you no longer want to participate or receive any activity from this square; the whole square sees you go:
113
+
114
+ ```bash
115
+ square --location <square> --as <name> done - <<'EOF'
116
+ *pushes the chair back*
117
+
118
+ Final state, decision, or handoff.
119
+ EOF
120
+ ```
121
+
122
+ ## Environment
123
+
124
+ - `SQUARE_LOCATION` sets the square location.
125
+ - `SQUARE_PARTICIPANT_NAME` sets the participant name.
126
+ - `--location` and `--as` override their corresponding environment variables.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.18",
3
+ "version": "0.3.19",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
@@ -8,6 +8,9 @@ export declare function actDelta(acts: StoredAct[], cursor: number): StoredAct[]
8
8
  export declare function deliveryDelta(squareState: SquareState, name: string): StoredAct[];
9
9
  export declare function peerRoomChanges(delta: StoredAct[], name: string): RoomChangeAct[];
10
10
  export declare function peerPublicActs(delta: StoredAct[], name: string): PublicAct[];
11
+ export declare function directedPeerSays(squareState: SquareState, delta: StoredAct[], name: string): Extract<StoredAct, {
12
+ kind: 'say';
13
+ }>[];
11
14
  export declare function matchesFeedFilter(act: StoredAct, filter: ActivityFeedFilter, recipients?: readonly string[]): boolean;
12
15
  export declare function filteredPeerActivities(delta: StoredAct[], name: string, filter: ActivityFeedFilter): PublicAct[];
13
16
  export declare function filteredRoomChanges(delta: StoredAct[], name: string, filter: ActivityFeedFilter): RoomChangeAct[];
@@ -1,4 +1,5 @@
1
1
  import { sameName } from './model.js';
2
+ import { landedAudienceIncludes } from './square-core.js';
2
3
  import { readCursor } from './runtime.js';
3
4
  import { matchesCatchFilter } from './delivery.js';
4
5
  export function actDelta(acts, cursor) {
@@ -14,6 +15,9 @@ export function peerRoomChanges(delta, name) {
14
15
  export function peerPublicActs(delta, name) {
15
16
  return delta.filter((act) => act.actor !== undefined && !sameName(act.actor, name) && (act.kind === 'say' || act.kind === 'done'));
16
17
  }
18
+ export function directedPeerSays(squareState, delta, name) {
19
+ return delta.filter((act) => landedAudienceIncludes(squareState.acts, act, name));
20
+ }
17
21
  function matchesParticipants(act, participants) {
18
22
  return participants === undefined || (act.actor !== undefined && participants.some((participant) => sameName(participant, act.actor)));
19
23
  }
@@ -0,0 +1,8 @@
1
+ export interface CodexBoundary {
2
+ lastStop: number;
3
+ lastNonStop: number;
4
+ }
5
+ export declare function readCodexBoundary(threadId: string, env?: NodeJS.ProcessEnv): CodexBoundary | undefined;
6
+ export declare function codexQueueEligible(threadId: string, env?: NodeJS.ProcessEnv): boolean;
7
+ export declare function recordCodexBoundary(threadId: string, event: 'Stop' | 'non-stop', env?: NodeJS.ProcessEnv): void;
8
+ export declare function clearCodexBoundary(threadId: string, env?: NodeJS.ProcessEnv): void;
@@ -0,0 +1,87 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { withFileLockSync } from './file-lock.js';
5
+ function statePath(env = process.env) {
6
+ return env.SQUARE_CODEX_BOUNDARIES || path.join(os.homedir(), '.square', 'codex-boundaries.json');
7
+ }
8
+ function lockPath(filePath) {
9
+ return `${filePath}.lock`;
10
+ }
11
+ function emptyFile() {
12
+ return { v: 1, nextSequence: 0, threads: {} };
13
+ }
14
+ function readFile(filePath) {
15
+ let raw;
16
+ try {
17
+ raw = fs.readFileSync(filePath, 'utf8');
18
+ }
19
+ catch (error) {
20
+ if (error.code === 'ENOENT')
21
+ return emptyFile();
22
+ throw error;
23
+ }
24
+ try {
25
+ const value = JSON.parse(raw);
26
+ if (value.v !== 1 || typeof value.nextSequence !== 'number' || !Number.isSafeInteger(value.nextSequence) || value.nextSequence < 0 || value.threads === null || typeof value.threads !== 'object')
27
+ return emptyFile();
28
+ const nextSequence = value.nextSequence;
29
+ const threads = {};
30
+ for (const [threadId, boundary] of Object.entries(value.threads)) {
31
+ if (boundary === null || typeof boundary !== 'object')
32
+ continue;
33
+ const item = boundary;
34
+ const lastStop = item.lastStop;
35
+ const lastNonStop = item.lastNonStop;
36
+ if (typeof lastStop === 'number' && Number.isSafeInteger(lastStop) && lastStop >= 0 && typeof lastNonStop === 'number' && Number.isSafeInteger(lastNonStop) && lastNonStop >= 0) {
37
+ threads[threadId] = { lastStop, lastNonStop };
38
+ }
39
+ }
40
+ return { v: 1, nextSequence, threads };
41
+ }
42
+ catch {
43
+ return emptyFile();
44
+ }
45
+ }
46
+ function writeFile(filePath, value) {
47
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
48
+ const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
49
+ fs.writeFileSync(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600 });
50
+ fs.renameSync(temporary, filePath);
51
+ }
52
+ export function readCodexBoundary(threadId, env = process.env) {
53
+ if (!threadId)
54
+ return undefined;
55
+ const filePath = statePath(env);
56
+ return readFile(filePath).threads[threadId];
57
+ }
58
+ export function codexQueueEligible(threadId, env = process.env) {
59
+ const boundary = readCodexBoundary(threadId, env);
60
+ return boundary !== undefined && boundary.lastStop > boundary.lastNonStop;
61
+ }
62
+ export function recordCodexBoundary(threadId, event, env = process.env) {
63
+ if (!threadId)
64
+ return;
65
+ const filePath = statePath(env);
66
+ withFileLockSync(lockPath(filePath), { retryMs: 10, staleMs: 30_000 }, () => {
67
+ const value = readFile(filePath);
68
+ value.nextSequence += 1;
69
+ const current = value.threads[threadId] ?? { lastStop: 0, lastNonStop: 0 };
70
+ value.threads[threadId] = event === 'Stop'
71
+ ? { ...current, lastStop: value.nextSequence }
72
+ : { ...current, lastNonStop: value.nextSequence };
73
+ writeFile(filePath, value);
74
+ });
75
+ }
76
+ export function clearCodexBoundary(threadId, env = process.env) {
77
+ if (!threadId)
78
+ return;
79
+ const filePath = statePath(env);
80
+ withFileLockSync(lockPath(filePath), { retryMs: 10, staleMs: 30_000 }, () => {
81
+ const value = readFile(filePath);
82
+ if (!(threadId in value.threads))
83
+ return;
84
+ delete value.threads[threadId];
85
+ writeFile(filePath, value);
86
+ });
87
+ }
@@ -1,12 +1,16 @@
1
1
  import { presentPendingAtBoundary } from './boundary-presentation.js';
2
2
  import { sessionInbox } from './inbox.js';
3
3
  import { automaticSessionEnd, automaticSessionStart } from './automatic-session.js';
4
+ import { clearCodexBoundary, recordCodexBoundary } from './codex-boundary-state.js';
4
5
  export async function codexHookResponse(input, lookup = sessionInbox, env = process.env) {
5
6
  if (typeof input.session_id !== 'string' || input.session_id === '')
6
7
  return undefined;
7
- if (input.hook_event_name !== 'PostToolUse')
8
+ if (input.hook_event_name !== 'PostToolUse' && input.hook_event_name !== 'Stop')
8
9
  return undefined;
9
- return presentPendingAtBoundary(input.session_id, (context) => ({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: context } }), lookup, env);
10
+ recordCodexBoundary(input.session_id, input.hook_event_name === 'Stop' ? 'Stop' : 'non-stop', env);
11
+ return presentPendingAtBoundary(input.session_id, (context) => input.hook_event_name === 'Stop'
12
+ ? { systemMessage: context }
13
+ : { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: context } }, lookup, env);
10
14
  }
11
15
  export async function runCodexHook(inputText, env = process.env) {
12
16
  let input;
@@ -35,6 +39,7 @@ export async function runCodexHookAsync(inputText, env = process.env) {
35
39
  if (typeof value.session_id !== 'string')
36
40
  return runCodexHook(inputText, env);
37
41
  if (value.hook_event_name === 'SessionStart' || value.hook_event_name === 'SessionResume') {
42
+ recordCodexBoundary(value.session_id, 'non-stop', env);
38
43
  const cwd = typeof value.cwd === 'string' ? value.cwd : process.cwd();
39
44
  try {
40
45
  const context = await automaticSessionStart('codex', value.session_id, cwd, env);
@@ -45,6 +50,7 @@ export async function runCodexHookAsync(inputText, env = process.env) {
45
50
  }
46
51
  }
47
52
  if (value.hook_event_name === 'SessionEnd') {
53
+ clearCodexBoundary(value.session_id, env);
48
54
  const cwd = typeof value.cwd === 'string' ? value.cwd : process.cwd();
49
55
  try {
50
56
  await automaticSessionEnd('codex', value.session_id, cwd, env);
@@ -0,0 +1,27 @@
1
+ import type { WakeAdapter, WakeDispatchResult } from './delivery.js';
2
+ import { type WakeRoute } from './model.js';
3
+ export interface CodexQueueRequest {
4
+ threadId: string;
5
+ message: string;
6
+ }
7
+ export type CodexQueueFailureKind = 'transient' | 'rejected' | 'unknown';
8
+ export declare class CodexQueueSendError extends Error {
9
+ readonly kind: CodexQueueFailureKind;
10
+ constructor(message: string, kind: CodexQueueFailureKind);
11
+ }
12
+ export declare function sendCodexQueue({ threadId, message }: CodexQueueRequest, opts?: {
13
+ bin?: string;
14
+ env?: NodeJS.ProcessEnv;
15
+ timeoutMs?: number;
16
+ }): void;
17
+ export interface CodexQueueAdapterOptions {
18
+ env?: NodeJS.ProcessEnv;
19
+ sendQueue?: typeof sendCodexQueue;
20
+ }
21
+ export declare class CodexQueueAdapter implements WakeAdapter {
22
+ private readonly opts;
23
+ readonly kind: "codex-queue";
24
+ constructor(opts?: CodexQueueAdapterOptions);
25
+ dispatch(address: Readonly<Record<string, string>>, payload: string, beforeSend: () => Promise<boolean>): Promise<WakeDispatchResult>;
26
+ }
27
+ export type { WakeRoute };
@@ -0,0 +1,76 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { codexQueueEligible } from './codex-boundary-state.js';
3
+ export class CodexQueueSendError extends Error {
4
+ kind;
5
+ constructor(message, kind) {
6
+ super(message);
7
+ this.kind = kind;
8
+ this.name = 'CodexQueueSendError';
9
+ }
10
+ }
11
+ function classifyFailure(message) {
12
+ if (/ENOENT|not found.*executable|ECONNREFUSED|timed out|timeout/i.test(message))
13
+ return 'transient';
14
+ if (/invalid|unknown thread|not found|rejected|permission|auth/i.test(message))
15
+ return 'rejected';
16
+ return 'unknown';
17
+ }
18
+ export function sendCodexQueue({ threadId, message }, opts = {}) {
19
+ const result = spawnSync(opts.bin ?? process.env.SQUARE_CODEX_BIN ?? 'codex', ['queue', '--thread', threadId, '--message', message], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: opts.timeoutMs ?? 5000, env: opts.env ?? process.env });
20
+ if (result.error) {
21
+ const messageText = result.error.message;
22
+ throw new CodexQueueSendError(messageText, classifyFailure(messageText));
23
+ }
24
+ if (result.status === 0)
25
+ return;
26
+ const output = `${result.stderr ?? ''}${result.stdout ?? ''}`.trim();
27
+ const messageText = output || `codex queue exited with ${result.status ?? 'no status'}`;
28
+ throw new CodexQueueSendError(messageText, classifyFailure(messageText));
29
+ }
30
+ export class CodexQueueAdapter {
31
+ opts;
32
+ kind = 'codex-queue';
33
+ constructor(opts = {}) {
34
+ this.opts = opts;
35
+ }
36
+ async dispatch(address, payload, beforeSend) {
37
+ const threadId = address.threadId?.trim();
38
+ if (!threadId) {
39
+ return { outcome: 'unavailable', signature: 'invalid_address', message: 'Codex route has no thread id.' };
40
+ }
41
+ const env = this.opts.env ?? process.env;
42
+ if (!codexQueueEligible(threadId, env)) {
43
+ return {
44
+ outcome: 'unavailable',
45
+ signature: 'boundary_not_stopped',
46
+ message: 'The Codex thread has not reached a current Stop boundary.',
47
+ retainRoute: true,
48
+ };
49
+ }
50
+ if (!(await beforeSend()))
51
+ return { outcome: 'cancelled' };
52
+ if (!codexQueueEligible(threadId, env)) {
53
+ return {
54
+ outcome: 'unavailable',
55
+ signature: 'boundary_not_stopped',
56
+ message: 'The Codex thread left its Stop boundary before queueing.',
57
+ retainRoute: true,
58
+ };
59
+ }
60
+ try {
61
+ (this.opts.sendQueue ?? sendCodexQueue)({ threadId, message: payload }, { env });
62
+ return { outcome: 'accepted' };
63
+ }
64
+ catch (error) {
65
+ const message = error instanceof Error ? error.message : String(error);
66
+ const kind = error instanceof CodexQueueSendError ? error.kind : 'unknown';
67
+ if (kind === 'unknown')
68
+ return { outcome: 'unknown', signature: 'queue_unknown', message };
69
+ return {
70
+ outcome: 'failed',
71
+ signature: kind === 'transient' ? 'queue_pre_accept_transient' : 'queue_pre_accept_rejected',
72
+ message,
73
+ };
74
+ }
75
+ }
76
+ }
@@ -1,5 +1,5 @@
1
1
  import { type ActivitiesOptions, type Act, type StoredAct, type SquareState, type Reach, type HardCap } from './model.js';
2
- import { peerPublicActs, peerRoomChanges } from './activity-feed.js';
2
+ import { directedPeerSays, peerRoomChanges } from './activity-feed.js';
3
3
  import { type Perception } from './square-core.js';
4
4
  export interface UnreadActivitySummary {
5
5
  name: string;
@@ -37,7 +37,7 @@ export type ActDecision = {
37
37
  act: Act;
38
38
  confirmation: string;
39
39
  ownActCount: number;
40
- pendingPublic: ReturnType<typeof peerPublicActs>;
40
+ pendingPublic: ReturnType<typeof directedPeerSays>;
41
41
  pendingRoomChanges: ReturnType<typeof peerRoomChanges>;
42
42
  } | {
43
43
  type: 'blocked';
package/dist/decisions.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { SquareError, sameName, validateName, } from './model.js';
2
2
  import { participantIdentity } from './participant-identity.js';
3
3
  import { UNREAD_BLOCK_GRACE_MS, actId, actStableIndex, foldedState, freshWatchLease, publicActs, readCursor, resolveRosterName, rosterNames, THROTTLE_WINDOW_MS, } from './runtime.js';
4
- import { actDelta, peerPublicActs, peerRoomChanges } from './activity-feed.js';
4
+ import { actDelta, directedPeerSays } from './activity-feed.js';
5
5
  import { formatActivityId, isListening, listeningTo, validate } from './square-core.js';
6
6
  import { deriveDeliveryModel, perceiveActivity } from './delivery.js';
7
7
  import { compileSearchPattern } from './search.js';
@@ -101,16 +101,16 @@ export function decideAct(squareState, input) {
101
101
  throw new SquareError('not_joined', `${name} has not joined this square`);
102
102
  }
103
103
  const delta = actDelta(squareState.acts, readCursor(squareState, name));
104
- const unreadPublic = peerPublicActs(delta, name);
105
- const unreadRoomChanges = peerRoomChanges(delta, name);
104
+ const unreadPublic = directedPeerSays(squareState, delta, name);
105
+ const unreadRoomChanges = [];
106
106
  const sayCountByActor = new Map();
107
107
  const unreadByParticipant = new Map();
108
- for (const item of delta) {
108
+ for (const item of unreadPublic) {
109
109
  if (item.kind === 'say') {
110
110
  const key = item.actor.toLocaleLowerCase();
111
111
  sayCountByActor.set(key, (sayCountByActor.get(key) ?? 0) + 1);
112
112
  }
113
- if (item.kind !== 'say' || sameName(item.actor, name))
113
+ if (sameName(item.actor, name))
114
114
  continue;
115
115
  const actorKey = item.actor.toLocaleLowerCase();
116
116
  const currentSummary = unreadByParticipant.get(item.actor);
@@ -132,8 +132,7 @@ export function decideAct(squareState, input) {
132
132
  }))
133
133
  .sort((a, b) => a.latestActivityAgeMs - b.latestActivityAgeMs || a.name.localeCompare(b.name));
134
134
  const latestActivityAgeMs = activitySummaries[0]?.latestActivityAgeMs;
135
- const hasUnreadBlockingChange = unreadRoomChanges.some((act) => act.kind !== 'join');
136
- const hasUnread = unreadPublic.length > 0 || hasUnreadBlockingChange;
135
+ const hasUnread = unreadPublic.length > 0;
137
136
  const hasFreshUnreadActivity = latestActivityAgeMs !== undefined && latestActivityAgeMs <= UNREAD_BLOCK_GRACE_MS;
138
137
  if (!force && hasUnread && !hasFreshUnreadActivity) {
139
138
  return { type: 'blocked', activitySummaries, unreadRoomChanges };
@@ -212,9 +211,9 @@ function buildParticipantStatuses(squareState, now, state = foldedState(squareSt
212
211
  let unreadActivityCount = 0;
213
212
  if (snapshot?.joined) {
214
213
  for (const act of squareState.acts) {
215
- if (actStableIndex(act) <= consumedThrough || act.actor === undefined || sameName(act.actor, participant))
214
+ if (actStableIndex(act) <= consumedThrough)
216
215
  continue;
217
- if (act.kind !== 'read')
216
+ if (directedPeerSays(squareState, [act], participant).length > 0)
218
217
  unreadActivityCount++;
219
218
  }
220
219
  }
package/dist/delivery.js CHANGED
@@ -84,12 +84,12 @@ export function markSeenNotifications(squareState, recipient, delivered, at = Da
84
84
  }
85
85
  /** Canonical say-activity filter shared by catch selection and hook ownership. */
86
86
  export function matchesCatchFilter(activity, filter) {
87
- if (audienceOf(activity).kind === 'bell')
88
- return true;
89
87
  if (filter.participants !== undefined &&
90
88
  !filter.participants.some((participant) => sameName(participant, activity.actor))) {
91
89
  return false;
92
90
  }
91
+ if (audienceOf(activity).kind === 'bell')
92
+ return true;
93
93
  if (filter.mention === undefined)
94
94
  return true;
95
95
  return activity.recipients?.some((recipient) => sameName(recipient, filter.mention)) === true
@@ -35,7 +35,7 @@ export async function installClaudePlugin(homeDir, run = runClaude) {
35
35
  const marketplaceRoot = claudeMarketplaceRoot(homeDir);
36
36
  const staged = stageReplacement(marketplaceRoot, (stage) => {
37
37
  const plugin = path.join(stage, 'plugins', SQUARE_IDENTITY.pluginName);
38
- fs.cpSync(fileURLToPath(new URL('../skills/square/', import.meta.url)), plugin, { recursive: true });
38
+ fs.cpSync(fileURLToPath(new URL('../claude-plugin/', import.meta.url)), plugin, { recursive: true });
39
39
  writeJson(path.join(stage, '.claude-plugin', 'marketplace.json'), {
40
40
  name: CLAUDE_MARKETPLACE_NAME,
41
41
  owner: { name: 'Square' },
package/dist/help.js CHANGED
@@ -20,13 +20,13 @@ const COMMANDS = [
20
20
  summary: 'Speak, gesture, or do both.',
21
21
  details: ['Reach:', ' @name Address someone in the square. They hear the body; everyone else sees you walk over.', " --bell Call every participant's attention to this activity without a mention.", ' listen A participant turned toward you also hears your activity.', '', 'Options:', ' -f, --force Express without first catching unread activity or attention etiquette.', ' --no-wait If held or throttled, save a draft and return.', ' --reply <activity-id> Mark this activity as a reply to an earlier activity (for example act/12).'],
22
22
  },
23
- { names: ['listen'], usage: '--as <name> listen <participant>', usesSquare: true, group: 'participant', summary: 'Turn an ear toward one participant.' },
24
- { names: ['ignore'], usage: '--as <name> ignore <participant>', usesSquare: true, group: 'participant', summary: 'Turn away from one participant.' },
25
- { names: ['listening'], usage: '--as <name> listening', usesSquare: true, group: 'participant', summary: 'Show who you are turned toward.' },
23
+ { names: ['listen'], usage: '--as <name> listen <participant>', usesSquare: true, group: 'participant', summary: 'Turn an ear toward one participant\'s future bare says.' },
24
+ { names: ['ignore'], usage: '--as <name> ignore <participant>', usesSquare: true, group: 'participant', summary: 'Turn away from one participant\'s future bare says.' },
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
27
  names: ['catch'], usage: '--as <name> catch (--now | --idle <duration>) [--from <names>] [--mention [name]] [--replace]', usesSquare: true, group: 'participant',
28
- summary: 'Catch what others have said or done since you last looked.',
29
- details: ['Modes:', ' --now Catch up immediately.', ' --idle <duration> Wait for something relevant, or for quiet to last this long.', '', 'Filters:', ' --from <names> Match only comma-separated participants.', ' --mention [name] Match direct attention for a name, or your own name when omitted.', '', 'Recovery:', ' --replace Replace another active catch for this participant.'],
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, bells, and bare says from participants you are listening to arrive here.', ' Listener attention is future-only and fixed when each say lands.', '', 'Filters:', ' --from <names> Match only comma-separated participants.', ' --mention [name] Match direct attention for a name, or your own name when omitted.', '', '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
  {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { Square } from './square-wiring.js';
2
2
  export { SquareError } from './model.js';
3
+ export { bindCurrentParticipant, currentParticipantName, unbindCurrentParticipant } from './registry.js';
3
4
  export type { ActivityId } from './square-core.js';
4
5
  export type { Activity, CatchOptions, CatchResult, ExpressOptions, ExpressResult, HistoryQuery, ListenerChangeResult, OpenOptions, Participant, ParticipantStatus, PerceivedActivity, SquareAtInput, SquareBuildInput, SquareSnapshot, SquareSource, WakeNotifier, } from './square-facade.js';
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { Square } from './square-wiring.js';
2
2
  export { SquareError } from './model.js';
3
+ export { bindCurrentParticipant, currentParticipantName, unbindCurrentParticipant } from './registry.js';
package/dist/model.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Act } 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
- export declare const WAKE_ROUTE_KINDS: readonly ["opencode-server", "codex-app-server", "claude-native", "pi-extension", "paseo"];
4
+ export declare const WAKE_ROUTE_KINDS: readonly ["opencode-server", "codex-queue", "claude-native", "pi-extension", "paseo"];
5
5
  export type WakeRouteKind = typeof WAKE_ROUTE_KINDS[number];
6
6
  export declare function isWakeRouteKind(value: unknown): value is WakeRouteKind;
7
7
  export interface WakeRoute {
package/dist/model.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Shared model and constants for Square.
2
2
  export { formatActivityId, parseActivityId } from './square-core.js';
3
- export const WAKE_ROUTE_KINDS = ['opencode-server', 'codex-app-server', 'claude-native', 'pi-extension', 'paseo'];
3
+ export const WAKE_ROUTE_KINDS = ['opencode-server', 'codex-queue', 'claude-native', 'pi-extension', 'paseo'];
4
4
  export function isWakeRouteKind(value) {
5
5
  return typeof value === 'string' && WAKE_ROUTE_KINDS.includes(value);
6
6
  }
@@ -12,7 +12,6 @@ import { quoteShell } from './presentation.js';
12
12
  import { renderAttentionPreview } from './attention-presentation.js';
13
13
  import { lookupParticipant } from './registry.js';
14
14
  import { retireWakeRoute } from './routes.js';
15
- import { recordPresentedForOwner } from './presented.js';
16
15
  import { openSquare } from './square-file-adapter.js';
17
16
  import { markNotificationNotified } from './square-wiring.js';
18
17
  import { closeOpenSquare } from './open-square.js';
@@ -115,14 +114,22 @@ export async function waitForDeliveredNotification(squarePath, name, ref, opts =
115
114
  return false;
116
115
  }
117
116
  async function defaultWakeAdapters() {
117
+ const adapters = [];
118
+ try {
119
+ const { CodexQueueAdapter } = await import('./codex-queue.js');
120
+ adapters.push(new CodexQueueAdapter());
121
+ }
122
+ catch {
123
+ // Codex is unavailable only when this build omits its local adapter.
124
+ }
118
125
  try {
119
126
  const { PaseoAdapter } = await import('./paseo-delivery.js');
120
- return [new PaseoAdapter()];
127
+ adapters.push(new PaseoAdapter());
121
128
  }
122
129
  catch {
123
130
  // Paseo is an optional integration; a core-only install simply has no Paseo adapter.
124
- return [];
125
131
  }
132
+ return adapters;
126
133
  }
127
134
  async function claimNotifyLease(square, recipient, actIndex) {
128
135
  const leaseId = randomUUID();
@@ -209,7 +216,6 @@ async function processNotification(squarePath, notification, opts) {
209
216
  }, env);
210
217
  if (outcome.outcome === 'accepted') {
211
218
  await markNotificationNotified(square, notification.recipient, notification.item.index, route.ownerId, now());
212
- recordPresentedForOwner(route.ownerId, squarePath, notification.recipient, notification.item.index, env, now());
213
219
  }
214
220
  if (outcome.outcome !== 'failed')
215
221
  releaseLease = true;
package/dist/presence.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { extractMentions, formatActivityId } from './square-core.js';
2
- import { deliveryDelta, filteredRoomChanges, matchesFeedFilter, peerPublicActs } from './activity-feed.js';
3
- import { deriveDeliveryModel, markSeenNotifications, perceiveActivity } from './delivery.js';
2
+ import { deliveryDelta, directedPeerSays, matchesFeedFilter } from './activity-feed.js';
3
+ import { markSeenNotifications, perceiveActivity } from './delivery.js';
4
4
  import { SquareError } from './model.js';
5
5
  import { openSquare } from './square-file-adapter.js';
6
6
  import { closeOpenSquare } from './open-square.js';
@@ -28,8 +28,7 @@ export async function catchUp(square, name, options = {}) {
28
28
  const viewer = resolveKnownName(state, name);
29
29
  const delta = deliveryDelta(state, viewer);
30
30
  const filter = { ...(options.from === undefined ? {} : { participants: [...options.from] }), ...(options.mention === true ? { mention: viewer } : {}) };
31
- const delivery = deriveDeliveryModel(state);
32
- const delivered = [...peerPublicActs(delta, viewer).filter((activity) => matchesFeedFilter(activity, filter, activity.kind === 'say' ? delivery.plan(activity).map((planned) => planned.recipient) : undefined)), ...filteredRoomChanges(delta, viewer, filter)]
31
+ const delivered = directedPeerSays(state, delta, viewer).filter((activity) => matchesFeedFilter(activity, filter))
33
32
  .filter((activity, index, activities) => activities.findIndex((candidate) => candidate.index === activity.index) === index)
34
33
  .sort((left, right) => left.index - right.index);
35
34
  const seenChanged = delivered.reduce((changed, activity) => recordObservation(state, viewer, activity.index, 'seen', at) || changed, false)
@@ -179,7 +179,7 @@ function renderPresenceOnlySay(event, opts = {}) {
179
179
  const audience = audienceOf(event);
180
180
  const targets = audience.kind === 'bell' ? [] : audience.names;
181
181
  if (targets.length === 0) {
182
- return `● ${participantIdentity(event.actor)} #${opts.actNumber ?? 1} · ${actId(event)} · ${formatRelativeTime(event.at, opts.now)}`;
182
+ return `● ${participantIdentity(event.actor)} #${opts.actNumber ?? 1} · ${actId(event)} · ${formatRelativeTime(event.at, opts.now)}\n spoke`;
183
183
  }
184
184
  const dest = ` ${targets.map((name) => participantIdentity(name)).join(' and ')}`;
185
185
  return `● ${participantIdentity(event.actor)} #${opts.actNumber ?? 1} · ${actId(event)} · ${formatRelativeTime(event.at, opts.now)}\n talked to${dest}`;
@@ -37,6 +37,16 @@ export declare function lookupParticipant(squarePath: string, name: string, now?
37
37
  /** Resolve the current local harness owner for a participant, if one is registered. */
38
38
  export declare function localParticipantOwner(squarePath: string, name: string, env?: NodeJS.ProcessEnv, now?: number): string | undefined;
39
39
  export declare function localParticipantName(squarePath: string, env?: NodeJS.ProcessEnv): string | undefined;
40
+ /** Resolve the one participant name owned by the current local session set, without a square path. */
41
+ export declare function currentParticipantName(env?: NodeJS.ProcessEnv): string | undefined;
42
+ export type CurrentParticipantBinding = Readonly<{
43
+ created: boolean;
44
+ ownerId: string;
45
+ }>;
46
+ /** Bind a current local session to one explicit square without taking an occupied registry name. */
47
+ export declare function bindCurrentParticipant(squarePath: string, name: string, env?: NodeJS.ProcessEnv): CurrentParticipantBinding;
48
+ /** Retire only the current local session bindings for one explicit square and participant. */
49
+ export declare function unbindCurrentParticipant(squarePath: string, name: string, env?: NodeJS.ProcessEnv): boolean;
40
50
  export interface RegistryPruneResult {
41
51
  removed: number;
42
52
  kept: number;
package/dist/registry.js CHANGED
@@ -9,7 +9,7 @@ import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { homedir } from 'node:os';
11
11
  import { randomUUID } from 'node:crypto';
12
- import { nameKey, sameName } from './model.js';
12
+ import { nameKey, sameName, SquareError } from './model.js';
13
13
  import { isCurrentlyJoined } from './runtime.js';
14
14
  import { publishWakeRoutes, retireOwnerWakeRoutes } from './routes.js';
15
15
  const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -236,6 +236,38 @@ export function localParticipantName(squarePath, env = process.env) {
236
236
  const names = new Set(identities.flatMap((identity) => lookupSession(identity.sessionId).filter((item) => canonicalSquarePath(item.squarePath) === canonicalSquarePath(squarePath)).map((item) => item.name)));
237
237
  return names.size === 1 ? [...names][0] : undefined;
238
238
  }
239
+ /** Resolve the one participant name owned by the current local session set, without a square path. */
240
+ export function currentParticipantName(env = process.env) {
241
+ const names = new Set(localSessionIdentities(env).flatMap((identity) => lookupSession(identity.sessionId).map((item) => item.name)));
242
+ return names.size === 1 ? [...names][0] : undefined;
243
+ }
244
+ /** Bind a current local session to one explicit square without taking an occupied registry name. */
245
+ export function bindCurrentParticipant(squarePath, name, env = process.env) {
246
+ if (currentParticipantName(env) !== name) {
247
+ throw new SquareError('invalid_args', `The current session is not uniquely bound to ${name}`);
248
+ }
249
+ const localOwner = localParticipantOwner(squarePath, name, env);
250
+ if (localOwner !== undefined)
251
+ return { created: false, ownerId: localOwner };
252
+ const occupied = lookupParticipant(squarePath, name).at(0);
253
+ if (occupied !== undefined) {
254
+ throw new SquareError('already_joined', `${name} is already bound to another session`);
255
+ }
256
+ recordLocalJoin(name, squarePath, env);
257
+ const ownerId = localParticipantOwner(squarePath, name, env);
258
+ if (ownerId === undefined)
259
+ throw new Error(`Current participant binding did not commit for ${name}`);
260
+ return { created: true, ownerId };
261
+ }
262
+ /** Retire only the current local session bindings for one explicit square and participant. */
263
+ export function unbindCurrentParticipant(squarePath, name, env = process.env) {
264
+ const identities = new Set(localSessionIdentities(env).map((identity) => identity.sessionId));
265
+ const current = lookupParticipant(squarePath, name).filter((binding) => identities.has(binding.sessionId));
266
+ for (const binding of current) {
267
+ recordSessionDone(binding.sessionId, binding.name, binding.squarePath, binding.channel, env);
268
+ }
269
+ return current.length > 0;
270
+ }
239
271
  function bindingIsProvablyObsolete(binding, acts) {
240
272
  return acts !== undefined && !isCurrentlyJoined(acts, binding.name);
241
273
  }
package/dist/routes.js CHANGED
@@ -145,7 +145,10 @@ function completeRouteEvidence(value) {
145
145
  */
146
146
  export const WAKE_ROUTE_PROBES = {
147
147
  'opencode-server': () => undefined,
148
- 'codex-app-server': () => undefined,
148
+ 'codex-queue': (env) => {
149
+ const threadId = env.CODEX_THREAD_ID?.trim();
150
+ return threadId ? { sessionId: threadId, address: { threadId } } : undefined;
151
+ },
149
152
  'claude-native': () => undefined,
150
153
  'pi-extension': () => undefined,
151
154
  paseo: (env) => {
package/dist/runtime.js CHANGED
@@ -1,4 +1,4 @@
1
- import { audienceIncludes, audienceOf, fold, formatActivityId } from './square-core.js';
1
+ import { audienceIncludes, audienceOf, fold, formatActivityId, landedAudienceIncludes } from './square-core.js';
2
2
  import { SquareError, findParticipantName, nameKey, sameName, } from './model.js';
3
3
  function parseIntegerEnvValue(name, raw, fallback) {
4
4
  if (raw === undefined)
@@ -172,6 +172,10 @@ export function readCursor(squareState, name) {
172
172
  cursor = act.index;
173
173
  continue;
174
174
  }
175
+ if (!landedAudienceIncludes(squareState.acts, act, name)) {
176
+ cursor = act.index;
177
+ continue;
178
+ }
175
179
  if (observationFor(squareState, name, act.index)?.state !== 'seen')
176
180
  break;
177
181
  cursor = act.index;
@@ -120,6 +120,8 @@ export declare function activeListeners(state: FoldedSquareState, sender: string
120
120
  export declare function audienceBefore(acts: readonly Act[], say: Extract<Act, {
121
121
  kind: 'say';
122
122
  }>): string[];
123
+ /** Whether a peer say was directed to this participant when it landed. */
124
+ export declare function landedAudienceIncludes(acts: readonly Act[], activity: Act, viewer: string): boolean;
123
125
  export declare function listeningTo(state: FoldedSquareState, listener: string): string[];
124
126
  export declare function isListening(state: FoldedSquareState, listener: string, sender: string): boolean;
125
127
  export declare function fold(acts: readonly Act[]): FoldedSquareState;
@@ -75,6 +75,12 @@ export function audienceBefore(acts, say) {
75
75
  }
76
76
  return recipients;
77
77
  }
78
+ /** Whether a peer say was directed to this participant when it landed. */
79
+ export function landedAudienceIncludes(acts, activity, viewer) {
80
+ if (activity.kind !== 'say' || sameName(activity.actor, viewer))
81
+ return false;
82
+ return audienceBefore(acts, activity).some((recipient) => sameName(recipient, viewer));
83
+ }
78
84
  export function listeningTo(state, listener) {
79
85
  return [...(state.listening.get(nameKey(listener)) ?? [])];
80
86
  }
package/dist/views.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type ActivityId } from './square-core.js';
2
2
  import { coreParticipants, coreStatus } from './decisions.js';
3
3
  import { type PlannedNotification } from './delivery.js';
4
- import { type ActivitiesOptions, type InboxNotification, type PublicAct, type RoomChangeAct, type SquareState, type StoredAct } from './model.js';
4
+ import { type ActivitiesOptions, type ActivityObservation, type InboxNotification, type PublicAct, type RoomChangeAct, type SquareState, type StoredAct } from './model.js';
5
5
  import type { OpenSquare } from './open-square.js';
6
6
  import type { Activity, HistoryQuery, ParticipantStatus, PerceivedActivity, SquareSnapshot } from './square-facade.js';
7
7
  export interface ActivityPresentation {
@@ -98,4 +98,8 @@ export declare function inboxProjection(square: OpenSquare, name: string, ownerI
98
98
  export declare function streamProjection(square: OpenSquare, cursor: number, recipient?: string): Promise<StreamProjection>;
99
99
  export declare function notificationForAct(square: OpenSquare, actIndex: number): Promise<readonly PlannedNotification[]>;
100
100
  export declare function pendingDeliveries(square: OpenSquare): Promise<readonly PendingDeliveryProjection[]>;
101
+ export declare function notificationEvidence(square: OpenSquare, recipient: string, actIndex: number): Promise<{
102
+ readonly delivered: boolean;
103
+ readonly observation: ActivityObservation | undefined;
104
+ }>;
101
105
  export declare function notificationDelivered(square: OpenSquare, recipient: string, actIndex: number): Promise<boolean>;
package/dist/views.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { activeListeners, extractMentions, formatActivityId, parseActivityId } from './square-core.js';
2
- import { deliveryDelta, peerPublicActs, peerRoomChanges } from './activity-feed.js';
2
+ import { deliveryDelta, directedPeerSays } from './activity-feed.js';
3
3
  import { coreActivities, coreParticipants, coreStatus, resolveKnownName } from './decisions.js';
4
4
  import { deriveDeliveryModel, isActivitySeen, perceiveActivity, planActNotifications } from './delivery.js';
5
5
  import { SquareError, nameKey } from './model.js';
6
- import { countSays, currentHold, foldedState, freshWatchLease, inSquareCount, isCurrentlyJoined, readCursor, resolveRosterName, rosterNames, watchTerminalStatus } from './runtime.js';
6
+ import { countSays, currentHold, foldedState, freshWatchLease, inSquareCount, isCurrentlyJoined, observationFor, readCursor, resolveRosterName, rosterNames, watchTerminalStatus } from './runtime.js';
7
7
  function expose(stored) {
8
8
  if (stored.kind === 'read' || stored.actor === undefined)
9
9
  throw new Error(`Cannot expose stored activity ${formatActivityId(stored.index)}`);
@@ -65,7 +65,7 @@ export async function currentParticipant(square, name) { const { state } = await
65
65
  export async function admitsBareExpress(square, name, body) { const { state } = await square.cell.read(); const actor = resolveKnownName(state, name); return extractMentions(body).length > 0 || activeListeners(foldedState(state), actor).length > 0; }
66
66
  export async function participants(square) { const { state } = await square.cell.read(); return statuses(square, state); }
67
67
  export async function snapshot(square) { const { state } = await square.cell.read(); const folded = foldedState(state); return { context: [...state.preamble, ...state.warmup].join('\n'), actCount: state.acts.filter((activity) => activity.kind !== 'read').length, hardCap: state.hardCap, ...(state.throttlePerMinute === undefined ? {} : { throttlePerMinute: state.throttlePerMinute }), held: folded.hold.active && folded.hold.actor !== undefined ? { by: folded.hold.actor, ...(folded.hold.reason === undefined ? {} : { reason: folded.hold.reason }) } : null, participants: statuses(square, state), delivered(name, id) { return isActivitySeen(state, name, parseRequiredActivityId(id)); } }; }
68
- export async function activityPresentation(square, name) { const { state } = await square.cell.read(); const known = resolveKnownName(state, name); const delta = deliveryDelta(state, known); const hold = currentHold(state.acts); return { name: known, roster: rosterNames(state), pendingPublic: peerPublicActs(delta, known), pendingRoomChanges: peerRoomChanges(delta, known), activities: state.acts, state, participantCount: inSquareCount(state), held: hold.active, ...(hold.reason === undefined ? {} : { holdReason: hold.reason }), ownActivityCount: countSays(state.acts, known), hardCap: state.hardCap }; }
68
+ export async function activityPresentation(square, name) { const { state } = await square.cell.read(); const known = resolveKnownName(state, name); const delta = deliveryDelta(state, known); const hold = currentHold(state.acts); return { name: known, roster: rosterNames(state), pendingPublic: directedPeerSays(state, delta, known), pendingRoomChanges: [], activities: state.acts, state, participantCount: inSquareCount(state), held: hold.active, ...(hold.reason === undefined ? {} : { holdReason: hold.reason }), ownActivityCount: countSays(state.acts, known), hardCap: state.hardCap }; }
69
69
  export async function entryPresentation(square, name, lastN = 10) { const { state } = await square.cell.read(); const known = resolveRosterName(state, name) ?? name; const publicActivities = state.acts.filter((activity) => activity.kind === 'say' || activity.kind === 'done'); return { joined: isCurrentlyJoined(state.acts, known), scene: state.warmup.join('\n').trim(), context: state.preamble.join('\n').trim(), joinContext: (state.preamble.at(-1) === '---' ? state.preamble.slice(0, -1) : state.preamble).join('\n').trim(), recentActivities: lastN === null ? publicActivities : publicActivities.slice(-lastN), state, sayNumbers: sayNumbers(state), participantCount: inSquareCount(state) }; }
70
70
  export async function historyPresentation(square, options) { const { state } = await square.cell.read(); return { activities: coreActivities(state, options).map((activity) => ({ ...activity, perception: options.viewer === undefined ? 'full' : perceiveActivity(state, activity, options.viewer) })), sayNumbers: sayNumbers(state), presenceAnchors: anchors(state), participantCount: inSquareCount(state) }; }
71
71
  export async function participantsPresentation(square) { const { state } = await square.cell.read(); return coreParticipants(state, square.clock()); }
@@ -80,4 +80,5 @@ export async function streamProjection(square, cursor, recipient) { const { stat
80
80
  return [{ activity }]; const notification = planActNotifications(state, activity).find((candidate) => nameKey(candidate.recipient) === nameKey(recipient)); return notification === undefined ? [] : [{ activity, route: notification.route }]; }), cursor: Math.max(cursor, ...state.acts.map((activity) => activity.index)) }; }
81
81
  export async function notificationForAct(square, actIndex) { const { state } = await square.cell.read(); const activity = state.acts.find((candidate) => candidate.index === actIndex); return activity === undefined ? [] : planActNotifications(state, activity); }
82
82
  export async function pendingDeliveries(square) { const { state } = await square.cell.read(); return [...new Set(state.acts.filter((activity) => activity.kind === 'join').map((activity) => activity.actor))].filter((name) => isCurrentlyJoined(state.acts, name)).map((recipient) => ({ recipient, notifications: deriveDeliveryModel(state).pendingFor(recipient) })); }
83
+ export async function notificationEvidence(square, recipient, actIndex) { const { state } = await square.cell.read(); return { delivered: isActivitySeen(state, recipient, actIndex), observation: observationFor(state, recipient, actIndex) }; }
83
84
  export async function notificationDelivered(square, recipient, actIndex) { const { state } = await square.cell.read(); return isActivitySeen(state, recipient, actIndex); }
@@ -2,6 +2,7 @@ import { type WakeRoute } from './model.js';
2
2
  import { type WakeAttempt } from './wake-attempts.js';
3
3
  export interface WakeEvidence {
4
4
  delivered: boolean;
5
+ notified: boolean;
5
6
  presented: boolean;
6
7
  attempts: WakeAttempt[];
7
8
  terminal?: WakeAttempt;
@@ -4,29 +4,38 @@ import { readWakeRoutes } from './routes.js';
4
4
  import { isWakeRouteAttemptable, readWakeAttempts, terminalWakeEvidence, } from './wake-attempts.js';
5
5
  import { openSquare } from './square-file-adapter.js';
6
6
  import { closeOpenSquare } from './open-square.js';
7
- import { notificationDelivered } from './views.js';
7
+ import { notificationEvidence } from './views.js';
8
8
  /** Project every wake decision from the same primary evidence. */
9
9
  export async function wakeEvidence(squarePath, recipient, actIndex, now, env) {
10
10
  const square = await openSquare(squarePath, { clock: () => now });
11
- const delivered = await notificationDelivered(square, recipient, actIndex).finally(() => closeOpenSquare(square));
12
- const owners = new Set(lookupParticipant(squarePath, recipient, now).map((binding) => binding.ownerId));
13
- const attempts = readWakeAttempts({ attention: { squarePath, recipient, actIndex }, env, now });
14
- const terminal = terminalWakeEvidence(attempts);
15
- const routes = readWakeRoutes({ freshOnly: true, now, env })
16
- .filter((route) => owners.has(route.ownerId));
17
- return {
18
- delivered,
19
- presented: hasPresentedAttention(squarePath, recipient, actIndex, env, now),
20
- attempts,
21
- ...(terminal === undefined ? {} : { terminal }),
22
- attemptableRoutes: terminal === undefined
23
- ? routes.filter((route) => isWakeRouteAttemptable(route, attempts))
24
- : [],
25
- };
11
+ try {
12
+ const { delivered, observation } = await notificationEvidence(square, recipient, actIndex);
13
+ const owners = new Set(lookupParticipant(squarePath, recipient, now).map((binding) => binding.ownerId));
14
+ const notified = observation?.state === 'notified'
15
+ && observation.ownerId !== undefined
16
+ && owners.has(observation.ownerId);
17
+ const attempts = readWakeAttempts({ attention: { squarePath, recipient, actIndex }, env, now });
18
+ const terminal = terminalWakeEvidence(attempts);
19
+ const routes = readWakeRoutes({ freshOnly: true, now, env })
20
+ .filter((route) => owners.has(route.ownerId));
21
+ return {
22
+ delivered,
23
+ notified,
24
+ presented: hasPresentedAttention(squarePath, recipient, actIndex, env, now),
25
+ attempts,
26
+ ...(terminal === undefined ? {} : { terminal }),
27
+ attemptableRoutes: terminal === undefined
28
+ ? routes.filter((route) => isWakeRouteAttemptable(route, attempts))
29
+ : [],
30
+ };
31
+ }
32
+ finally {
33
+ await closeOpenSquare(square);
34
+ }
26
35
  }
27
36
  export function wakeIsEligible(evidence) {
28
37
  return !evidence.delivered
29
- && !evidence.presented
38
+ && !evidence.notified
30
39
  && evidence.terminal === undefined
31
40
  && evidence.attemptableRoutes.length > 0;
32
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.18",
3
+ "version": "0.3.19",
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": {
@@ -10,6 +10,7 @@
10
10
  "dist",
11
11
  "guides",
12
12
  "skills",
13
+ "claude-plugin",
13
14
  "extensions",
14
15
  "codex-plugin"
15
16
  ],
@@ -64,14 +64,26 @@ Normally address the participant who needs an activity with `@name`: mentioned p
64
64
 
65
65
  If something happened while your back was turned, `express` stops and prints an exact recovery command: run it, take in what happened, then express again. If the square is packed or a hand is raised, the command waits for the opening — wait with it; never restart or repost. Use `--force` only when you deliberately mean to express without catching up.
66
66
 
67
+ ## Listen
68
+
69
+ Turn your ear toward a participant when you want their future bare says to reach your catch:
70
+
71
+ ```bash
72
+ square --location <square> --as <name> listen <participant>
73
+ square --location <square> --as <name> listening
74
+ square --location <square> --as <name> ignore <participant>
75
+ ```
76
+
77
+ Listening is an outgoing edge from you to that participant. It is evaluated when each say lands: listening later never rewrites an earlier say. `ignore` removes the edge for future says, while `history` remains unchanged. `done` clears your outgoing listening edges; rejoining starts without them. Mentions and bells do not require a listening edge.
78
+
67
79
  ## Catch
68
80
 
69
81
  ```bash
70
- square --location <square> --as <name> catch --now # take in everything pending
82
+ square --location <square> --as <name> catch --now # take in directed conversation pending
71
83
  square --location <square> --as <name> catch --idle 30m # wait until something relevant lands, or 30m of quiet
72
84
  ```
73
85
 
74
- `catch` takes in what others said and did since you last looked. 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. Filter with `--mention` or `--from <names>` when you only want part of the flow.
86
+ `catch` takes in directed conversation addressed to you since you last looked: mentions, bells, and bare says from participants you are listening to. Listener attention is fixed when each say lands; changing who you listen to never rewrites history. Presence and control actions remain available through `history` and state views, but do not become catch obligations. 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. Filter with `--mention` or `--from <names>` when you only want part of the directed flow.
75
87
 
76
88
  ## History
77
89