@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.
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/skills/square/SKILL.md +3 -1
- package/codex-plugin/.codex-plugin/plugin.json +1 -1
- package/codex-plugin/hooks/hooks.json +1 -2
- package/dist/attention-presentation.d.ts +1 -1
- package/dist/attention-presentation.js +1 -2
- package/dist/automatic-session.js +22 -7
- package/dist/boundary-presentation.js +3 -0
- package/dist/cli/square-commands.js +10 -3
- package/dist/delivery-operations.js +1 -1
- package/dist/harness-claude.js +3 -0
- package/dist/host-ledger-file-adapter.d.ts +2 -1
- package/dist/host-ledger-file-adapter.js +16 -0
- package/dist/host-ledger.d.ts +12 -0
- package/dist/notifications.js +1 -1
- package/dist/ports.d.ts +2 -1
- package/dist/registry.d.ts +5 -0
- package/dist/registry.js +18 -0
- package/dist/square-actions.d.ts +1 -1
- package/dist/square-actions.js +2 -9
- package/dist/square-wiring.d.ts +1 -1
- package/dist/square-wiring.js +2 -2
- package/guides/participant-ownership.md +186 -0
- package/package.json +1 -1
- package/skills/square/SKILL.md +3 -1
|
@@ -61,7 +61,9 @@ The ownership boundary belongs here. Does this match your read?
|
|
|
61
61
|
EOF
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
**Addressing.**
|
|
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
|
|
|
@@ -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 =
|
|
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
|
|
122
|
+
const opened = await openParticipant({ path: squarePath, hostLedger: hostLedgerForEnv(scopedEnv), env: scopedEnv }, binding.participant);
|
|
113
123
|
try {
|
|
114
|
-
|
|
115
|
-
await
|
|
116
|
-
|
|
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
|
|
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
|
-
|
|
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') {
|
package/dist/harness-claude.js
CHANGED
|
@@ -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 };
|
package/dist/host-ledger.d.ts
CHANGED
|
@@ -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[]>;
|
package/dist/notifications.js
CHANGED
|
@@ -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. */
|
package/dist/registry.d.ts
CHANGED
|
@@ -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)
|
package/dist/square-actions.d.ts
CHANGED
|
@@ -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
|
|
20
|
+
export declare function takeover(square: OperationContext, name: string): Promise<{
|
|
21
21
|
readonly name: string;
|
|
22
22
|
readonly activities: readonly Activity[];
|
|
23
23
|
}>;
|
package/dist/square-actions.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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) {
|
package/dist/square-wiring.d.ts
CHANGED
|
@@ -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
|
|
16
|
+
takeover(name: string): Promise<Participant>;
|
|
17
17
|
implicitJoin(name: string): Promise<{
|
|
18
18
|
readonly state: 'joined' | 'active' | 'done';
|
|
19
19
|
readonly participant?: Participant;
|
package/dist/square-wiring.js
CHANGED
|
@@ -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
|
|
73
|
-
const result = await takeover(this.context, name
|
|
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
package/skills/square/SKILL.md
CHANGED
|
@@ -61,7 +61,9 @@ The ownership boundary belongs here. Does this match your read?
|
|
|
61
61
|
EOF
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
**Addressing.**
|
|
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
|
|