@astrosheep/square 0.3.68 → 0.3.70

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.
@@ -31,7 +31,7 @@ export async function installClaudePlugin(homeDir, run = runClaude) {
31
31
  const marketplaceRoot = claudeMarketplaceRoot(homeDir);
32
32
  const staged = stageReplacement(marketplaceRoot, (stage) => {
33
33
  const plugin = path.join(stage, 'plugins', SQUARE_IDENTITY.pluginName);
34
- fs.cpSync(fileURLToPath(new URL('../claude-plugin/', import.meta.url)), plugin, { recursive: true });
34
+ fs.cpSync(fileURLToPath(new URL('../plugins/claude/', import.meta.url)), plugin, { recursive: true });
35
35
  const skill = path.join(plugin, 'skills', 'square', 'SKILL.md');
36
36
  fs.mkdirSync(path.dirname(skill), { recursive: true });
37
37
  fs.copyFileSync(path.join(fileURLToPath(new URL('../skills/square/', import.meta.url)), 'SKILL.md'), skill);
@@ -85,7 +85,7 @@ export async function installCodexPlugin(homeDir, run = runCodex, codexHomeDir)
85
85
  const root = activeMarketplaceRoot(homeDir, current, resolvedCodexHome);
86
86
  const staged = stageReplacement(root, (stage) => {
87
87
  const plugin = path.join(stage, 'plugins', SQUARE_IDENTITY.pluginName);
88
- fs.cpSync(fileURLToPath(new URL('../codex-plugin/', import.meta.url)), plugin, { recursive: true });
88
+ fs.cpSync(fileURLToPath(new URL('../plugins/codex/', import.meta.url)), plugin, { recursive: true });
89
89
  const skill = path.join(plugin, 'skills', 'square', 'SKILL.md');
90
90
  fs.mkdirSync(path.dirname(skill), { recursive: true });
91
91
  fs.copyFileSync(path.join(fileURLToPath(new URL('../skills/square/', import.meta.url)), 'SKILL.md'), skill);
@@ -50,7 +50,7 @@ export function doctorPiPackage(homeDir, run = runPi) {
50
50
  manifest?.version === SQUARE_IDENTITY.packageVersion
51
51
  ? `✓ Pi package ${SQUARE_IDENTITY.packageVersion} installed at ${root}`
52
52
  : `○ Pi package ${SQUARE_IDENTITY.packageVersion} missing at ${root}`,
53
- Array.isArray(extensions) && extensions.includes('./extensions/square-pi.js')
53
+ Array.isArray(extensions) && extensions.includes('./plugins/pi/square-pi.js')
54
54
  ? '✓ Pi Square extension declared'
55
55
  : '○ Pi Square extension not declared',
56
56
  ];
@@ -1,4 +1,4 @@
1
- import { type OpenCodeNativeSession } from './packages/agent-delivery/src/opencode-native.js';
1
+ import { type OpenCodeNativeSession } from '../../packages/agent-delivery/src/opencode-native.js';
2
2
  export interface OpenCodeContextMessage {
3
3
  readonly id?: string;
4
4
  readonly role: string;
@@ -1,15 +1,15 @@
1
- import { nameKey } from './model.js';
2
- import { observeSessionPending, sessionInbox } from './inbox.js';
3
- import { hostLedgerForEnv } from './registry.js';
4
- import { openSquare } from './square-file-adapter.js';
5
- import { closeOpenSquare } from './open-square.js';
6
- import { nativePendingPreview, presentationSuppressesWake, projectPresentationEvidence } from './square-projections.js';
7
- import { presentPending } from './presentation-operations.js';
8
- import { deliverPending } from './delivery-operations.js';
9
- import { createWakeTransport } from './notifications.js';
10
- import { publishWakeRoute } from './routes.js';
11
- import { formatActivityId, parseActivityId } from './square-core.js';
12
- import { createNativeInputId, sendNativeText } from './packages/agent-delivery/src/opencode-native.js';
1
+ import { nameKey } from '../../model.js';
2
+ import { observeSessionPending, sessionInbox } from '../../inbox.js';
3
+ import { hostLedgerForEnv } from '../../registry.js';
4
+ import { openSquare } from '../../square-file-adapter.js';
5
+ import { closeOpenSquare } from '../../open-square.js';
6
+ import { nativePendingPreview, presentationSuppressesWake, projectPresentationEvidence } from '../../square-projections.js';
7
+ import { presentPending } from '../../presentation-operations.js';
8
+ import { deliverPending } from '../../delivery-operations.js';
9
+ import { createWakeTransport } from '../../notifications.js';
10
+ import { publishWakeRoute } from '../../routes.js';
11
+ import { formatActivityId, parseActivityId } from '../../square-core.js';
12
+ import { createNativeInputId, sendNativeText } from '../../packages/agent-delivery/src/opencode-native.js';
13
13
  const routeKind = 'opencode-server';
14
14
  const key = (location, participant, index) => `${location}\0${nameKey(participant)}\0${index}`;
15
15
  /** The one native pending preview: the audience/catch/epoch fence before send and inside presentation. */
@@ -1,5 +1,5 @@
1
- import { type OpenCodeSessionCapability } from './packages/agent-delivery/src/opencode-native.js';
2
- import { type OpenCodeContextMessage } from './opencode-delivery.js';
1
+ import { type OpenCodeSessionCapability } from '../../packages/agent-delivery/src/opencode-native.js';
2
+ import { type OpenCodeContextMessage } from './delivery.js';
3
3
  interface OpenCodePluginContext {
4
4
  readonly app: {
5
5
  readonly version: string;
@@ -1,11 +1,11 @@
1
- import { automaticSessionStart, operationEnv } from './automatic-session.js';
2
- import { hostLedgerForEnv } from './registry.js';
3
- import { canonicalPath } from './canonical-path.js';
4
- import { openSquare } from './square-file-adapter.js';
5
- import { closeOpenSquare } from './open-square.js';
6
- import { Square } from './square-wiring.js';
7
- import { connectNative } from './packages/agent-delivery/src/opencode-native.js';
8
- import { observeOpenCodeContext, publishOpenCodeRoutes, receiveOpenCodePending } from './opencode-delivery.js';
1
+ import { automaticSessionStart, operationEnv } from '../../automatic-session.js';
2
+ import { hostLedgerForEnv } from '../../registry.js';
3
+ import { canonicalPath } from '../../canonical-path.js';
4
+ import { openSquare } from '../../square-file-adapter.js';
5
+ import { closeOpenSquare } from '../../open-square.js';
6
+ import { Square } from '../../square-wiring.js';
7
+ import { connectNative } from '../../packages/agent-delivery/src/opencode-native.js';
8
+ import { observeOpenCodeContext, publishOpenCodeRoutes, receiveOpenCodePending } from './delivery.js';
9
9
  /** Actual OpenCode 2.0.20 server plugin. Harness identity belongs to ctx, never process.env. */
10
10
  export default {
11
11
  id: 'square',
@@ -1,6 +1,6 @@
1
1
  # Pi shared native delivery
2
2
 
3
- The shipped `extensions/square-pi.js` remains the **only Square Pi extension**.
3
+ The shipped `plugins/pi/square-pi.js` remains the **only Square Pi extension**.
4
4
  Both its activity steer and advisory `nextTurn` send call the SDK-free shared
5
5
  `sendPiMessage` leaf compiled from `packages/agent-delivery/src`. Square keeps
6
6
  framing, custom type, display, preview/UI, observation/presentation evidence,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.68",
3
+ "version": "0.3.70",
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,9 +10,7 @@
10
10
  "dist",
11
11
  "guides",
12
12
  "skills/square",
13
- "claude-plugin",
14
- "extensions",
15
- "codex-plugin"
13
+ "plugins"
16
14
  ],
17
15
  "scripts": {
18
16
  "clean": "node --input-type=module -e \"import fs from 'node:fs'; fs.rmSync('dist',{recursive:true,force:true})\"",
@@ -60,8 +58,8 @@
60
58
  "default": "./dist/paseo.js"
61
59
  },
62
60
  "./server": {
63
- "types": "./dist/opencode.d.ts",
64
- "default": "./dist/opencode.js"
61
+ "types": "./dist/plugins/opencode/index.d.ts",
62
+ "default": "./dist/plugins/opencode/index.js"
65
63
  },
66
64
  "./mcp": {
67
65
  "types": "./dist/mcp.d.ts",
@@ -72,7 +70,7 @@
72
70
  "types": "dist/index.d.ts",
73
71
  "pi": {
74
72
  "extensions": [
75
- "./extensions/square-pi.js"
73
+ "./plugins/pi/square-pi.js"
76
74
  ],
77
75
  "skills": [
78
76
  "./skills"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.68",
3
+ "version": "0.3.70",
4
4
  "description": "Native Claude Code inbox delivery for Square participants",
5
5
  "types": "./types.d.ts",
6
6
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.68",
3
+ "version": "0.3.70",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
@@ -0,0 +1,149 @@
1
+ import type { EvidenceRecord, NativeDeliveryEvidence, PresenceRecord } from '../../host-ledger.js';
2
+ import type { WakeTransportPort } from '../../ports.js';
3
+ import { nameKey, type SquareState } from '../../model.js';
4
+ import { observeSessionPending, sessionInbox } from '../../inbox.js';
5
+ import { hostLedgerForEnv } from '../../registry.js';
6
+ import { openSquare } from '../../square-file-adapter.js';
7
+ import { closeOpenSquare } from '../../open-square.js';
8
+ import { nativePendingPreview, presentationSuppressesWake, projectPresentationEvidence } from '../../square-projections.js';
9
+ import { presentPending } from '../../presentation-operations.js';
10
+ import { deliverPending } from '../../delivery-operations.js';
11
+ import { createWakeTransport } from '../../notifications.js';
12
+ import { publishWakeRoute } from '../../routes.js';
13
+ import { formatActivityId, parseActivityId } from '../../square-core.js';
14
+ import { createNativeInputId, sendNativeText, type OpenCodeNativeSession } from '../../packages/agent-delivery/src/opencode-native.js';
15
+
16
+ const routeKind = 'opencode-server' as const;
17
+ type NativeRow = EvidenceRecord & { claimToken: string; nativeDelivery: Extract<NativeDeliveryEvidence, { harness: 'opencode' }> };
18
+ export interface OpenCodeContextMessage { readonly id?: string; readonly role: string; readonly content: readonly { readonly type: string; readonly text?: string }[] }
19
+ const key = (location: string, participant: string, index: number) => `${location}\0${nameKey(participant)}\0${index}`;
20
+
21
+ /** The one native pending preview: the audience/catch/epoch fence before send and inside presentation. */
22
+ function preview(state: SquareState, owner: PresenceRecord, index: number) {
23
+ return nativePendingPreview(state, { location: owner.location, routeKind, sessionId: owner.session, participant: owner.participant, actIndex: index, epoch: owner.epoch, address: { sessionId: owner.session }, cancelledThrough: owner.cancelledThrough, now: Date.now() });
24
+ }
25
+
26
+ function createOpenCodeWakeTransport(target: OpenCodeNativeSession, env: NodeJS.ProcessEnv, lifetime: AbortSignal): WakeTransportPort {
27
+ const ledger = hostLedgerForEnv(env);
28
+ return {
29
+ probe: async (route) => !lifetime.aborted && route.kind === routeKind && route.sessionId === target.sessionId && route.address.sessionId === target.sessionId,
30
+ async attempt(request, timeoutMs, beforeSend) {
31
+ const signal = AbortSignal.any([lifetime, AbortSignal.timeout(timeoutMs)]);
32
+ const deadline = Date.now() + timeoutMs;
33
+ // Start the native call under the ownership fence, but await it OUTSIDE: prompt may
34
+ // enter our context hook synchronously and that hook needs the same ownership lock.
35
+ const started = await ledger.withClaimLock(async () => {
36
+ if (signal.aborted || request.route.sessionId !== target.sessionId || !request.claimToken
37
+ || !await (beforeSend?.() ?? Promise.resolve(true))) return undefined;
38
+ const owner = (await ledger.listPresence({ location: request.location, participant: request.participant, session: target.sessionId }))
39
+ .find((binding) => binding.channel === 'opencode' && binding.epoch === request.route.epoch);
40
+ const index = parseActivityId(request.activity);
41
+ if (!owner || index === undefined) return undefined;
42
+ const square = await openSquare(owner.location, { hostLedger: ledger, env, signal });
43
+ try {
44
+ const prepared = preview((await square.artifact.read(signal)).state, owner, index);
45
+ if (!prepared) return undefined;
46
+ const row: NativeRow & { routeKind: typeof routeKind } = { location: owner.location, participant: owner.participant, session: target.sessionId,
47
+ activity: request.activity, kind: 'wake', outcome: 'dispatching', routeKind, attemptN: request.attemptN, claimToken: request.claimToken,
48
+ nativeDelivery: { harness: 'opencode', epoch: owner.epoch ?? 0, inputId: createNativeInputId(), payload: prepared.payload } };
49
+ if (!await ledger.prepareNativeWake(row) || !await (beforeSend?.() ?? Promise.resolve(true)) || signal.aborted
50
+ || preview((await square.artifact.read(signal)).state, owner, index)?.payload !== prepared.payload) return undefined;
51
+ return { sending: sendNativeText(target, prepared.payload, { inputId: row.nativeDelivery.inputId, delivery: 'steer',
52
+ timeoutMs: Math.max(1, deadline - Date.now()), signal }) };
53
+ } finally { await closeOpenSquare(square); }
54
+ }, signal);
55
+ if (!started) return { outcome: 'not-capable', diagnostic: 'OpenCode attention, binding or local delivery generation changed before send.' };
56
+ const result = await started.sending;
57
+ if (result.state === 'accepted') return { outcome: 'accepted' };
58
+ if (result.state === 'unavailable') return { outcome: 'failed', unavailable: true, retainRoute: true, signature: result.code };
59
+ return { outcome: 'unknown', signature: result.code };
60
+ },
61
+ };
62
+ }
63
+
64
+ /** Only the matching prepared primary context authorizes presentation, never admission. */
65
+ export async function observeOpenCodeContext(sessionId: string, messages: readonly OpenCodeContextMessage[], env: NodeJS.ProcessEnv, signal: AbortSignal): Promise<void> {
66
+ const ledger = hostLedgerForEnv(env);
67
+ await ledger.withClaimLock(async () => {
68
+ if (signal.aborted) return;
69
+ const rows = (await ledger.listEvidence({ kind: 'wake', session: sessionId })).filter((row): row is NativeRow =>
70
+ row.nativeDelivery?.harness === 'opencode' && !!row.claimToken && ['dispatching', 'unknown', 'accepted'].includes(row.outcome));
71
+ for (const row of rows) {
72
+ if (signal.aborted || !messages.some((message) => message.id === row.nativeDelivery.inputId && message.role === 'user'
73
+ && message.content.every((part) => part.type === 'text' && typeof part.text === 'string')
74
+ && message.content.map((part) => part.text).join('') === row.nativeDelivery.payload)) continue;
75
+ const owner = (await ledger.listPresence({ location: row.location, participant: row.participant, session: sessionId }))
76
+ .find((binding) => binding.channel === 'opencode' && binding.epoch === row.nativeDelivery.epoch);
77
+ const index = parseActivityId(row.activity);
78
+ if (!owner || index === undefined) continue;
79
+ const square = await openSquare(row.location, { hostLedger: ledger, env, signal });
80
+ try {
81
+ const prepared = preview((await square.artifact.read(signal)).state, owner, index);
82
+ if (!prepared || prepared.payload !== row.nativeDelivery.payload) continue;
83
+ const prior = await projectPresentationEvidence({ hostLedger: ledger, location: row.location, participant: row.participant, sessionId, activity: row.activity });
84
+ if (presentationSuppressesWake(prior)) continue;
85
+ await presentPending({ artifact: square.artifact, hostLedger: ledger, location: row.location, participant: row.participant, session: sessionId,
86
+ activity: row.activity, sink: { present() {} }, markSeen: !prepared.clipped, signal,
87
+ current: (state) => !signal.aborted && preview(state, owner, index)?.payload === row.nativeDelivery.payload });
88
+ } finally { await closeOpenSquare(square); }
89
+ }
90
+ }, signal);
91
+ }
92
+
93
+ /** One existing observer and the existing dispatch owner; no second queue or evidence store. */
94
+ export async function receiveOpenCodePending(target: OpenCodeNativeSession, env: NodeJS.ProcessEnv, signal: AbortSignal): Promise<void> {
95
+ const ledger = hostLedgerForEnv(env);
96
+ const observer = await observeSessionPending(target.sessionId, env);
97
+ const excluded = new Set<string>();
98
+ const refreshExclusions = async () => {
99
+ excluded.clear();
100
+ const owners = await ledger.listPresence({ session: target.sessionId });
101
+ for (const row of await ledger.listEvidence({ kind: 'wake', session: target.sessionId })) {
102
+ const index = parseActivityId(row.activity);
103
+ const current = owners.some((owner) => owner.location === row.location && nameKey(owner.participant) === nameKey(row.participant) && owner.epoch === row.nativeDelivery?.epoch);
104
+ if (index !== undefined && (['accepted', 'unknown'].includes(row.outcome) || row.outcome === 'dispatching' && current && row.nativeDelivery?.harness === 'opencode')) excluded.add(key(row.location, row.participant, index));
105
+ }
106
+ for (const membership of await sessionInbox(target.sessionId, env, signal)) {
107
+ const owner = owners.find((binding) => binding.location === membership.squarePath && nameKey(binding.participant) === nameKey(membership.name));
108
+ for (const note of membership.notifications) if (note.actIndex <= (owner?.cancelledThrough ?? -1)) excluded.add(key(membership.squarePath, membership.name, note.actIndex));
109
+ }
110
+ };
111
+ const transport = createWakeTransport([], ledger, Date.now, { [routeKind]: createOpenCodeWakeTransport(target, env, signal) });
112
+ try {
113
+ await refreshExclusions();
114
+ let pending = await observer.wait(Infinity, { signal, excludeKeys: excluded });
115
+ while (!signal.aborted && pending.length > 0) {
116
+ let armed!: (value: boolean) => void;
117
+ const ready = new Promise<boolean>((resolve) => { armed = resolve; });
118
+ // Capture the next state edge BEFORE dispatch. Evidence failures alone do not retry.
119
+ const next = observer.wait(Infinity, { signal, excludeKeys: excluded, skipImmediate: true, onChangeArmed: armed })
120
+ .catch(() => { armed(false); return []; });
121
+ if (!await ready || signal.aborted) break;
122
+ for (const membership of pending) {
123
+ const square = await openSquare(membership.squarePath, { hostLedger: ledger, env, signal });
124
+ try {
125
+ for (const note of membership.notifications) {
126
+ if (signal.aborted || excluded.has(key(membership.squarePath, membership.name, note.actIndex))) continue;
127
+ await deliverPending({ artifact: square.artifact, hostLedger: ledger, transport, location: square.location, activity: formatActivityId(note.actIndex), now: Date.now() });
128
+ }
129
+ } finally { await closeOpenSquare(square); }
130
+ }
131
+ await refreshExclusions();
132
+ pending = await next;
133
+ }
134
+ } finally { observer.close(); }
135
+ }
136
+
137
+ export async function publishOpenCodeRoutes(sessionId: string, env: NodeJS.ProcessEnv, signal: AbortSignal): Promise<boolean> {
138
+ const ledger = hostLedgerForEnv(env);
139
+ return ledger.withClaimLock(async () => {
140
+ const owners = (await ledger.listPresence({ session: sessionId })).filter((binding) => binding.channel === 'opencode');
141
+ for (const owner of owners) {
142
+ const square = await openSquare(owner.location, { hostLedger: ledger, env, signal });
143
+ try { await publishWakeRoute(square.artifact, { location: owner.location, participant: owner.participant, sessionId, channel: 'opencode',
144
+ kind: routeKind, address: { sessionId }, epoch: owner.epoch }, { requireCurrentSession: true }); }
145
+ finally { await closeOpenSquare(square); }
146
+ }
147
+ return owners.length > 0;
148
+ }, signal);
149
+ }
@@ -0,0 +1,136 @@
1
+ import { automaticSessionStart, operationEnv } from '../../automatic-session.js';
2
+ import { hostLedgerForEnv } from '../../registry.js';
3
+ import { canonicalPath } from '../../canonical-path.js';
4
+ import { openSquare } from '../../square-file-adapter.js';
5
+ import { closeOpenSquare } from '../../open-square.js';
6
+ import { Square } from '../../square-wiring.js';
7
+ import { connectNative, type OpenCodeNativeSession, type OpenCodeSessionCapability } from '../../packages/agent-delivery/src/opencode-native.js';
8
+ import { observeOpenCodeContext, publishOpenCodeRoutes, receiveOpenCodePending, type OpenCodeContextMessage } from './delivery.js';
9
+
10
+ interface OpenCodePluginContext {
11
+ readonly app: { readonly version: string };
12
+ readonly location: { readonly directory: string; readonly project: { readonly id: string }; readonly workspaceID?: string };
13
+ readonly session: OpenCodeSessionCapability & {
14
+ hook(name: 'prompt', callback: (event: { sessionID: string }) => Promise<void>): Promise<unknown>;
15
+ hook(name: 'context', callback: (event: { sessionID: string; messages: OpenCodeContextMessage[] }) => Promise<void>): Promise<unknown>;
16
+ };
17
+ readonly event: { subscribe(options: { signal: AbortSignal }): AsyncIterable<{ type: string; data: { sessionID?: string; reason?: string } }> };
18
+ }
19
+ interface Receiver { readonly abort: AbortController; readonly running: Promise<void>; settled: boolean }
20
+
21
+ /** Actual OpenCode 2.0.20 server plugin. Harness identity belongs to ctx, never process.env. */
22
+ export default {
23
+ id: 'square',
24
+ async setup(ctx: OpenCodePluginContext): Promise<() => Promise<void>> {
25
+ if (ctx.app.version !== '2.0.20') return async () => {};
26
+ const env = { ...process.env };
27
+ const ledger = hostLedgerForEnv(env);
28
+ const lifetime = new AbortController();
29
+ const receivers = new Map<string, Receiver>(); // Operational handles only; presence owns membership.
30
+ const directory = await canonicalPath(ctx.location.directory);
31
+ let coordinating = Promise.resolve();
32
+ const coordinate = (operation: () => Promise<void>) => {
33
+ const task = coordinating.then(() => lifetime.signal.aborted ? undefined : operation());
34
+ coordinating = task.catch(() => {}); // Optional Square hooks must never fail primary request preparation.
35
+ return coordinating;
36
+ };
37
+ const stop = (sessionId: string) => {
38
+ const receiver = receivers.get(sessionId);
39
+ receiver?.abort.abort();
40
+ receivers.delete(sessionId);
41
+ return receiver?.running;
42
+ };
43
+ const validate = async (sessionId: string): Promise<OpenCodeNativeSession | undefined> => {
44
+ let info: unknown;
45
+ const target = await connectNative({ sessionId, timeoutMs: 3000, signal: lifetime.signal, session: {
46
+ async get(input) { info = await ctx.session.get(input); return info; },
47
+ prompt: (input) => ctx.session.prompt(input),
48
+ } }).catch(() => undefined);
49
+ const record = info as { projectID?: string; location?: { directory?: string; workspaceID?: string } } | undefined;
50
+ if (!target || !record?.location?.directory || record.projectID !== ctx.location.project.id
51
+ || record.location.workspaceID !== ctx.location.workspaceID
52
+ || await canonicalPath(record.location.directory) !== directory) return undefined;
53
+ return target;
54
+ };
55
+ const reconcile = async (activeSession?: string) => {
56
+ const candidates = new Set((await ledger.listPresence({})).filter((binding) => binding.channel === 'opencode').map((binding) => binding.session));
57
+ if (activeSession) candidates.add(activeSession);
58
+ for (const sessionId of candidates) {
59
+ if (lifetime.signal.aborted) return;
60
+ const target = await validate(sessionId);
61
+ if (!target) { stop(sessionId); continue; }
62
+ const scopedEnv = operationEnv('opencode', sessionId, env);
63
+ if (sessionId === activeSession) await automaticSessionStart('opencode', sessionId, directory, scopedEnv);
64
+ if (!await publishOpenCodeRoutes(sessionId, scopedEnv, lifetime.signal)) { stop(sessionId); continue; }
65
+ if (receivers.get(sessionId)?.settled === false) continue;
66
+ const abort = new AbortController();
67
+ const signal = AbortSignal.any([lifetime.signal, abort.signal]);
68
+ const receiver: Receiver = { abort, settled: false, running: receiveOpenCodePending(target, scopedEnv, signal).catch(() => {}).finally(() => { receiver.settled = true; }) };
69
+ receivers.set(sessionId, receiver);
70
+ }
71
+ for (const sessionId of receivers.keys()) if (!candidates.has(sessionId)) stop(sessionId);
72
+ };
73
+ const cancel = async (sessionId: string) => {
74
+ if (!receivers.has(sessionId)) return;
75
+ // Stop synchronously; persist the cutoff before allowing reconciliation to start fresh work.
76
+ stop(sessionId);
77
+ await ledger.withClaimLock(async () => {
78
+ for (const owner of (await ledger.listPresence({ session: sessionId })).filter((binding) => binding.channel === 'opencode')) {
79
+ const square = await openSquare(owner.location, { hostLedger: ledger, env, signal: lifetime.signal });
80
+ try { await ledger.suppressPresence(owner, (await square.artifact.read(lifetime.signal)).state.runtime.nextActIndex - 1); }
81
+ finally { await closeOpenSquare(square); }
82
+ }
83
+ }, lifetime.signal);
84
+ await reconcile();
85
+ };
86
+ const end = async (sessionId: string) => {
87
+ stop(sessionId);
88
+ const memberships = (await ledger.listPresence({ session: sessionId })).filter((binding) => binding.channel === 'opencode');
89
+ for (const owner of memberships) {
90
+ const square = await Square.at({ path: owner.location, hostLedger: ledger, env: operationEnv('opencode', sessionId, env) });
91
+ try { await square.endOwnedSession(owner.participant, sessionId, owner.epoch); }
92
+ finally { await square.close(); }
93
+ }
94
+ };
95
+
96
+ await ctx.session.hook('prompt', (event) => coordinate(() => reconcile(event.sessionID)));
97
+ await ctx.session.hook('context', async (event) => {
98
+ await coordinate(() => reconcile(event.sessionID));
99
+ const receiver = receivers.get(event.sessionID);
100
+ if (!receiver || lifetime.signal.aborted) return;
101
+ await observeOpenCodeContext(event.sessionID, event.messages, operationEnv('opencode', event.sessionID, env),
102
+ AbortSignal.any([lifetime.signal, receiver.abort.signal])).catch(() => {});
103
+ });
104
+ const changes = await ledger.observeChanges();
105
+ changes.evidence.close(); // Manual joins activate an idle receiver; evidence already has a session observer.
106
+ const presenceLoop = (async () => {
107
+ while (!lifetime.signal.aborted) {
108
+ const baseline = await changes.presence.read(lifetime.signal);
109
+ await coordinate(() => reconcile());
110
+ await changes.presence.changed(baseline, Infinity, lifetime.signal);
111
+ }
112
+ })().catch(() => {});
113
+ const eventLoop = (async () => {
114
+ for await (const event of ctx.event.subscribe({ signal: lifetime.signal })) {
115
+ const sessionId = event.data.sessionID;
116
+ if (!sessionId) continue;
117
+ if (event.type === 'session.deleted') await coordinate(() => end(sessionId));
118
+ else if (event.type === 'session.moved') await coordinate(async () => { stop(sessionId); await reconcile(); });
119
+ else if (event.type === 'session.execution.interrupted' && event.data.reason === 'user') {
120
+ receivers.get(sessionId)?.abort.abort();
121
+ await coordinate(() => cancel(sessionId));
122
+ }
123
+ }
124
+ })().catch(() => {});
125
+ // Do not await any native prompt in setup: plugin activation itself is lazy.
126
+ return async () => {
127
+ lifetime.abort();
128
+ changes.presence.close();
129
+ const tasks = [presenceLoop, eventLoop, coordinating, ...[...receivers.keys()].map(stop)];
130
+ let timer: ReturnType<typeof setTimeout> | undefined;
131
+ try { await Promise.race([Promise.allSettled(tasks), new Promise<void>((resolve) => { timer = setTimeout(resolve, 3000); })]); }
132
+ finally { if (timer !== undefined) clearTimeout(timer); }
133
+ // Instance unload is not session deletion: retain memberships and uncertain correlations.
134
+ };
135
+ },
136
+ };
@@ -0,0 +1,14 @@
1
+ # Square
2
+
3
+ Expandable Square context in the Paseo conversation. A compact card tells you
4
+ who reached you and where. Expand it to see the original custom-message content,
5
+ select text, or copy it in full. The card follows the active Paseo theme on
6
+ desktop and mobile.
7
+
8
+ Requires Paseo 0.11.2 or newer with custom-message tool rows (#6090). Square
9
+ 0.3.69 or newer supplies structured card headings. Older messages and joining
10
+ context use a neutral “Square context” heading.
11
+
12
+ The plugin changes presentation only. It does not send prompts or alter model
13
+ context. If Square clipped a body before delivery, expansion shows that same
14
+ preview and the command for reading the rest, not an additional fetch.
@@ -0,0 +1,54 @@
1
+ # Square for Paseo
2
+
3
+ A client-only presentation plugin for Square custom messages. No daemon restart,
4
+ backend entry, extra notifications, or model-context changes.
5
+
6
+ ## Install from GitHub
7
+
8
+ ```bash
9
+ paseo plugin install git:astrosheep-zero/square:plugins/paseo
10
+ ```
11
+
12
+ Use `--ref <tag-or-branch>` to select an existing ref that contains the plugin
13
+ directory. Square tags up to v0.3.69 do not contain it.
14
+
15
+ Paseo supplies all runtime modules. No Git preparation step is needed. Plugins
16
+ are trusted, unsandboxed code running inside the Paseo app.
17
+
18
+ Requires Paseo >=0.11.2 with custom-message tool rows (#6090). Square 0.3.69+
19
+ provides `details.items` for the collapsed heading. Messages without those
20
+ details still expand to their original content.
21
+
22
+ ## Display
23
+
24
+ Only synthetic `tool_call` items with `metadata.customType === "square"` and
25
+ plain-text detail are projected into a card. Real tools, other custom messages,
26
+ and unrecognized rows remain unchanged.
27
+
28
+ The collapsed heading uses `metadata.details.items`; it never reverse-parses
29
+ the content. Expansion shows `detail.text` verbatim, including fences, clipping
30
+ notices, and catch commands. Copy content copies that exact string. It does not
31
+ fetch extra activity or mark anything seen. Live events and fetched history use
32
+ the same transformer.
33
+
34
+ The card uses Paseo theme colors, selectable text, accessible expansion state,
35
+ and compact layout on narrow displays. Removing the plugin restores Paseo's
36
+ ordinary custom-message row.
37
+
38
+ ## Develop
39
+
40
+ ```bash
41
+ cd plugins/paseo
42
+ npm ci
43
+ npm run typecheck
44
+ npm test
45
+ paseo plugin install "$PWD"
46
+ ```
47
+
48
+ After editing an installed directory source:
49
+
50
+ ```bash
51
+ paseo plugin reload square
52
+ ```
53
+
54
+ No Paseo restart is needed. The plugin ID is `square`.
@@ -0,0 +1,88 @@
1
+ import type { PluginTimelineItemProps } from "@getpaseo/plugin/client";
2
+ import { copyText, Icon, useToast } from "@getpaseo/plugin/client/react-native";
3
+ import { useState } from "react";
4
+ import { Platform, Pressable, Text, View } from "react-native";
5
+ import type { SquareMessageData } from "../shared/message";
6
+
7
+ export function SquareMessage({ item, theme, layout }: PluginTimelineItemProps<SquareMessageData>) {
8
+ const [expanded, setExpanded] = useState(false);
9
+ const toast = useToast();
10
+ const { text, items } = item.data;
11
+ const first = items?.[0];
12
+ const muted = theme.colors.foregroundMuted;
13
+ const foreground = theme.colors.foreground;
14
+ const padding = layout.compact ? 12 : 16;
15
+
16
+ return (
17
+ <View style={{
18
+ backgroundColor: theme.colors.surface1,
19
+ borderRadius: 10,
20
+ overflow: "hidden",
21
+ }}>
22
+ <Pressable
23
+ accessibilityRole="button"
24
+ accessibilityLabel={first?.description ?? "Square context"}
25
+ aria-expanded={expanded}
26
+ onPress={() => setExpanded(value => !value)}
27
+ style={({ pressed }) => ({
28
+ flexDirection: "row",
29
+ alignItems: "center",
30
+ gap: 10,
31
+ paddingHorizontal: padding,
32
+ paddingVertical: 12,
33
+ opacity: pressed ? 0.7 : 1,
34
+ })}
35
+ >
36
+ <Text style={{ color: theme.colors.accent, fontSize: 11 }}>■</Text>
37
+ <View style={{
38
+ flex: 1,
39
+ minWidth: 0,
40
+ flexDirection: layout.compact ? "column" : "row",
41
+ alignItems: layout.compact ? "flex-start" : "center",
42
+ gap: layout.compact ? 3 : 12,
43
+ }}>
44
+ <Text numberOfLines={layout.compact ? 2 : 1}
45
+ style={{ color: foreground, fontSize: 13, fontWeight: "500", lineHeight: 19, flexShrink: 1 }}>
46
+ {first?.description ?? "Square context"}
47
+ </Text>
48
+ {first && <Text numberOfLines={1} style={{ color: muted, fontSize: 11, lineHeight: 16, flexShrink: 1 }}>
49
+ {first.square}{items && items.length > 1 ? ` · +${items.length - 1} more` : ""}
50
+ </Text>}
51
+ </View>
52
+ <Icon name={expanded ? "ChevronDown" : "ChevronRight"} size={14} color={muted} />
53
+ </Pressable>
54
+ {expanded && <View style={{
55
+ marginHorizontal: padding,
56
+ borderTopWidth: 1,
57
+ borderTopColor: theme.colors.border,
58
+ paddingTop: 12,
59
+ paddingBottom: 12,
60
+ gap: 12,
61
+ }}>
62
+ <Text selectable style={{
63
+ color: foreground,
64
+ fontSize: 12,
65
+ lineHeight: 20,
66
+ fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
67
+ }}>{text}</Text>
68
+ <Pressable
69
+ accessibilityRole="button"
70
+ accessibilityLabel="Copy Square content"
71
+ onPress={() => { void copyText(text).catch(() => toast.error("Could not copy Square content")); }}
72
+ style={({ pressed }) => ({
73
+ alignSelf: "flex-end",
74
+ flexDirection: "row",
75
+ alignItems: "center",
76
+ gap: 5,
77
+ paddingVertical: 4,
78
+ paddingHorizontal: 4,
79
+ opacity: pressed ? 0.6 : 1,
80
+ })}
81
+ >
82
+ <Icon name="Copy" size={12} color={muted} />
83
+ <Text style={{ color: muted, fontSize: 11 }}>Copy content</Text>
84
+ </Pressable>
85
+ </View>}
86
+ </View>
87
+ );
88
+ }
@@ -0,0 +1,24 @@
1
+ import type { PluginTimelineTransformerContribution } from "@getpaseo/plugin/client";
2
+ import { squareCardDetailsSchema } from "../shared/message.ts";
3
+
4
+ // Recognition is metadata-based, not a guess from a tool name or body text.
5
+ // The same projection applies to live events and fetched history.
6
+ export const transformSquareMessage: PluginTimelineTransformerContribution<"tool_call">["transform"] = ({ item }) => {
7
+ if (item.metadata?.synthetic !== true ||
8
+ item.metadata.customType !== "square" ||
9
+ item.detail.type !== "plain_text" ||
10
+ typeof item.detail.text !== "string") return;
11
+
12
+ const details = squareCardDetailsSchema.safeParse(item.metadata.details);
13
+ return {
14
+ items: [{
15
+ type: "plugin",
16
+ kind: "square-message",
17
+ version: 1,
18
+ data: {
19
+ text: item.detail.text,
20
+ ...(details.success ? { items: details.data.items } : {}),
21
+ },
22
+ }],
23
+ };
24
+ };
@@ -0,0 +1,19 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { SquareMessage } from "./client/square-message";
3
+ import { transformSquareMessage } from "./client/transform";
4
+ import { squareMessageSchema } from "./shared/message";
5
+
6
+ export default function contribute(client: PluginClientContext) {
7
+ client.addTimelineTransformer({
8
+ id: "square-message",
9
+ query: { itemType: "tool_call" },
10
+ transform: transformSquareMessage,
11
+ });
12
+ client.addTimelineRenderer({
13
+ kind: "square-message",
14
+ version: 1,
15
+ schema: squareMessageSchema,
16
+ Component: SquareMessage,
17
+ });
18
+ return () => {};
19
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@astrosheep/square-paseo",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "typecheck": "tsc --noEmit",
8
+ "test": "node --experimental-strip-types --test test/*.test.ts"
9
+ },
10
+ "devDependencies": {
11
+ "@getpaseo/plugin": "0.11.2",
12
+ "@types/react": "~19.2.0",
13
+ "@types/node": "^22",
14
+ "react": "19.1.0",
15
+ "react-native": "0.81.5",
16
+ "typescript": "^5.9.3",
17
+ "zod": "^4.4.3"
18
+ }
19
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "square",
3
+ "name": "Square",
4
+ "requirements": {
5
+ "paseo": ">=0.11.2"
6
+ }
7
+ }
@@ -0,0 +1,14 @@
1
+ import { z } from "zod";
2
+
3
+ export const squareCardDetailsSchema = z.object({
4
+ items: z.array(z.object({
5
+ square: z.string(),
6
+ description: z.string(),
7
+ })).min(1),
8
+ });
9
+
10
+ export const squareMessageSchema = z.object({
11
+ text: z.string(),
12
+ items: squareCardDetailsSchema.shape.items.optional(),
13
+ });
14
+ export type SquareMessageData = z.output<typeof squareMessageSchema>;
@@ -1,8 +1,10 @@
1
- import { presentPendingAtBoundary, renderPendingAtBoundary } from '../dist/boundary-presentation.js';
2
- import { automaticSessionEnd, automaticSessionStart } from '../dist/automatic-session.js';
3
- import { sessionInbox, observeSessionPending } from '../dist/inbox.js';
4
- import { renderAttentionDescription } from '../dist/attention-presentation.js';
5
- import { createPiReceiver, sendPiMessage } from '../dist/packages/agent-delivery/src/pi.js';
1
+ import path from 'node:path';
2
+
3
+ import { presentPendingAtBoundary, renderPendingAtBoundary } from '../../dist/boundary-presentation.js';
4
+ import { automaticSessionEnd, automaticSessionStart } from '../../dist/automatic-session.js';
5
+ import { sessionInbox, observeSessionPending } from '../../dist/inbox.js';
6
+ import { renderAttentionDescription } from '../../dist/attention-presentation.js';
7
+ import { createPiReceiver, sendPiMessage } from '../../dist/packages/agent-delivery/src/pi.js';
6
8
 
7
9
  class PiDeliveryDroppedError extends Error {
8
10
  constructor() {
@@ -25,31 +27,66 @@ export function renderPiInbox(inbox) {
25
27
  return renderPendingAtBoundary(pendingInbox(inbox));
26
28
  }
27
29
 
28
- const NOTIFY_BODY_MAX = 80;
29
-
30
30
  function framePiMessage(content) {
31
31
  return `\n${content.replace(/^\n+/, '').replace(/\n+$/, '')}\n`;
32
32
  }
33
33
 
34
- export function summarizePendingForNotify(pending) {
35
- const lines = pending.flatMap((membership) => membership.notifications.map((note) => {
36
- const description = renderAttentionDescription({ actor: note.actor, recipient: membership.name, route: note.route });
37
- const body = note.body.replace(/\s+/g, ' ').trim();
38
- const clipped = body.length > NOTIFY_BODY_MAX ? `${body.slice(0, NOTIFY_BODY_MAX).trimEnd()}…` : body;
39
- return `${description}${clipped ? ` — ${clipped}` : ''}`;
40
- }));
41
- if (lines.length === 0) return undefined;
42
- return lines.length === 1 ? `■ square · ${lines[0]}` : `■ square · ${lines[0]} (+${lines.length - 1} more)`;
34
+ /** Renderer-only facts for the collapsed card; Pi never sends `details` to the model. */
35
+ export function pendingCardDetails(pending) {
36
+ const items = pending.flatMap((membership) => membership.notifications.map((note) => ({
37
+ square: path.basename(membership.squarePath, '.square'),
38
+ description: renderAttentionDescription({ actor: note.actor, recipient: membership.name, route: note.route }),
39
+ })));
40
+ return items.length === 0 ? undefined : { items };
41
+ }
42
+
43
+ // Conservative terminal width: anything outside ASCII counts as two columns, so a
44
+ // truncated line never exceeds the viewport (Pi stops the TUI when a line overflows).
45
+ const columnsOf = (char) => (char.charCodeAt(0) < 0x80 ? 1 : 2);
46
+ const widthOf = (text) => Array.from(text).reduce((sum, char) => sum + columnsOf(char), 0);
47
+
48
+ function fitColumns(text, columns) {
49
+ if (widthOf(text) <= columns) return text;
50
+ let used = 1; // the ellipsis
51
+ let out = '';
52
+ for (const char of text) {
53
+ if (used + columnsOf(char) > columns) break;
54
+ used += columnsOf(char);
55
+ out += char;
56
+ }
57
+ return `${out}…`;
58
+ }
59
+
60
+ /**
61
+ * Collapsed: one line naming who reached you and where. Expanded: Pi's default card,
62
+ * which shows the exact content the model received.
63
+ */
64
+ export function renderSquareCard(message, { expanded, outputPad = 1 }, theme) {
65
+ const items = message.details?.items;
66
+ if (expanded || !Array.isArray(items) || items.length === 0) return undefined;
67
+ const [first] = items;
68
+ const tail = ` · ${first.square}${items.length > 1 ? ` +${items.length - 1}` : ''}`;
69
+ return {
70
+ invalidate() {},
71
+ render(width) {
72
+ const room = Math.max(1, width - outputPad * 2 - 2);
73
+ // Style only an untruncated line, so escape codes never count toward or get cut by the width.
74
+ const line = widthOf(first.description + tail) <= room
75
+ ? `${first.description}${theme.fg('dim', tail)}`
76
+ : fitColumns(first.description + tail, room);
77
+ return [`${' '.repeat(outputPad)}${theme.fg('customMessageLabel', '■')} ${line}`];
78
+ },
79
+ };
43
80
  }
44
81
 
45
82
  export default function squarePiExtension(pi) {
83
+ pi.registerMessageRenderer?.('square', renderSquareCard);
46
84
  pi.registerFlag('agent-delivery-socket', { type: 'string', description: 'Explicit private local delivery socket' });
47
85
  createPiReceiver(pi, {
48
86
  get endpoint() { return pi.getFlag('agent-delivery-socket'); },
49
87
  });
50
88
  let sessionId;
51
89
  let sessionCwd;
52
- let ui;
53
90
  let watcher;
54
91
  let watcherAbort;
55
92
  let generation = 0;
@@ -199,15 +236,13 @@ export default function squarePiExtension(pi) {
199
236
  boundSessionId,
200
237
  async (content) => {
201
238
  if (signal.aborted || currentRunSignal?.aborted) throw new Error('Pi notification delivery cancelled');
202
- // The full activity stays out of the TUI stream (display: false) but still
203
- // reaches the model; a brief notify tells the human what just landed.
204
- const summary = summarizePendingForNotify(pending);
205
- if (summary) ui?.notify?.(summary, 'info');
239
+ // One card for the human: collapsed it names who reached you, expanded it
240
+ // shows the same content the model receives.
206
241
  const messageContent = framePiMessage(content);
207
242
  const landing = waitForLanding(messageContent, signal, 'steer');
208
243
  try {
209
244
  sendPiMessage(pi,
210
- { customType: 'square', content: messageContent, display: false },
245
+ { customType: 'square', content: messageContent, display: true, details: pendingCardDetails(pending) },
211
246
  { deliverAs: 'steer', triggerTurn: true },
212
247
  );
213
248
  } catch (error) {
@@ -261,7 +296,6 @@ export default function squarePiExtension(pi) {
261
296
  turnIndex = 0;
262
297
  activeTurn = undefined;
263
298
  currentRunSignal = undefined;
264
- ui = ctx?.ui;
265
299
  sessionId = ctx.sessionManager.getSessionId();
266
300
  sessionCwd = ctx.cwd || process.cwd();
267
301
  const token = generation;
@@ -354,7 +388,6 @@ export default function squarePiExtension(pi) {
354
388
  generation += 1;
355
389
  failAcks(new Error('Pi session ended'));
356
390
  const stopped = stopWatcher();
357
- ui = undefined;
358
391
  const endedId = sessionId;
359
392
  const endedCwd = sessionCwd;
360
393
  const endedEnv = sessionEnv(endedId);
File without changes