@astrosheep/square 0.3.36 → 0.3.38

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.36",
3
+ "version": "0.3.38",
4
4
  "description": "Native Claude Code turn-boundary delivery for Square participants",
5
5
  "author": {
6
6
  "name": "Square"
@@ -61,7 +61,9 @@ The ownership boundary belongs here. Does this match your read?
61
61
  EOF
62
62
  ```
63
63
 
64
- **Addressing.** Use `--mention <name>` (repeatable) to address participants; they hear the full body even when they are not listening, and everyone else sees you walk over to them. Use `--no-mention` to land a bare activity; `listen` opts a participant into future bare delivery. Use `--bell` only when every participant needs it. An `@name` in the body is ordinary Markdown and does not address anyone. Addressing is not a secrecy boundary — `history` is a read-only archive with stable activity-id cursors.
64
+ **Addressing.** Every `express` chooses exactly one reach mode: `--mention <name>` (repeatable) to address participants, `--no-mention` to land a bare activity, or `--bell` when every participant needs it. Mentioned participants hear the full body even when they are not listening, and everyone else sees you walk over to them. `listen` opts a participant into future bare delivery. An `@name` in the body is ordinary Markdown and does not address anyone. Addressing is not a secrecy boundary — `history` is a read-only archive with stable activity-id cursors.
65
+
66
+ Every human-CLI `express` must choose exactly one reach mode: one or more `--mention <name>` flags, `--no-mention`, or `--bell`. The CLI rejects an omitted mode and rejects combining these modes. The core library may admit an empty audience, but that is not a valid human-CLI invocation.
65
67
 
66
68
  **Discipline.** Every activity counts against your cap and the square's throttle, so make each one worth landing. Keep private progress and tool chatter out — express only when another participant needs the thought, question, or decision.
67
69
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.36",
3
+ "version": "0.3.38",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
@@ -16,8 +16,7 @@
16
16
  "type": "command",
17
17
  "command": "square codex-hook",
18
18
  "timeout": 5,
19
- "statusMessage": "square-codex-hook",
20
- "additionalContextLimit": 1200
19
+ "statusMessage": "square-codex-hook"
21
20
  }
22
21
  ]
23
22
  }
@@ -1,5 +1,5 @@
1
1
  import type { DirectedNotificationRoute } from './model.js';
2
- export declare const ATTENTION_BODY_MAX = 120;
2
+ export declare const ATTENTION_BODY_MAX = 100;
3
3
  export interface AttentionPreview {
4
4
  squarePath: string;
5
5
  actIndex: number;
@@ -2,7 +2,7 @@ import { homedir } from 'node:os';
2
2
  import { notificationMessageId } from './delivery.js';
3
3
  import { participantIdentity } from './presentation.js';
4
4
  import { formatActivityId } from './square-core.js';
5
- export const ATTENTION_BODY_MAX = 120;
5
+ export const ATTENTION_BODY_MAX = 100;
6
6
  export function previewAttentionBody(body) {
7
7
  const compact = body.replace(/\r\n/g, '\n');
8
8
  if (compact.length <= ATTENTION_BODY_MAX)
@@ -23,6 +23,5 @@ export function renderAttentionPreview(attention) {
23
23
  return [
24
24
  `${id}: ${participantIdentity(attention.recipient)} from ${participantIdentity(attention.actor)} (${attentionKind})`,
25
25
  previewAttentionBody(normalizedBody),
26
- ...(normalizedBody.length <= ATTENTION_BODY_MAX ? ['✓ shown in full'] : []),
27
26
  ].join('\n');
28
27
  }
@@ -2,12 +2,13 @@ import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { openSquare } from './square-file-adapter.js';
4
4
  import { closeOpenSquare } from './open-square.js';
5
- import { Square } from './square-wiring.js';
5
+ import { openParticipant, Square } from './square-wiring.js';
6
6
  import { entryPresentation } from './views.js';
7
7
  import { automaticParticipant } from './participant-identity.js';
8
8
  import { SquareError } from './model.js';
9
9
  import { createHostLedgerPort } from './host-ledger-file-adapter.js';
10
10
  import { projectSessionBindings } from './square-projections.js';
11
+ import { claimSessionParticipant, releaseSessionParticipant } from './registry.js';
11
12
  import { publishWakeRoute, retireWakeRouteFromArtifact, resolvePrimaryWakeRoute, defaultWakeRouteCapabilities } from './routes.js';
12
13
  export { automaticParticipant } from './participant-identity.js';
13
14
  const providerEnv = {
@@ -68,11 +69,15 @@ export async function automaticSessionStart(provider, sessionId, cwd, env = proc
68
69
  throw new SquareError('already_joined', `${name} is already joined by another session`);
69
70
  }
70
71
  const scopedEnv = operationEnv(provider, sessionId, env);
72
+ const claim = await claimSessionParticipant(squarePath, name, scopedEnv);
71
73
  const square = await Square.at({ path: squarePath, hostLedger: hostLedgerForEnv(scopedEnv), env: scopedEnv });
72
74
  try {
73
75
  const implicit = await square.implicitJoin(name);
74
- if (implicit.state === 'done')
76
+ if (implicit.state === 'done') {
77
+ if (claim?.status === 'acquired')
78
+ await releaseSessionParticipant(squarePath, name, scopedEnv);
75
79
  return undefined;
80
+ }
76
81
  const route = resolvePrimaryWakeRoute({ location: squarePath, participant: name, sessionId, provider }, env, await defaultWakeRouteCapabilities(hostLedgerForEnv(env)));
77
82
  if (route !== undefined) {
78
83
  const publisher = await openSquare(squarePath, { hostLedger: hostLedgerForEnv(scopedEnv), env: scopedEnv });
@@ -87,6 +92,11 @@ export async function automaticSessionStart(provider, sessionId, cwd, env = proc
87
92
  await square.reconcileBinding();
88
93
  return undefined;
89
94
  }
95
+ catch (error) {
96
+ if (claim?.status === 'acquired')
97
+ await releaseSessionParticipant(squarePath, name, scopedEnv).catch(() => undefined);
98
+ throw error;
99
+ }
90
100
  finally {
91
101
  await square.close();
92
102
  }
@@ -109,11 +119,16 @@ export async function automaticSessionEnd(provider, sessionId, cwd, env = proces
109
119
  const joined = await entryPresentation(reader, binding.participant).finally(() => closeOpenSquare(reader));
110
120
  if (!joined.joined)
111
121
  return;
112
- const square = await Square.at({ path: squarePath, hostLedger: hostLedgerForEnv(scopedEnv), env: scopedEnv });
122
+ const opened = await openParticipant({ path: squarePath, hostLedger: hostLedgerForEnv(scopedEnv), env: scopedEnv }, binding.participant);
113
123
  try {
114
- const participant = await square.join(binding.participant);
115
- await participant.done();
116
- await square.reconcileBinding();
124
+ await opened.participant.done();
125
+ const reconciler = await openSquare(squarePath);
126
+ try {
127
+ await hostLedger.reconcileBinding({ artifact: reconciler.artifact });
128
+ }
129
+ finally {
130
+ await closeOpenSquare(reconciler);
131
+ }
117
132
  const cleanup = await openSquare(squarePath);
118
133
  try {
119
134
  await retireWakeRouteFromArtifact(cleanup.artifact, { location: squarePath, participant: binding.participant, sessionId });
@@ -123,6 +138,6 @@ export async function automaticSessionEnd(provider, sessionId, cwd, env = proces
123
138
  }
124
139
  }
125
140
  finally {
126
- await square.close();
141
+ await opened.close();
127
142
  }
128
143
  }
@@ -39,6 +39,7 @@ function renderBoundary(inbox) {
39
39
  const blocks = [];
40
40
  const complete = [];
41
41
  let omitted = 0;
42
+ const duplicateHint = 'ignore if you have already seen this.';
42
43
  for (const [index, entry] of queued.entries()) {
43
44
  const { membership, notification } = entry;
44
45
  const block = [
@@ -59,6 +60,7 @@ function renderBoundary(inbox) {
59
60
  ...(omittedAfter > 0
60
61
  ? [`… ${omittedAfter} unread ${omittedAfter === 1 ? 'notification' : 'notifications'} omitted.`]
61
62
  : []),
63
+ duplicateHint,
62
64
  ...footer,
63
65
  ].join('\n');
64
66
  if (prospective.length > CONTEXT_MAX) {
@@ -79,6 +81,7 @@ function renderBoundary(inbox) {
79
81
  ...(omitted > 0
80
82
  ? [`… ${omitted} unread ${omitted === 1 ? 'notification' : 'notifications'} omitted.`]
81
83
  : []),
84
+ duplicateHint,
82
85
  ...footer,
83
86
  ].join('\n'),
84
87
  complete,
@@ -1,7 +1,7 @@
1
1
  import { cmdActivity } from '../activity.js';
2
2
  import { formatActivityId, formatHardCap, parseActivityId, } from '../model.js';
3
3
  import { participantCommandPrefix, participantIdentity, quoteShell, renderEventCli, renderAmbientEvent, withPathOutput, } from '../presentation.js';
4
- import { hasAutomaticDeliveryIdentity, lookupParticipant, recordSessionDone, } from '../registry.js';
4
+ import { claimSessionParticipant, hasAutomaticDeliveryIdentity, lookupParticipant, recordSessionDone, } from '../registry.js';
5
5
  import { createHostLedgerPort } from '../host-ledger-file-adapter.js';
6
6
  import { projectLocalParticipantBinding, sessionIdsFromEnvironment } from '../square-projections.js';
7
7
  import { nowMs } from '../runtime.js';
@@ -108,16 +108,23 @@ export const joinCommand = {
108
108
  }
109
109
  const takeoverNeeded = isRejoin && intent.kick && !reconnect;
110
110
  const oldBindings = takeoverNeeded ? await lookupParticipant(squarePath, intent.name) : [];
111
- const participant = takeoverNeeded ? await square.takeover(intent.name, oldBindings.map((binding) => binding.sessionId)) : await square.join(intent.name);
112
- const joinedName = participant.name;
111
+ let participant;
113
112
  if (takeoverNeeded) {
114
113
  const currentSessions = new Set(sessionIdsFromEnvironment());
114
+ await square.takeover(intent.name);
115
115
  for (const binding of oldBindings) {
116
116
  if (!currentSessions.has(binding.sessionId)) {
117
117
  await recordSessionDone(binding.sessionId, binding.name, binding.squarePath, binding.channel).catch(() => undefined);
118
118
  }
119
119
  }
120
+ await claimSessionParticipant(squarePath, intent.name, process.env);
121
+ participant = await square.join(intent.name);
122
+ }
123
+ else {
124
+ await claimSessionParticipant(squarePath, intent.name, process.env);
125
+ participant = await square.join(intent.name);
120
126
  }
127
+ const joinedName = participant.name;
121
128
  const afterSquare = await openSquare(squarePath, { clock: nowMs });
122
129
  const after = await entryPresentation(afterSquare, joinedName, intent.lastN);
123
130
  await closeOpenSquare(afterSquare);
@@ -125,7 +125,7 @@ export async function deliverPending(input) {
125
125
  continue;
126
126
  }
127
127
  const attemptN = attempts.reduce((highest, record) => Math.max(highest, record.attemptN ?? 0), 0) + 1;
128
- const request = { location: input.location, participant: membership.recipient, activity, route: requestRoute };
128
+ const request = { location: input.location, participant: membership.recipient, activity, actor: notification.item.actor, route: requestRoute };
129
129
  let outcome;
130
130
  const claim = await input.hostLedger.claimEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', leaseMs, now: input.now });
131
131
  if (claim.status !== 'acquired') {
@@ -36,6 +36,9 @@ export async function installClaudePlugin(homeDir, run = runClaude) {
36
36
  const staged = stageReplacement(marketplaceRoot, (stage) => {
37
37
  const plugin = path.join(stage, 'plugins', SQUARE_IDENTITY.pluginName);
38
38
  fs.cpSync(fileURLToPath(new URL('../claude-plugin/', import.meta.url)), plugin, { recursive: true });
39
+ const skill = path.join(plugin, 'skills', 'square', 'SKILL.md');
40
+ fs.mkdirSync(path.dirname(skill), { recursive: true });
41
+ fs.copyFileSync(path.join(fileURLToPath(new URL('../skills/square/', import.meta.url)), 'SKILL.md'), skill);
39
42
  writeJson(path.join(stage, '.claude-plugin', 'marketplace.json'), {
40
43
  name: CLAUDE_MARKETPLACE_NAME,
41
44
  owner: { name: 'Square' },
@@ -1,4 +1,4 @@
1
- import type { HostLedgerPort, HostLedgerScope, PresenceRecord, PresenceKey, PresenceLookup, PresenceResult, EvidenceRecord, EvidenceClaim, EvidenceRelease, EvidenceLookup, EvidenceGc, ClaimResult, ReconcileBindingInput, ReconcileBindingResult, WakeDispatchClaim, WakeDispatchClaimInput, WakeDispatchReleaseInput, WakeDispatchTransitionInput, WakeAttemptLookup } from './host-ledger.js';
1
+ import type { HostLedgerPort, HostLedgerScope, PresenceRecord, PresenceKey, PresenceLookup, PresenceResult, PresenceClaimResult, EvidenceRecord, EvidenceClaim, EvidenceRelease, EvidenceLookup, EvidenceGc, ClaimResult, ReconcileBindingInput, ReconcileBindingResult, WakeDispatchClaim, WakeDispatchClaimInput, WakeDispatchReleaseInput, WakeDispatchTransitionInput, WakeAttemptLookup } from './host-ledger.js';
2
2
  export interface HostLedgerFileAdapterOptions {
3
3
  userPath?: string;
4
4
  localPath?: string;
@@ -16,6 +16,7 @@ export declare class FileHostLedgerPort implements HostLedgerPort {
16
16
  private readonly clock;
17
17
  constructor(o?: HostLedgerFileAdapterOptions);
18
18
  private file;
19
+ claimPresence(i: PresenceRecord, scope?: HostLedgerScope): Promise<PresenceClaimResult>;
19
20
  ensurePresence(i: PresenceRecord, scope?: HostLedgerScope): Promise<PresenceResult>;
20
21
  removePresence(i: PresenceKey): Promise<void>;
21
22
  listPresence(i?: PresenceLookup): Promise<readonly PresenceRecord[]>;
@@ -62,6 +62,22 @@ export class FileHostLedgerPort {
62
62
  clock;
63
63
  constructor(o = {}) { const testRoot = process.env.SQUARE_REGISTRY ? path.dirname(process.env.SQUARE_REGISTRY) : undefined; this.user = o.userPath ?? (testRoot ?? path.join(os.homedir(), '.square', 'host-ledger')); this.local = o.localPath ?? (testRoot ?? path.join(process.cwd(), '.square', 'host-ledger')); this.writable = o.writableScope ?? 'local'; this.readable = o.readableScopes ?? ['user', 'local']; this.claims = o.claimsPath ?? path.join(this.user, 'wake-claims.ndjsonl'); this.clock = o.now ?? Date.now; }
64
64
  file(s, f) { return path.join(s === 'user' ? this.user : this.local, `${f}.ndjsonl`); }
65
+ async claimPresence(i, scope = this.writable) { const location = await canon(i.location), r = { ...i, location, ...(scope === 'local' ? { route: undefined } : {}), updatedAt: i.updatedAt ?? this.clock(), v: 1 }, lock = path.join(this.user, 'presence-claim.lock'); try {
66
+ return await withFileLock(lock, LOCK, async () => { const candidates = []; for (const s of ['user', 'local']) {
67
+ for (const row of await read(this.file(s, 'presence'), this.clock(), true)) {
68
+ if (row.location !== location || nameKey(row.participant) !== nameKey(r.participant))
69
+ continue;
70
+ candidates.push(s === 'user' ? row : { ...row, route: undefined });
71
+ }
72
+ } const existing = candidates.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0]; if (existing !== undefined) {
73
+ if (existing.session === r.session)
74
+ return { status: 'owned', record: existing };
75
+ return { status: 'busy', record: existing };
76
+ } const f = this.file(scope, 'presence'); await withFileLock(f + '.lock', LOCK, async () => write(f, [...(await read(f, this.clock(), true)).filter(x => k(x) !== k(r)), r])); return { status: 'acquired', record: r }; });
77
+ }
78
+ catch (error) {
79
+ return { status: 'degraded', record: r, error };
80
+ } }
65
81
  async ensurePresence(i, scope = this.writable) { const r = { ...i, location: await canon(i.location), ...(scope === 'local' ? { route: undefined } : {}), updatedAt: i.updatedAt ?? this.clock(), v: 1 }; const f = this.file(scope, 'presence'); try {
66
82
  await withFileLock(f + '.lock', LOCK, async () => write(f, [...(await read(f, this.clock(), true)).filter(x => k(x) !== k(r)), r]));
67
83
  return { status: 'ensured', record: r };
@@ -28,6 +28,17 @@ export type PresenceResult = {
28
28
  readonly record: PresenceRecord;
29
29
  readonly error: unknown;
30
30
  };
31
+ export type PresenceClaimResult = {
32
+ readonly status: 'acquired' | 'owned';
33
+ readonly record: PresenceRecord;
34
+ } | {
35
+ readonly status: 'busy';
36
+ readonly record: PresenceRecord;
37
+ } | {
38
+ readonly status: 'degraded';
39
+ readonly record: PresenceRecord;
40
+ readonly error: unknown;
41
+ };
31
42
  export interface EvidenceRecord {
32
43
  readonly location: string;
33
44
  readonly participant: string;
@@ -143,6 +154,7 @@ export type ReconcileBindingResult = {
143
154
  readonly error?: unknown;
144
155
  };
145
156
  export interface HostLedgerPort {
157
+ claimPresence(input: PresenceRecord, scope?: HostLedgerScope): Promise<PresenceClaimResult>;
146
158
  ensurePresence(input: PresenceRecord, scope?: HostLedgerScope): Promise<PresenceResult>;
147
159
  removePresence(input: PresenceKey): Promise<void>;
148
160
  listPresence(input: PresenceLookup): Promise<readonly PresenceRecord[]>;
@@ -33,7 +33,7 @@ function renderWakePayload(request) {
33
33
  return [
34
34
  `<system-reminder source="square" wake="${wakeLabel(request.route.kind)}">`,
35
35
  `square: ${displayAttentionPath(request.location)}`,
36
- `attention: ${request.activity} for ${request.participant}`,
36
+ `attention: ${request.activity} for ${request.participant} from ${request.actor}`,
37
37
  '</system-reminder>',
38
38
  ].join('\n');
39
39
  }
package/dist/ports.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { InboxNotification, SquareState, StoredAct, WakeRoute, WatchLease } from './model.js';
2
2
  import type { PlannedNotification } from './delivery.js';
3
- export type { ClaimResult, EvidenceClaim, EvidenceRelease, EvidenceGc, EvidenceLookup, EvidenceRecord, HostLedgerPort, HostLedgerScope, PresenceKey, PresenceLookup, PresenceRecord, PresenceChannel, PresenceResult, ReconcileBindingInput as HostLedgerReconcileBindingInput, ReconcileBindingResult, } from './host-ledger.js';
3
+ export type { ClaimResult, EvidenceClaim, EvidenceRelease, EvidenceGc, EvidenceLookup, EvidenceRecord, HostLedgerPort, HostLedgerScope, PresenceKey, PresenceLookup, PresenceRecord, PresenceClaimResult, PresenceChannel, PresenceResult, ReconcileBindingInput as HostLedgerReconcileBindingInput, ReconcileBindingResult, } from './host-ledger.js';
4
4
  import type { HostLedgerPort } from './host-ledger.js';
5
5
  /** Atomic artifact access. Never exposes framing, compression, locks, or storage schema. */
6
6
  export interface SquareArtifactPort {
@@ -30,6 +30,7 @@ export interface WakeRequest {
30
30
  readonly location: string;
31
31
  readonly participant: string;
32
32
  readonly activity: string;
33
+ readonly actor: string;
33
34
  readonly route: WakeRoute;
34
35
  }
35
36
  /** Executor-neutral presentation capability; intentionally separate from wake transport. */
@@ -36,6 +36,11 @@ export type CurrentParticipantBinding = Readonly<{
36
36
  created: boolean;
37
37
  sessionId: string;
38
38
  }>;
39
+ export declare function claimSessionParticipant(squarePath: string, name: string, env?: NodeJS.ProcessEnv): Promise<{
40
+ readonly status: 'acquired' | 'owned';
41
+ readonly sessionId: string;
42
+ } | undefined>;
43
+ export declare function releaseSessionParticipant(squarePath: string, name: string, env?: NodeJS.ProcessEnv): Promise<void>;
39
44
  export declare function bindCurrentParticipant(squarePath: string, name: string, env?: NodeJS.ProcessEnv): Promise<CurrentParticipantBinding>;
40
45
  export declare function unbindCurrentParticipant(squarePath: string, name: string, env?: NodeJS.ProcessEnv): Promise<boolean>;
41
46
  export interface RegistryPruneResult {
package/dist/registry.js CHANGED
@@ -45,6 +45,24 @@ export async function localParticipantOwner(squarePath, name, env = process.env,
45
45
  return undefined; return (await lookupParticipant(squarePath, name, now, env)).find((binding) => sessionIds.has(binding.sessionId))?.sessionId; }
46
46
  export async function localParticipantName(squarePath, env = process.env) { const canonicalPath = await canonicalSquarePath(squarePath); const names = new Set((await Promise.all(localSessionIdentities(env).map(async (identity) => (await lookupSession(identity.sessionId, Date.now(), env)).filter((item) => item.squarePath === canonicalPath).map((item) => item.name)))).flat()); return names.size === 1 ? [...names][0] : undefined; }
47
47
  export function squareAssignedParticipantName(env = process.env) { return computeSquareAssignedParticipantName(env); }
48
+ export async function claimSessionParticipant(squarePath, name, env = process.env) {
49
+ const identity = localSessionIdentities(env)[0];
50
+ if (identity === undefined)
51
+ return undefined;
52
+ const location = await canonicalSquarePath(squarePath);
53
+ const result = await ledger(env).claimPresence({ location, participant: name, session: identity.sessionId, channel: identity.channel, updatedAt: Date.now() }, 'local');
54
+ if (result.status === 'busy')
55
+ throw new SquareError('already_joined', `${name} is already bound to another session`);
56
+ if (result.status === 'degraded')
57
+ throw result.error;
58
+ return { status: result.status, sessionId: identity.sessionId };
59
+ }
60
+ export async function releaseSessionParticipant(squarePath, name, env = process.env) {
61
+ const identity = localSessionIdentities(env)[0];
62
+ if (identity === undefined)
63
+ return;
64
+ await ledger(env).removePresence({ location: squarePath, participant: name, session: identity.sessionId, channel: identity.channel });
65
+ }
48
66
  export async function bindCurrentParticipant(squarePath, name, env = process.env) { if (squareAssignedParticipantName(env) !== name)
49
67
  throw new SquareError('invalid_args', `The current session is not assigned ${name}`); const sessionId = await localParticipantOwner(squarePath, name, env); if (sessionId !== undefined)
50
68
  return { created: false, sessionId }; if ((await lookupParticipant(squarePath, name, Date.now(), env)).at(0) !== undefined)
@@ -17,7 +17,7 @@ export declare function join(square: OperationContext, name: string): Promise<{
17
17
  readonly activity: Activity | null;
18
18
  }>;
19
19
  /** End the standing participant and immediately let the caller reclaim the name. */
20
- export declare function takeover(square: OperationContext, name: string, oldSessionIds?: readonly string[]): Promise<{
20
+ export declare function takeover(square: OperationContext, name: string): Promise<{
21
21
  readonly name: string;
22
22
  readonly activities: readonly Activity[];
23
23
  }>;
@@ -130,9 +130,8 @@ export async function join(square, name) {
130
130
  return { name: committed.name, activity: committed.stored === null ? null : exposeActivity(committed.stored) };
131
131
  }
132
132
  /** End the standing participant and immediately let the caller reclaim the name. */
133
- export async function takeover(square, name, oldSessionIds = []) {
133
+ export async function takeover(square, name) {
134
134
  const now = square.clock();
135
- const oldSessions = new Set(oldSessionIds);
136
135
  const committed = await square.artifact.transact((state) => {
137
136
  const joinedName = resolveKnownName(state, name);
138
137
  const done = coreDone(state, joinedName, '', now);
@@ -141,15 +140,9 @@ export async function takeover(square, name, oldSessionIds = []) {
141
140
  if (decision.joinAct === undefined)
142
141
  throw new SquareError('already_joined', `${participantIdentity(joinedName)} could not be reclaimed`);
143
142
  const storedJoin = committedActivity(storeActs(state, [decision.joinAct]), 'join');
144
- const participantSessions = new Set([
145
- ...oldSessions,
146
- ...(state.routes ?? []).filter((route) => nameKey(route.participant) === nameKey(joinedName)).map((route) => route.sessionId),
147
- ]);
148
- state.routes = (state.routes ?? []).filter((route) => nameKey(route.participant) !== nameKey(joinedName) && !participantSessions.has(route.sessionId));
143
+ state.routes = (state.routes ?? []).filter((route) => nameKey(route.participant) !== nameKey(joinedName));
149
144
  return { state, result: { name: joinedName, stored: [storedDone, storedJoin] } };
150
145
  });
151
- await ensureLocalPresence(square, committed.name);
152
- await publishIdentityRoute(square, committed.name);
153
146
  return { name: committed.name, activities: committed.stored.map(exposeActivity) };
154
147
  }
155
148
  export async function implicitJoin(square, name) {
@@ -13,7 +13,7 @@ export declare class Square {
13
13
  readonly participant: Participant;
14
14
  readonly activity: Activity | null;
15
15
  }>;
16
- takeover(name: string, oldSessionIds?: readonly string[]): Promise<Participant>;
16
+ takeover(name: string): Promise<Participant>;
17
17
  implicitJoin(name: string): Promise<{
18
18
  readonly state: 'joined' | 'active' | 'done';
19
19
  readonly participant?: Participant;
@@ -69,8 +69,8 @@ export class Square {
69
69
  const joined = await join(this.context, name);
70
70
  return { participant: new ParticipantHandle(joined.name, this.square, this.context), activity: joined.activity };
71
71
  }
72
- async takeover(name, oldSessionIds = []) {
73
- const result = await takeover(this.context, name, oldSessionIds);
72
+ async takeover(name) {
73
+ const result = await takeover(this.context, name);
74
74
  return new ParticipantHandle(result.name, this.square, this.context);
75
75
  }
76
76
  async implicitJoin(name) {
@@ -0,0 +1,186 @@
1
+ # Participant Ownership
2
+
3
+ This document defines the ownership boundary for a participant name, a host
4
+ session, and its wake route. It is the contract for ordinary `join`, automatic
5
+ session startup, explicit `--kick`, and session shutdown.
6
+
7
+ The central invariant is simple:
8
+
9
+ > At most one live host session owns a participant name in one square.
10
+
11
+ An artifact participant and a host session are related, but they are not the
12
+ same fact. The artifact is authoritative for the participant lifecycle. The
13
+ host ledger is authoritative for the local session binding. A wake route is
14
+ only a callable address for an existing binding.
15
+
16
+ The uniqueness key is the canonical square location plus `nameKey(name)` (the
17
+ same normalized identity used by the artifact). Display spelling does not
18
+ create a second participant.
19
+
20
+ ## Owners
21
+
22
+ | Fact | Authority | May change it |
23
+ | --- | --- | --- |
24
+ | Participant name is known, active, or done | Square artifact (`SquareState`) | Square actions in an artifact transaction |
25
+ | Session owns a name at a location | Host ledger presence | Registry/ledger binding operations |
26
+ | Session can be woken | Wake route in the square artifact | Route operations after the lifecycle commit |
27
+ | CLI or hook requested join, kick, or shutdown | CLI or host adapter input | The caller only; it does not become a new authority |
28
+
29
+ No layer may use display text, a route row, or an old registry row as a
30
+ replacement for the authoritative fact it does not own.
31
+
32
+ ## Call Boundaries
33
+
34
+ The artifact boundary and the host boundary exchange facts, not each other's
35
+ storage objects:
36
+
37
+ - `square-core` and participant lifecycle actions decide and commit activities
38
+ (`join`, `done`, takeover) and artifact-owned wake routes. Their post-commit
39
+ presence refresh is best effort; they do not discover foreign sessions,
40
+ claim names, or remove another session's presence.
41
+ - The host ledger claims and releases `(location, name, session, channel)`
42
+ bindings. It does not append participant activities and it does not decide
43
+ whether a name is active in the artifact.
44
+ - The name claim must be an atomic compare-and-set on `(location, name)`:
45
+ observe-then-upsert (`listPresence` followed by `ensurePresence`) is not a
46
+ claim and cannot enforce the one-owner invariant under concurrency.
47
+ - CLI and hook orchestration obtains the current session identity, sequences
48
+ the two boundaries, and performs exact-session cleanup. It may pass a
49
+ participant name and a lifecycle intent to an artifact operation; it must
50
+ not pass host binding rows or `oldSessionIds` into a square action.
51
+ - Route publication and retirement write the artifact route set. A route is
52
+ useful only while its exact session binding is owned; route rows are not a
53
+ second name-ownership ledger.
54
+
55
+ The intended shape is therefore `claim/verify -> artifact transaction ->
56
+ presence/route effects`. A helper that combines those steps is an orchestration
57
+ helper, not a square action. In particular, a `takeover(name,
58
+ oldSessionIds)`-style API is the wrong boundary: the artifact can retire the
59
+ active participant routes by name, while the host adapter cleans old presence
60
+ by the exact sessions it observed.
61
+
62
+ ## Ordinary Join
63
+
64
+ `join` is a name claim, not a reconnect heuristic.
65
+
66
+ 1. Validate the requested participant name.
67
+ 2. Ask the host ledger to claim the `(location, name)` binding for the current
68
+ session. An existing binding for another session is a hard refusal. A
69
+ binding that this same session already held is an idempotent reconnect. A
70
+ newly-created claim is provisional until the artifact check below passes.
71
+ 3. In one artifact transaction, decide and commit the participant `join`
72
+ activity when the name is not active. If the artifact already says that the
73
+ name is active, only a pre-existing binding for this exact session may
74
+ reconnect; release a provisional claim and refuse every other caller.
75
+ 4. After the artifact commit, ensure presence for the current session only.
76
+ 5. After presence is established, publish the current session's primary wake
77
+ route.
78
+ 6. Render the entry result from the committed artifact snapshot. Rendering
79
+ does not perform another ownership decision.
80
+
81
+ If any precondition fails, no join activity, presence row, or route is created
82
+ left behind for the refused session. A reconnect does not append another `join`
83
+ activity.
84
+
85
+ ## Automatic Session Startup
86
+
87
+ Automatic startup is a host adapter around the same ownership semantics. It is
88
+ not an implicit kick and it does not get a separate exception for configured
89
+ names.
90
+
91
+ 1. Derive the participant name from the provider session and environment.
92
+ 2. Attempt the host-ledger binding claim.
93
+ 3. If another session owns the name, return the coded `already_joined`
94
+ refusal. Do not rewrite the artifact, remove the other session, or create a
95
+ second binding.
96
+ 4. If the current session already owns the binding, refresh its presence and
97
+ route without another artifact `join`.
98
+ 5. If the name is new, commit the single artifact `join`, then establish
99
+ presence and route as in ordinary join.
100
+
101
+ The hook boundary catches and reports this refusal according to host policy;
102
+ the hook must not silently turn it into a takeover.
103
+
104
+ ## Explicit Kick
105
+
106
+ `join --kick` is the only name-takeover operation. It is an explicit human
107
+ action and must leave an observable lifecycle, not merely overwrite a ledger
108
+ row.
109
+
110
+ 1. Resolve the requested name and collect the currently active bindings for
111
+ that name. The current session is excluded from the old-owner set only when
112
+ it is already the owner and this is a reconnect, not a kick.
113
+ 2. Under the artifact lock, commit two lifecycle activities as one transaction:
114
+ `done` for the standing participant, followed immediately by `join` for the
115
+ reclaimed name. If this transaction cannot commit, nothing is kicked.
116
+ 3. In the same artifact transaction, remove every route for the kicked
117
+ participant. The artifact operation receives only the participant name; it
118
+ does not receive host session rows. The new route is not published until
119
+ after the transaction succeeds.
120
+ 4. After the artifact commit, remove every old owner's presence from both
121
+ readable ledger scopes. The cleanup is keyed by the exact old session and
122
+ channel; never remove by participant name alone.
123
+ 5. Establish the new session's presence and publish its route.
124
+ 6. Reconcile once from the committed artifact and ledger facts. Reconciliation
125
+ may remove stale external rows, but it may not invent a join or undo the
126
+ committed takeover.
127
+
128
+ The resulting history must contain `join -> done -> join`. A successful kick
129
+ must leave no old binding and no old callable route. If external cleanup is
130
+ temporarily unavailable, the artifact still records the kick and the cleanup
131
+ remains retryable and session-scoped.
132
+
133
+ ## Session Shutdown
134
+
135
+ Session shutdown is owner-scoped.
136
+
137
+ 1. Look up the binding by the exact session id and square location in the host
138
+ ledger.
139
+ 2. If no binding exists, stop without changing the artifact.
140
+ 3. If the binding exists and its participant is still active, ask the artifact
141
+ boundary to commit that participant's `done` activity. The artifact action
142
+ does not need the session id.
143
+ 4. Remove that exact session's presence in the host ledger and retire that
144
+ exact session's route in the artifact.
145
+ 5. Do not mark a replacement session done, even when it uses the same display
146
+ name. Under the invariant, such a replacement should have been rejected or
147
+ created through `--kick`.
148
+
149
+ A shutdown arriving after a kick is an idempotent cleanup of an already-removed
150
+ owner. It must not append a second `done` for the new owner.
151
+
152
+ ## Concurrency and Recovery
153
+
154
+ The artifact transaction serializes participant lifecycle activities. The host
155
+ ledger lock serializes binding rows. These locks protect different facts and
156
+ must not be replaced with a copied sidecar or a display-level mutex.
157
+
158
+ The safe ordering is:
159
+
160
+ 1. Claim or verify the session binding.
161
+ 2. Check the artifact state and commit its lifecycle transaction.
162
+ 3. Apply presence and route effects for the committed result.
163
+ 4. Retry or reconcile only the external effects that did not commit.
164
+
165
+ An operation must never claim success from a partially completed sequence. A
166
+ failed precondition produces no artifact mutation. A post-commit ledger or
167
+ route failure produces a diagnosed, retryable external inconsistency while
168
+ preserving the artifact's committed lifecycle.
169
+
170
+ ## Acceptance Evidence
171
+
172
+ The implementation is complete only when these invariants are directly tested:
173
+
174
+ - two sessions cannot concurrently own the same `(location, name)`;
175
+ - same-session reconnect is idempotent and appends no second `join`;
176
+ - automatic startup refuses a foreign owner and never performs a kick;
177
+ - `join --kick` records `done -> join` atomically;
178
+ - kick cleanup removes old presence and routes by exact session identity;
179
+ - a kicked session's later shutdown cannot close the replacement;
180
+ - a failed external cleanup is retryable without duplicating lifecycle
181
+ activities;
182
+ - reconciliation removes stale external rows only when the artifact proves
183
+ the participant is no longer active.
184
+
185
+ Tests should assert the authoritative facts and their ownership boundaries,
186
+ not the order of helper calls or the wording of CLI output.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.36",
3
+ "version": "0.3.38",
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": {
@@ -61,7 +61,9 @@ The ownership boundary belongs here. Does this match your read?
61
61
  EOF
62
62
  ```
63
63
 
64
- **Addressing.** Use `--mention <name>` (repeatable) to address participants; they hear the full body even when they are not listening, and everyone else sees you walk over to them. Use `--no-mention` to land a bare activity; `listen` opts a participant into future bare delivery. Use `--bell` only when every participant needs it. An `@name` in the body is ordinary Markdown and does not address anyone. Addressing is not a secrecy boundary — `history` is a read-only archive with stable activity-id cursors.
64
+ **Addressing.** Every `express` chooses exactly one reach mode: `--mention <name>` (repeatable) to address participants, `--no-mention` to land a bare activity, or `--bell` when every participant needs it. Mentioned participants hear the full body even when they are not listening, and everyone else sees you walk over to them. `listen` opts a participant into future bare delivery. An `@name` in the body is ordinary Markdown and does not address anyone. Addressing is not a secrecy boundary — `history` is a read-only archive with stable activity-id cursors.
65
+
66
+ Every human-CLI `express` must choose exactly one reach mode: one or more `--mention <name>` flags, `--no-mention`, or `--bell`. The CLI rejects an omitted mode and rejects combining these modes. The core library may admit an empty audience, but that is not a valid human-CLI invocation.
65
67
 
66
68
  **Discipline.** Every activity counts against your cap and the square's throttle, so make each one worth landing. Keep private progress and tool chatter out — express only when another participant needs the thought, question, or decision.
67
69