@astrosheep/square 0.3.10 → 0.3.12

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.
Files changed (54) hide show
  1. package/codex-plugin/.codex-plugin/plugin.json +1 -1
  2. package/dist/activity.js +6 -7
  3. package/dist/artifact.js +337 -618
  4. package/dist/boundary-presentation.js +1 -1
  5. package/dist/cli/context.js +3 -3
  6. package/dist/cli/harness-command.js +1 -1
  7. package/dist/cli/maintenance-commands.js +12 -58
  8. package/dist/cli/observation-commands.js +14 -34
  9. package/dist/cli/program.js +3 -6
  10. package/dist/cli/registry.js +1 -2
  11. package/dist/cli/square-commands.js +39 -20
  12. package/dist/cmd/notify-once.js +5 -15
  13. package/dist/compact.js +4 -4
  14. package/dist/decisions.js +21 -7
  15. package/dist/delivery-health.js +56 -136
  16. package/dist/delivery.js +11 -47
  17. package/dist/file-lock.js +112 -0
  18. package/dist/harness-codex.js +35 -29
  19. package/dist/harness-links.js +0 -3
  20. package/dist/harness-pi.js +57 -0
  21. package/dist/harness.js +10 -15
  22. package/dist/help.js +16 -18
  23. package/dist/index.js +11 -5
  24. package/dist/list.js +3 -47
  25. package/dist/model.js +4 -6
  26. package/dist/notifications.js +217 -32
  27. package/dist/paseo-connection.js +135 -0
  28. package/dist/paseo-delivery.js +73 -144
  29. package/dist/paseo-state.js +1 -1
  30. package/dist/paseo-timeline.js +32 -42
  31. package/dist/presentation.js +24 -39
  32. package/dist/presented.js +10 -72
  33. package/dist/registry.js +23 -24
  34. package/dist/routes.js +153 -0
  35. package/dist/runtime.js +6 -21
  36. package/dist/square-application.js +56 -127
  37. package/dist/square-core.js +56 -9
  38. package/dist/stream.js +1 -1
  39. package/dist/wake-attempts.js +175 -0
  40. package/dist/wake-evidence.js +35 -0
  41. package/dist/wake-port.js +22 -0
  42. package/dist/wake-sink.js +45 -6
  43. package/dist/watch.js +1 -2
  44. package/guides/participant.md +7 -174
  45. package/package.json +6 -3
  46. package/skills/brainstorm/SKILL.md +28 -28
  47. package/skills/square/.claude-plugin/plugin.json +1 -1
  48. package/skills/square/SKILL.md +23 -14
  49. package/skills/square-feedback/SKILL.md +7 -7
  50. package/dist/doctor.js +0 -35
  51. package/dist/notification-failures.js +0 -54
  52. package/template.md +0 -4
  53. package/templates/architect.md +0 -4
  54. package/templates/brainstorm.md +0 -4
@@ -1,143 +1,63 @@
1
- import fs from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
1
  import { loadSquare } from './artifact.js';
5
- import { deriveDeliveryModel } from './delivery.js';
6
- import { readNotificationFailures } from './notification-failures.js';
7
- import { isCurrentlyJoined } from './runtime.js';
8
- import { sameName } from './model.js';
2
+ import { deriveDeliveryModel, } from './delivery.js';
3
+ import { formatActivityId } from './square-core.js';
9
4
  import { formatDuration } from './time.js';
10
- const STALE_MS = 60_000;
11
- const LOOKBACK_MS = 60 * 60 * 1000;
12
- function positive(name, fallback, env) {
13
- const raw = env[name];
14
- if (raw === undefined)
15
- return fallback;
16
- const value = Number(raw);
17
- if (!Number.isInteger(value) || value <= 0)
18
- throw new Error(`Invalid ${name}: expected a positive integer.`);
19
- return value;
20
- }
21
- export function deliveryStaleMs(env = process.env) {
22
- return positive('SQUARE_DELIVERY_STALE_MS', STALE_MS, env);
23
- }
24
- export function deliveryLookbackMs(env = process.env) {
25
- return positive('SQUARE_DELIVERY_LOOKBACK_MS', LOOKBACK_MS, env);
26
- }
27
- function actedAfter(doc, recipient, actIndex) {
28
- return doc.acts.some((act) => act.actor !== undefined && sameName(act.actor, recipient) && act.index > actIndex);
29
- }
30
- function pending(squarePath, now) {
5
+ import { joinedRecipients, wakeEvidence } from './wake-evidence.js';
6
+ const DISPLAY_ORDER = [
7
+ 'awaiting',
8
+ 'wake-accepted',
9
+ 'wake-unknown',
10
+ 'presented-not-delivered',
11
+ 'unreachable',
12
+ ];
13
+ const ACTIONABLE = new Set(['wake-unknown', 'unreachable']);
14
+ /** Purely classify current pending attention from the artifact and durable ledgers. */
15
+ export function classifyDeliveryHealth(squarePath, opts) {
16
+ const now = opts.now ?? Date.now();
17
+ const env = opts.env ?? process.env;
31
18
  const doc = loadSquare(squarePath);
32
19
  const model = deriveDeliveryModel(doc);
33
- const recipients = [...new Set(doc.acts.filter((act) => act.kind === 'join').map((act) => act.actor))]
34
- .filter((name) => isCurrentlyJoined(doc.acts, name));
35
- return recipients.flatMap((recipient) => model.pendingFor(recipient).map((note) => ({
36
- squarePath,
37
- recipient: note.recipient,
38
- actIndex: note.item.index,
39
- actor: note.item.actor,
40
- at: note.item.at,
41
- ageMs: now - note.item.at,
42
- route: note.route,
43
- actedAfterWithoutDelivery: actedAfter(doc, note.recipient, note.item.index),
44
- })));
45
- }
46
- export function partitionPendingDeliveries(squarePath, opts = {}) {
47
- const now = opts.now ?? Date.now();
48
- const staleMs = opts.staleMs ?? deliveryStaleMs();
49
- const lookbackMs = Math.max(opts.lookbackMs ?? deliveryLookbackMs(), staleMs);
50
- const recent = [];
51
- const historical = [];
52
- for (const item of pending(squarePath, now)) {
53
- if (item.ageMs >= staleMs && item.ageMs <= lookbackMs)
54
- recent.push(item);
55
- else if (item.ageMs >= staleMs)
56
- historical.push(item);
57
- }
58
- return { recent, historical };
59
- }
60
- function byRecipient(items) {
61
- const groups = new Map();
62
- for (const item of items)
63
- groups.set(item.recipient, [...(groups.get(item.recipient) ?? []), item]);
64
- return [...groups].map(([recipient, notes]) => {
65
- const oldest = notes.reduce((first, item) => item.at < first.at ? item : first);
66
- const adapterFault = notes.some((item) => item.actedAfterWithoutDelivery);
67
- return adapterFault
68
- ? ` · @${recipient}: ${notes.length} pending; they acted after it without a receipt (act_${oldest.actIndex})`
69
- : ` · @${recipient}: ${notes.length} pending (oldest act_${oldest.actIndex} from @${oldest.actor})`;
70
- });
71
- }
72
- export function formatStaleDeliveryWarnings(recent, historical = [], opts = {}) {
73
- const out = [];
74
- if (recent.length > 0) {
75
- const adapterFaults = recent.filter((item) => item.actedAfterWithoutDelivery);
76
- out.push(adapterFaults.length > 0
77
- ? `✕ ${adapterFaults.length} pending notification(s) point to an adapter/pull dead path.`
78
- : `✕ ${recent.length} recent notification(s) have no delivered receipt.`);
79
- out.push(...byRecipient(recent));
20
+ return joinedRecipients(doc).flatMap((recipient) => model.pendingFor(recipient).map((note) => {
21
+ const ageMs = Math.max(0, now - note.item.at);
22
+ const evidence = wakeEvidence(squarePath, note.recipient, note.item.index, now, env);
23
+ const kind = evidence.presented
24
+ ? 'presented-not-delivered'
25
+ : evidence.terminal?.outcome === 'accepted'
26
+ ? 'wake-accepted'
27
+ : evidence.terminal?.outcome === 'unknown'
28
+ ? 'wake-unknown'
29
+ : ageMs > opts.graceMs && evidence.attemptableRoutes.length === 0
30
+ ? 'unreachable'
31
+ : 'awaiting';
32
+ const attempt = evidence.terminal ?? evidence.attempts.at(-1);
33
+ return {
34
+ squarePath,
35
+ recipient: note.recipient,
36
+ actIndex: note.item.index,
37
+ actor: note.item.actor,
38
+ at: note.item.at,
39
+ ageMs,
40
+ route: note.route,
41
+ kind,
42
+ ...(attempt === undefined ? {} : { attempt }),
43
+ };
44
+ }));
45
+ }
46
+ function formatItem(item) {
47
+ const evidence = item.attempt?.signature === undefined ? '' : ` · ${item.attempt.signature}`;
48
+ return ` · ${formatActivityId(item.actIndex)} @${item.recipient} from @${item.actor} · ${formatDuration(item.ageMs)}${evidence}`;
49
+ }
50
+ export function doctorDeliveryHealth(squarePath, graceMs, now = Date.now(), env = process.env) {
51
+ const items = classifyDeliveryHealth(squarePath, { graceMs, now, env });
52
+ if (items.length === 0)
53
+ return ['✓ no pending delivery attention'];
54
+ const out = [`· delivery attention · ${items.length} pending`];
55
+ for (const kind of DISPLAY_ORDER) {
56
+ const group = items.filter((item) => item.kind === kind);
57
+ if (group.length === 0)
58
+ continue;
59
+ out.push(`${ACTIONABLE.has(kind) ? '✕' : '○'} ${kind}: ${group.length}`);
60
+ out.push(...group.map(formatItem));
80
61
  }
81
- if (historical.length > 0) {
82
- out.push(`○ ${historical.length} older pending notification(s) remain as historical backlog.`);
83
- out.push(...byRecipient(historical));
84
- if (opts.previousBacklog !== undefined) {
85
- const delta = historical.length - opts.previousBacklog;
86
- out.push(delta === 0 ? ' · backlog unchanged since last doctor.' : delta > 0 ? ` · backlog grew by ${delta} since last doctor.` : ` · backlog shrank by ${-delta} since last doctor.`);
87
- }
88
- }
89
- else if ((opts.previousBacklog ?? 0) > 0)
90
- out.push(`○ backlog cleared (was ${opts.previousBacklog}).`);
91
62
  return out;
92
63
  }
93
- function baselineFile(env) {
94
- return env.SQUARE_DELIVERY_BASELINE ?? path.join(os.homedir(), '.square', 'delivery-baseline.json');
95
- }
96
- function baseline(squarePath, env) {
97
- try {
98
- return JSON.parse(fs.readFileSync(baselineFile(env), 'utf8'))[path.resolve(squarePath)]?.backlogCount;
99
- }
100
- catch {
101
- return undefined;
102
- }
103
- }
104
- function writeBaseline(squarePath, backlogCount, env, at) {
105
- const file = baselineFile(env);
106
- let rows = {};
107
- try {
108
- rows = JSON.parse(fs.readFileSync(file, 'utf8'));
109
- }
110
- catch { }
111
- rows[path.resolve(squarePath)] = { backlogCount, at };
112
- fs.mkdirSync(path.dirname(file), { recursive: true });
113
- fs.writeFileSync(file, `${JSON.stringify(rows, null, 2)}\n`, { mode: 0o600 });
114
- }
115
- function formatFailures(squarePath, recent, env) {
116
- const failures = readNotificationFailures(squarePath, env);
117
- if (failures.length === 0)
118
- return [];
119
- const pendingKeys = new Set(recent.map((item) => `${item.recipient}\0${item.actIndex}`));
120
- const current = failures.filter((item) => item.recipient !== undefined && pendingKeys.has(`${item.recipient}\0${item.actIndex}`));
121
- const rows = current.length > 0 ? current : failures;
122
- const historical = current.length === 0;
123
- const latest = rows.at(-1);
124
- const diagnostic = latest.diagnostic;
125
- return [
126
- historical ? `○ ${rows.length} historical notification failure(s) retained: ${latest.message}` : `✕ ${rows.length} notification attempt(s) failed: ${latest.message}; receipt remains pending.`,
127
- ...(diagnostic?.passwordPresent === false ? [' · PASEO_PASSWORD absent; pass PASEO_PASSWORD to the Codex process.'] : []),
128
- ` · ${notificationFailuresPathForDisplay(squarePath, env)}`,
129
- ];
130
- }
131
- function notificationFailuresPathForDisplay(squarePath, env) {
132
- return env.SQUARE_NOTIFICATION_FAILURES ?? path.join(path.dirname(squarePath), 'notification-failures.ndjsonl');
133
- }
134
- export function doctorDeliveryHealth(squarePath, now = Date.now(), env = process.env) {
135
- const { recent, historical } = partitionPendingDeliveries(squarePath, { now, staleMs: deliveryStaleMs(env), lookbackMs: deliveryLookbackMs(env) });
136
- const prior = baseline(squarePath, env);
137
- writeBaseline(squarePath, historical.length, env, now);
138
- return [
139
- `· stale after ${formatDuration(deliveryStaleMs(env))} · scan window ${formatDuration(deliveryLookbackMs(env))}`,
140
- ...(recent.length === 0 && historical.length === 0 ? ['✓ no stale undelivered notifications'] : formatStaleDeliveryWarnings(recent, historical, { previousBacklog: prior })),
141
- ...formatFailures(squarePath, recent, env),
142
- ];
143
- }
package/dist/delivery.js CHANGED
@@ -1,10 +1,8 @@
1
1
  import { findParticipantName, sameName, } from './model.js';
2
- import { actId, extractMentions, isCurrentlyJoined, lastJoinIndex, matchesMentionTarget, resolveRosterName, rosterNames } from './runtime.js';
2
+ import { audienceOf, formatActivityId, resolveAudience } from './square-core.js';
3
+ import { actId, isCurrentlyJoined, lastJoinIndex, matchesMentionTarget, resolveRosterName, rosterNames } from './runtime.js';
3
4
  export function notificationMessageId(squarePath, actIndex) {
4
- return `square:${squarePath}#act_${actIndex}`;
5
- }
6
- export function isPendingNotification(notification) {
7
- return notification.route !== 'broadcast';
5
+ return `square:${squarePath}#${formatActivityId(actIndex)}`;
8
6
  }
9
7
  function canonicalRecipient(doc, name) {
10
8
  return resolveRosterName(doc, name) ?? name;
@@ -32,16 +30,6 @@ export function recordDeliveredRuntime(runtime, recipient, actOrIndex, receipt)
32
30
  export function markDeliveredDelivery(doc, name, actOrIndex, at = Date.now()) {
33
31
  return recordDeliveredDelivery(doc, name, actOrIndex, { at });
34
32
  }
35
- function uniqueKnownMentions(body, roster) {
36
- const recipients = [];
37
- for (const mention of extractMentions(body)) {
38
- const known = findParticipantName(roster, mention);
39
- if (known !== undefined && !recipients.some((recipient) => sameName(recipient, known))) {
40
- recipients.push(known);
41
- }
42
- }
43
- return recipients;
44
- }
45
33
  /**
46
34
  * Derive delivery behavior once from the parsed Square document.
47
35
  * All consumers share these targets instead of reinterpreting artifact text or cursor state.
@@ -53,23 +41,9 @@ export function deriveDeliveryModel(doc) {
53
41
  if (item.kind !== 'say')
54
42
  return [];
55
43
  const sayItem = item;
56
- const actor = sayItem.actor;
57
- if (sayItem.reach === 'bell') {
58
- return roster
59
- .filter((recipient) => !sameName(recipient, actor))
60
- .map((recipient) => ({ item: sayItem, recipient, route: 'bell' }));
61
- }
62
- if (sayItem.reach !== undefined) {
63
- const recipient = findParticipantName(roster, sayItem.reach.beside);
64
- return recipient === undefined || sameName(recipient, actor)
65
- ? []
66
- : [{ item: sayItem, recipient, route: 'beside' }];
67
- }
68
- const mentions = uniqueKnownMentions(sayItem.body, roster).filter((recipient) => !sameName(recipient, actor));
69
- const recipients = mentions.length > 0
70
- ? mentions
71
- : roster.filter((recipient) => !sameName(recipient, actor));
72
- const route = mentions.length > 0 ? 'mention' : 'broadcast';
44
+ const audience = audienceOf(sayItem);
45
+ const recipients = resolveAudience(audience, roster).filter((recipient) => !sameName(recipient, sayItem.actor));
46
+ const route = audience.kind === 'bell' ? 'bell' : 'mention';
73
47
  return recipients.map((recipient) => ({ item: sayItem, recipient, route }));
74
48
  }
75
49
  function pendingFor(requestedRecipient) {
@@ -82,19 +56,16 @@ export function deriveDeliveryModel(doc) {
82
56
  for (const act of doc.acts) {
83
57
  if (act.kind !== 'say')
84
58
  continue;
85
- // Broadcasts can never be pending directed notifications. Skipping them here
86
- // avoids allocating one planned notification per participant per activity.
87
- if (act.reach === undefined && extractMentions(act.body).length === 0)
59
+ const audience = audienceOf(act);
60
+ if (audience.kind === 'mentions' && audience.names.length === 0)
88
61
  continue;
89
62
  for (const planned of plan(act)) {
90
- if (planned.route === 'broadcast')
91
- continue;
92
63
  const joinedAt = joinedAfter.get(planned.recipient);
93
64
  if (joinedAt === undefined || act.index <= joinedAt)
94
65
  continue;
95
66
  if (isDeliveryDelivered(doc, planned.recipient, act.index))
96
67
  continue;
97
- pendingByRecipient.get(planned.recipient)?.push({ ...planned, route: planned.route });
68
+ pendingByRecipient.get(planned.recipient)?.push(planned);
98
69
  }
99
70
  }
100
71
  }
@@ -118,7 +89,7 @@ export function markDeliveredNotifications(doc, recipient, delivered, at = Date.
118
89
  }
119
90
  /** Canonical say-activity filter shared by catch selection and hook ownership. */
120
91
  export function matchesCatchFilter(activity, filter) {
121
- if (activity.reach === 'bell')
92
+ if (audienceOf(activity).kind === 'bell')
122
93
  return true;
123
94
  if (filter.participants !== undefined &&
124
95
  !filter.participants.some((participant) => sameName(participant, activity.actor))) {
@@ -128,16 +99,9 @@ export function matchesCatchFilter(activity, filter) {
128
99
  }
129
100
  /** True only when the live catch's own filters would deliver this notification. */
130
101
  export function leaseOwnsNotification(lease, notification) {
131
- const recipient = notification.recipient;
132
- if (notification.route === 'beside' && recipient === undefined)
133
- return false;
134
102
  return matchesCatchFilter({
135
103
  actor: notification.actor,
136
104
  body: notification.body,
137
- reach: notification.route === 'bell'
138
- ? 'bell'
139
- : notification.route === 'beside'
140
- ? { beside: recipient }
141
- : undefined,
105
+ ...(notification.route === 'bell' ? { reach: 'bell' } : {}),
142
106
  }, lease.filter ?? {});
143
107
  }
@@ -0,0 +1,112 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { setTimeout as sleep } from 'node:timers/promises';
5
+ const lockWait = new Int32Array(new SharedArrayBuffer(4));
6
+ const heldSyncLocks = new Set();
7
+ function ownerState(lockPath) {
8
+ let pid;
9
+ try {
10
+ pid = Number.parseInt(fs.readFileSync(lockPath, 'utf8').split('\n')[0], 10);
11
+ }
12
+ catch {
13
+ return 'unknown';
14
+ }
15
+ if (!Number.isSafeInteger(pid) || pid <= 0)
16
+ return 'unknown';
17
+ try {
18
+ process.kill(pid, 0);
19
+ return 'alive';
20
+ }
21
+ catch (error) {
22
+ return error.code === 'ESRCH' ? 'dead' : 'alive';
23
+ }
24
+ }
25
+ function createLock(lockPath) {
26
+ let fd;
27
+ try {
28
+ fd = fs.openSync(lockPath, 'wx', 0o600);
29
+ }
30
+ catch (error) {
31
+ if (error.code === 'EEXIST')
32
+ return undefined;
33
+ throw error;
34
+ }
35
+ const token = `${process.pid}\n${Date.now()}\n${randomUUID()}\n`;
36
+ try {
37
+ fs.writeFileSync(fd, token, 'utf8');
38
+ return token;
39
+ }
40
+ catch (error) {
41
+ try {
42
+ fs.unlinkSync(lockPath);
43
+ }
44
+ catch { }
45
+ throw error;
46
+ }
47
+ finally {
48
+ fs.closeSync(fd);
49
+ }
50
+ }
51
+ function reclaimLock(lockPath, staleMs) {
52
+ try {
53
+ const stale = Date.now() - fs.statSync(lockPath).mtimeMs > staleMs;
54
+ if (ownerState(lockPath) !== 'dead' && !stale)
55
+ return false;
56
+ fs.unlinkSync(lockPath);
57
+ return true;
58
+ }
59
+ catch (error) {
60
+ return error.code === 'ENOENT';
61
+ }
62
+ }
63
+ function releaseLock(lockPath, token) {
64
+ try {
65
+ if (fs.readFileSync(lockPath, 'utf8') === token)
66
+ fs.unlinkSync(lockPath);
67
+ }
68
+ catch { }
69
+ }
70
+ function prepare(lockPath) {
71
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
72
+ }
73
+ export function withFileLockSync(lockPath, options, fn) {
74
+ if (heldSyncLocks.has(lockPath))
75
+ throw new Error(`Reentrant file lock: ${lockPath}`);
76
+ prepare(lockPath);
77
+ let token;
78
+ while (token === undefined) {
79
+ token = createLock(lockPath);
80
+ if (token !== undefined)
81
+ break;
82
+ if (reclaimLock(lockPath, options.staleMs))
83
+ continue;
84
+ Atomics.wait(lockWait, 0, 0, options.retryMs);
85
+ }
86
+ heldSyncLocks.add(lockPath);
87
+ try {
88
+ return fn();
89
+ }
90
+ finally {
91
+ heldSyncLocks.delete(lockPath);
92
+ releaseLock(lockPath, token);
93
+ }
94
+ }
95
+ export async function withFileLock(lockPath, options, fn) {
96
+ prepare(lockPath);
97
+ let token;
98
+ while (token === undefined) {
99
+ token = createLock(lockPath);
100
+ if (token !== undefined)
101
+ break;
102
+ if (reclaimLock(lockPath, options.staleMs))
103
+ continue;
104
+ await sleep(options.retryMs);
105
+ }
106
+ try {
107
+ return await fn();
108
+ }
109
+ finally {
110
+ releaseLock(lockPath, token);
111
+ }
112
+ }
@@ -12,7 +12,7 @@ const LEGACY_MARKETPLACES = ['astrosheep-square'];
12
12
  export function codexMarketplaceRoot(homeDir) {
13
13
  return path.join(homeDir, '.square', 'codex', 'marketplaces', CODEX_MARKETPLACE_NAME);
14
14
  }
15
- function configuredMarketplaceRoot(homeDir, configText) {
15
+ function configuredMarketplaceRoot(homeDir, configText, codexHomeDir) {
16
16
  const lines = configText.split('\n');
17
17
  const header = `[marketplaces.${CODEX_MARKETPLACE_NAME}]`;
18
18
  const start = lines.findIndex((line) => line.trim() === header);
@@ -28,10 +28,10 @@ function configuredMarketplaceRoot(homeDir, configText) {
28
28
  if (!match)
29
29
  return undefined;
30
30
  const value = match[1].startsWith('"') ? JSON.parse(match[1]) : match[1].slice(1, -1);
31
- return path.isAbsolute(value) ? value : path.resolve(codexHome(homeDir), value);
31
+ return path.isAbsolute(value) ? value : path.resolve(codexHome(homeDir, codexHomeDir), value);
32
32
  }
33
- function activeMarketplaceRoot(homeDir, configText) {
34
- return configuredMarketplaceRoot(homeDir, configText) ?? codexMarketplaceRoot(homeDir);
33
+ function activeMarketplaceRoot(homeDir, configText, codexHomeDir) {
34
+ return configuredMarketplaceRoot(homeDir, configText, codexHomeDir) ?? codexMarketplaceRoot(homeDir);
35
35
  }
36
36
  export function codexPluginRoot(homeDir, marketplaceRoot = codexMarketplaceRoot(homeDir)) {
37
37
  return path.join(marketplaceRoot, 'plugins', SQUARE_IDENTITY.pluginName);
@@ -39,12 +39,15 @@ export function codexPluginRoot(homeDir, marketplaceRoot = codexMarketplaceRoot(
39
39
  export function codexPluginHooksPath(homeDir, marketplaceRoot = codexMarketplaceRoot(homeDir)) {
40
40
  return path.join(codexPluginRoot(homeDir, marketplaceRoot), 'hooks', 'hooks.json');
41
41
  }
42
- function codexHome(homeDir) { return path.join(homeDir, '.codex'); }
43
- export function codexHomeHooksPath(homeDir) { return path.join(codexHome(homeDir), 'hooks.json'); }
44
- export function codexConfigPath(homeDir) { return path.join(codexHome(homeDir), 'config.toml'); }
45
- function runCodex(homeDir, args) {
42
+ function codexHome(homeDir, codexHomeDir) {
43
+ const explicit = codexHomeDir?.trim();
44
+ return explicit ? path.resolve(explicit) : path.join(homeDir, '.codex');
45
+ }
46
+ export function codexHomeHooksPath(homeDir, codexHomeDir) { return path.join(codexHome(homeDir, codexHomeDir), 'hooks.json'); }
47
+ export function codexConfigPath(homeDir, codexHomeDir) { return path.join(codexHome(homeDir, codexHomeDir), 'config.toml'); }
48
+ function runCodex(homeDir, args, codexHomeDir) {
46
49
  const result = spawnSync(process.env.SQUARE_CODEX_BIN || 'codex', args, {
47
- encoding: 'utf8', env: { ...process.env, HOME: homeDir, CODEX_HOME: codexHome(homeDir) }, timeout: 30_000,
50
+ encoding: 'utf8', env: { ...process.env, HOME: homeDir, CODEX_HOME: codexHome(homeDir, codexHomeDir) }, timeout: 30_000,
48
51
  });
49
52
  if (result.error)
50
53
  throw result.error;
@@ -77,10 +80,11 @@ function writeAtomic(file, text) {
77
80
  fs.writeFileSync(temp, text, { mode: 0o600 });
78
81
  fs.renameSync(temp, file);
79
82
  }
80
- export async function installCodexPlugin(homeDir, run = runCodex) {
81
- const config = codexConfigPath(homeDir);
83
+ export async function installCodexPlugin(homeDir, run = runCodex, codexHomeDir) {
84
+ const resolvedCodexHome = codexHome(homeDir, codexHomeDir);
85
+ const config = codexConfigPath(homeDir, resolvedCodexHome);
82
86
  const current = fs.existsSync(config) ? fs.readFileSync(config, 'utf8') : '';
83
- const root = activeMarketplaceRoot(homeDir, current);
87
+ const root = activeMarketplaceRoot(homeDir, current, resolvedCodexHome);
84
88
  const staged = stageReplacement(root, (stage) => {
85
89
  const plugin = path.join(stage, 'plugins', SQUARE_IDENTITY.pluginName);
86
90
  fs.cpSync(fileURLToPath(new URL('../codex-plugin/', import.meta.url)), plugin, { recursive: true });
@@ -92,8 +96,8 @@ export async function installCodexPlugin(homeDir, run = runCodex) {
92
96
  const notes = [];
93
97
  try {
94
98
  writeAtomic(config, upsertTomlSectionKey(current, 'features', 'hooks', 'true'));
95
- requireSuccess(run(homeDir, ['plugin', 'marketplace', 'add', root, '--json']), 'marketplace install', true);
96
- const installed = run(homeDir, ['plugin', 'add', CODEX_PLUGIN_ID, '--json']);
99
+ requireSuccess(run(homeDir, ['plugin', 'marketplace', 'add', root, '--json'], resolvedCodexHome), 'marketplace install', true);
100
+ const installed = run(homeDir, ['plugin', 'add', CODEX_PLUGIN_ID, '--json'], resolvedCodexHome);
97
101
  requireSuccess(installed, 'plugin install');
98
102
  let installedPath;
99
103
  try {
@@ -108,8 +112,8 @@ export async function installCodexPlugin(homeDir, run = runCodex) {
108
112
  const pluginId = `${SQUARE_IDENTITY.pluginName}@${marketplace}`;
109
113
  if (!current.includes(`[marketplaces.${marketplace}]`) && !current.includes(`[plugins."${pluginId}"]`))
110
114
  continue;
111
- requireSuccess(run(homeDir, ['plugin', 'remove', pluginId, '--json']), 'legacy plugin removal', true);
112
- requireSuccess(run(homeDir, ['plugin', 'marketplace', 'remove', marketplace, '--json']), 'legacy marketplace removal', true);
115
+ requireSuccess(run(homeDir, ['plugin', 'remove', pluginId, '--json'], resolvedCodexHome), 'legacy plugin removal', true);
116
+ requireSuccess(run(homeDir, ['plugin', 'marketplace', 'remove', marketplace, '--json'], resolvedCodexHome), 'legacy marketplace removal', true);
113
117
  notes.push(`retired ${pluginId}`);
114
118
  }
115
119
  staged.finalize();
@@ -120,25 +124,27 @@ export async function installCodexPlugin(homeDir, run = runCodex) {
120
124
  throw error;
121
125
  }
122
126
  }
123
- export async function uninstallCodexPlugin(homeDir, run = runCodex) {
124
- const config = codexConfigPath(homeDir);
127
+ export async function uninstallCodexPlugin(homeDir, run = runCodex, codexHomeDir) {
128
+ const resolvedCodexHome = codexHome(homeDir, codexHomeDir);
129
+ const config = codexConfigPath(homeDir, resolvedCodexHome);
125
130
  const current = fs.existsSync(config) ? fs.readFileSync(config, 'utf8') : '';
126
- const root = activeMarketplaceRoot(homeDir, current);
127
- requireSuccess(run(homeDir, ['plugin', 'remove', CODEX_PLUGIN_ID, '--json']), 'plugin removal', true);
128
- requireSuccess(run(homeDir, ['plugin', 'marketplace', 'remove', CODEX_MARKETPLACE_NAME, '--json']), 'marketplace removal', true);
131
+ const root = activeMarketplaceRoot(homeDir, current, resolvedCodexHome);
132
+ requireSuccess(run(homeDir, ['plugin', 'remove', CODEX_PLUGIN_ID, '--json'], resolvedCodexHome), 'plugin removal', true);
133
+ requireSuccess(run(homeDir, ['plugin', 'marketplace', 'remove', CODEX_MARKETPLACE_NAME, '--json'], resolvedCodexHome), 'marketplace removal', true);
129
134
  for (const marketplace of LEGACY_MARKETPLACES) {
130
- requireSuccess(run(homeDir, ['plugin', 'remove', `${SQUARE_IDENTITY.pluginName}@${marketplace}`, '--json']), 'legacy plugin removal', true);
131
- requireSuccess(run(homeDir, ['plugin', 'marketplace', 'remove', marketplace, '--json']), 'legacy marketplace removal', true);
135
+ requireSuccess(run(homeDir, ['plugin', 'remove', `${SQUARE_IDENTITY.pluginName}@${marketplace}`, '--json'], resolvedCodexHome), 'legacy plugin removal', true);
136
+ requireSuccess(run(homeDir, ['plugin', 'marketplace', 'remove', marketplace, '--json'], resolvedCodexHome), 'legacy marketplace removal', true);
132
137
  }
133
138
  fs.rmSync(root, { recursive: true, force: true });
134
- fs.rmSync(codexHomeHooksPath(homeDir), { force: true });
135
- return { paths: [root, codexConfigPath(homeDir), codexHomeHooksPath(homeDir)], notes: [] };
139
+ fs.rmSync(codexHomeHooksPath(homeDir, resolvedCodexHome), { force: true });
140
+ return { paths: [root, config, codexHomeHooksPath(homeDir, resolvedCodexHome)], notes: [] };
136
141
  }
137
- export async function doctorCodexPlugin(homeDir, run = runCodex) {
138
- const config = codexConfigPath(homeDir);
142
+ export async function doctorCodexPlugin(homeDir, run = runCodex, codexHomeDir) {
143
+ const resolvedCodexHome = codexHome(homeDir, codexHomeDir);
144
+ const config = codexConfigPath(homeDir, resolvedCodexHome);
139
145
  const current = fs.existsSync(config) ? fs.readFileSync(config, 'utf8') : '';
140
- const root = activeMarketplaceRoot(homeDir, current);
141
- const listed = run(homeDir, ['plugin', 'list', '--json']);
146
+ const root = activeMarketplaceRoot(homeDir, current, resolvedCodexHome);
147
+ const listed = run(homeDir, ['plugin', 'list', '--json'], resolvedCodexHome);
142
148
  return [
143
149
  /^hooks\s*=\s*true$/m.test(current) ? `✓ features.hooks=true in ${config}` : `○ features.hooks missing in ${config}`,
144
150
  fs.existsSync(codexPluginHooksPath(homeDir, root)) ? `✓ Square plugin hooks ${codexPluginHooksPath(homeDir, root)}` : `○ Square plugin bundle missing ${root}`,
@@ -122,6 +122,3 @@ export function opencodeExtensionLink(homeDir = os.homedir()) {
122
122
  kind: 'extension',
123
123
  };
124
124
  }
125
- export function piExtensionLink(homeDir = os.homedir()) {
126
- return { source: path.join(packageRoot(), 'extensions', 'square-pi.js'), target: path.join(homeDir, '.pi', 'agent', 'extensions', 'square.js'), kind: 'extension' };
127
- }
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { SQUARE_IDENTITY } from './identity.js';
5
+ export function piPackageSource() {
6
+ return `npm:${SQUARE_IDENTITY.packageName}@${SQUARE_IDENTITY.packageVersion}`;
7
+ }
8
+ export function piPackageRoot(homeDir) {
9
+ return path.join(homeDir, '.pi', 'agent', 'npm', 'node_modules', ...SQUARE_IDENTITY.packageName.split('/'));
10
+ }
11
+ function runPi(homeDir, args) {
12
+ const result = spawnSync(process.env.SQUARE_PI_BIN || 'pi', args, {
13
+ encoding: 'utf8',
14
+ env: { ...process.env, HOME: homeDir },
15
+ timeout: 30_000,
16
+ });
17
+ if (result.error)
18
+ throw result.error;
19
+ return { status: result.status ?? 1, stdout: result.stdout || '', stderr: result.stderr || '' };
20
+ }
21
+ function requireSuccess(result, action) {
22
+ if (result.status === 0)
23
+ return;
24
+ throw new Error(`Pi ${action} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`}`);
25
+ }
26
+ export function installPiPackage(homeDir, run = runPi) {
27
+ requireSuccess(run(homeDir, ['install', piPackageSource()]), 'package install');
28
+ return [piPackageRoot(homeDir)];
29
+ }
30
+ export function uninstallPiPackage(homeDir, run = runPi) {
31
+ requireSuccess(run(homeDir, ['remove', piPackageSource()]), 'package removal');
32
+ return [piPackageRoot(homeDir)];
33
+ }
34
+ export function doctorPiPackage(homeDir, run = runPi) {
35
+ const listed = run(homeDir, ['list']);
36
+ const root = piPackageRoot(homeDir);
37
+ const manifestPath = path.join(root, 'package.json');
38
+ let manifest;
39
+ try {
40
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
41
+ }
42
+ catch {
43
+ // The diagnostics below name the missing or invalid package state.
44
+ }
45
+ const extensions = manifest?.pi?.extensions;
46
+ return [
47
+ listed.status === 0 && listed.stdout.includes(SQUARE_IDENTITY.packageName)
48
+ ? `✓ Pi package ${SQUARE_IDENTITY.packageName} configured`
49
+ : `○ Pi package ${SQUARE_IDENTITY.packageName} not configured`,
50
+ manifest?.version === SQUARE_IDENTITY.packageVersion
51
+ ? `✓ Pi package ${SQUARE_IDENTITY.packageVersion} installed at ${root}`
52
+ : `○ Pi package ${SQUARE_IDENTITY.packageVersion} missing at ${root}`,
53
+ Array.isArray(extensions) && extensions.includes('./extensions/square-pi.js')
54
+ ? '✓ Pi Square extension declared'
55
+ : '○ Pi Square extension not declared',
56
+ ];
57
+ }