@astrosheep/square 0.3.5 → 0.3.7

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 (57) hide show
  1. package/codex-plugin/.codex-plugin/plugin.json +3 -2
  2. package/codex-plugin/hooks/hooks.json +3 -14
  3. package/dist/activity-feed.js +26 -18
  4. package/dist/activity.js +10 -10
  5. package/dist/artifact.js +138 -203
  6. package/dist/boundary-presentation.js +77 -0
  7. package/dist/claude-hook.js +4 -94
  8. package/dist/cli/context.js +7 -7
  9. package/dist/cli/maintenance-commands.js +10 -26
  10. package/dist/cli/meta-commands.js +3 -6
  11. package/dist/cli/observation-commands.js +48 -53
  12. package/dist/cli/program.js +4 -4
  13. package/dist/cli/registry.js +5 -5
  14. package/dist/cli/square-commands.js +27 -20
  15. package/dist/cmd/notify-once.js +23 -21
  16. package/dist/codex-hook.js +22 -0
  17. package/dist/compact.js +1 -1
  18. package/dist/decisions.js +61 -88
  19. package/dist/delivery-health.js +104 -210
  20. package/dist/delivery.js +68 -18
  21. package/dist/doctor.js +9 -8
  22. package/dist/harness-claude.js +38 -245
  23. package/dist/harness-codex.js +82 -616
  24. package/dist/harness-stage.js +36 -0
  25. package/dist/harness.js +3 -5
  26. package/dist/help.js +43 -35
  27. package/dist/inbox.js +12 -11
  28. package/dist/index.js +10 -121
  29. package/dist/list.js +1 -1
  30. package/dist/model.js +0 -6
  31. package/dist/notification-failures.js +54 -0
  32. package/dist/notifications.js +47 -62
  33. package/dist/paseo-delivery.js +160 -0
  34. package/dist/paseo-state.js +31 -0
  35. package/dist/paseo-timeline.js +58 -188
  36. package/dist/presentation.js +57 -64
  37. package/dist/presented.js +9 -8
  38. package/dist/registry.js +55 -45
  39. package/dist/runtime.js +27 -84
  40. package/dist/square-application.js +135 -130
  41. package/dist/square-core.js +3 -11
  42. package/dist/stream.js +27 -126
  43. package/dist/wake-sink.js +3 -214
  44. package/dist/watch.js +65 -122
  45. package/extensions/square-opencode.js +8 -73
  46. package/extensions/square-pi.js +8 -132
  47. package/guides/architect.md +3 -3
  48. package/guides/participant.md +25 -16
  49. package/package.json +2 -2
  50. package/skills/brainstorm/SKILL.md +25 -32
  51. package/skills/square/.claude-plugin/plugin.json +1 -1
  52. package/skills/square/SKILL.md +39 -107
  53. package/skills/square/hooks/hooks.json +2 -13
  54. package/skills/square-feedback/SKILL.md +4 -4
  55. package/dist/harness-lifecycle.js +0 -102
  56. package/dist/square-store.js +0 -111
  57. package/dist/terminal.js +0 -125
@@ -1,107 +1,139 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { renderArtifactAct, renderSquare } from './artifact.js';
3
+ import { setTimeout as sleep } from 'node:timers/promises';
4
+ import { emptyRuntimeState, loadRuntimeSidecar, loadSquare, mergeRuntimeState, renderArtifactAct, renderSquare, renderSquareDoc, saveRuntimeSidecar } from './artifact.js';
4
5
  import { coreCompact, coreDone, coreHold, coreResume, decideAct, decideJoin, resolveKnownName } from './decisions.js';
5
- import { partitionPendingDeliveries } from './delivery-health.js';
6
- import { planRepair } from './doctor.js';
7
6
  import { dispatchActNotifications } from './notifications.js';
8
- import { squareStore } from './square-store.js';
9
- import { actId, freshWatchLease, touchPresenceCursor } from './runtime.js';
10
- /**
11
- * Publish a dependent persistence file before the Square document. A retained
12
- * backup lets a failed document commit restore the prior file exactly.
13
- */
14
- function prepareFileReplacement(filePath, text) {
15
- const parent = path.dirname(filePath);
16
- const token = `${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
17
- const stage = path.join(parent, `.${path.basename(filePath)}.${token}.stage`);
18
- const backup = path.join(parent, `.${path.basename(filePath)}.${token}.previous`);
19
- let replaced = false;
20
- let hadOriginal = false;
21
- try {
22
- fs.writeFileSync(stage, text);
23
- hadOriginal = fs.existsSync(filePath);
24
- if (hadOriginal)
25
- fs.renameSync(filePath, backup);
26
- fs.renameSync(stage, filePath);
27
- replaced = true;
28
- }
29
- catch (error) {
7
+ import { planRepair } from './doctor.js';
8
+ import { stageReplacement } from './harness-stage.js';
9
+ import { SquareError } from './model.js';
10
+ import { advanceCursor, freshWatchLease, LOCK_RETRY_MS, LOCK_STALE_MS, removeWatchLease, touchPresenceCursor, watchLease, writeWatchLease } from './runtime.js';
11
+ /** The only persistence primitive: one per-square lock, one Markdown write, one sidecar write. */
12
+ export async function withSquareLock(squarePath, fn) {
13
+ const lockPath = `${squarePath}.lock`;
14
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
15
+ while (true) {
16
+ let fd;
30
17
  try {
31
- fs.rmSync(stage, { force: true });
18
+ fd = fs.openSync(lockPath, 'wx');
19
+ fs.writeFileSync(fd, `${process.pid}\n${Date.now()}\n`, 'utf8');
32
20
  }
33
- catch { }
34
- if (hadOriginal && fs.existsSync(backup) && !fs.existsSync(filePath)) {
21
+ catch (error) {
22
+ if (fd !== undefined) {
23
+ try {
24
+ fs.closeSync(fd);
25
+ }
26
+ catch { }
27
+ try {
28
+ fs.unlinkSync(lockPath);
29
+ }
30
+ catch { }
31
+ }
32
+ const errno = error;
33
+ if (errno.code !== 'EEXIST')
34
+ throw error;
35
35
  try {
36
- fs.renameSync(backup, filePath);
36
+ if (Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS)
37
+ fs.unlinkSync(lockPath);
38
+ }
39
+ catch { }
40
+ await sleep(LOCK_RETRY_MS);
41
+ continue;
42
+ }
43
+ fs.closeSync(fd);
44
+ try {
45
+ return await fn();
46
+ }
47
+ finally {
48
+ try {
49
+ fs.unlinkSync(lockPath);
37
50
  }
38
51
  catch { }
39
52
  }
40
- throw error;
41
53
  }
42
- return {
43
- rollback() {
44
- if (replaced)
45
- fs.rmSync(filePath, { force: true });
46
- if (hadOriginal && fs.existsSync(backup))
47
- fs.renameSync(backup, filePath);
48
- },
49
- finalize() {
50
- fs.rmSync(backup, { force: true });
51
- },
52
- };
54
+ }
55
+ export function writeSquareDoc(squarePath, doc) {
56
+ const temporary = path.join(path.dirname(squarePath), `.${path.basename(squarePath)}.${process.pid}.${Date.now()}.tmp`);
57
+ fs.writeFileSync(temporary, renderSquareDoc(doc));
58
+ fs.renameSync(temporary, squarePath);
59
+ saveRuntimeSidecar(squarePath, doc.runtime);
60
+ }
61
+ export function appendAct(squarePath, doc, act) {
62
+ const stored = applyActs(doc, [act])[0];
63
+ writeSquareDoc(squarePath, doc);
64
+ return stored;
65
+ }
66
+ function applyActs(doc, acts, mutateRuntime) {
67
+ const stored = [];
68
+ for (const act of acts) {
69
+ const item = { ...act, index: doc.runtime.nextActIndex };
70
+ doc.runtime.nextActIndex++;
71
+ doc.acts.push(item);
72
+ if (item.actor !== undefined)
73
+ touchPresenceCursor(doc, item.actor, item.at, item.index);
74
+ stored.push(item);
75
+ }
76
+ mutateRuntime?.(doc);
77
+ return stored;
78
+ }
79
+ /**
80
+ * Publish a dependent persistence file before the Square document. A retained
81
+ * backup lets a failed document commit restore the prior file exactly.
82
+ */
83
+ function prepareAppend(filePath, block, existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '') {
84
+ return stageReplacement(filePath, (stage) => {
85
+ fs.writeFileSync(stage, `${existing}${existing === '' ? '' : '\n'}${block}\n`);
86
+ });
53
87
  }
54
88
  function plan(doc, intent) {
55
89
  switch (intent.type) {
56
90
  case 'join': {
57
91
  const decision = decideJoin(doc, intent.name, intent.now);
58
- return { result: decision, acts: [decision.joinAct], afterCommit: [] };
92
+ return { result: decision, acts: [decision.joinAct] };
59
93
  }
60
94
  case 'say': {
61
95
  const decision = decideAct(doc, intent);
62
96
  return {
63
97
  result: decision,
64
98
  acts: decision.type === 'sent' ? [decision.act] : [],
65
- afterCommit: [],
66
99
  };
67
100
  }
68
101
  case 'hold':
69
- return { result: undefined, acts: [coreHold(doc, intent.actor, intent.body, intent.now)], afterCommit: [] };
102
+ return { result: undefined, acts: [coreHold(doc, intent.actor, intent.body, intent.now)] };
70
103
  case 'resume':
71
- return { result: undefined, acts: [coreResume(doc, intent.actor, intent.now)], afterCommit: [] };
104
+ return { result: undefined, acts: [coreResume(doc, intent.actor, intent.now)] };
72
105
  case 'done':
73
- return { result: undefined, acts: [coreDone(doc, intent.name, intent.body, intent.now)], afterCommit: [] };
106
+ return { result: undefined, acts: [coreDone(doc, intent.name, intent.body, intent.now)] };
74
107
  case 'lease': {
75
108
  const name = resolveKnownName(doc, intent.name);
76
109
  const existing = freshWatchLease(doc, name, intent.at);
77
110
  if (existing !== undefined && !intent.force) {
78
- return { result: { type: 'active', lease: existing }, acts: [], afterCommit: [] };
111
+ return { result: { type: 'active', lease: existing }, acts: [] };
79
112
  }
80
113
  return {
81
114
  result: { type: 'started', name, replaced: existing !== undefined },
82
115
  acts: [],
83
- mutateRuntime: (runtime) => {
84
- runtime.leases[name] = {
116
+ mutateRuntime: (nextDoc) => {
117
+ writeWatchLease(nextDoc, name, {
85
118
  leaseId: intent.leaseId,
119
+ ...(intent.ownerId === undefined ? {} : { ownerId: intent.ownerId }),
86
120
  heartbeatAt: intent.at,
87
121
  expiresAt: intent.expiresAt,
88
122
  ...(intent.filter === undefined ? {} : { filter: intent.filter }),
89
- };
90
- touchPresenceCursor(doc, name, intent.at, 'watch');
123
+ });
124
+ touchPresenceCursor(nextDoc, name, intent.at);
91
125
  },
92
- afterCommit: [],
93
126
  };
94
127
  }
95
128
  case 'release-lease': {
96
129
  const name = resolveKnownName(doc, intent.name);
97
- if (doc.runtime.leases[name]?.leaseId !== intent.leaseId) {
98
- return { result: { released: false }, acts: [], afterCommit: [] };
130
+ if (watchLease(doc, name)?.leaseId !== intent.leaseId) {
131
+ return { result: { released: false }, acts: [] };
99
132
  }
100
133
  return {
101
134
  result: { released: true },
102
135
  acts: [],
103
- mutateRuntime: (runtime) => { delete runtime.leases[name]; },
104
- afterCommit: [],
136
+ mutateRuntime: (nextDoc) => { removeWatchLease(nextDoc, name, intent.leaseId); },
105
137
  };
106
138
  }
107
139
  case 'consume': {
@@ -109,15 +141,7 @@ function plan(doc, intent) {
109
141
  return {
110
142
  result: { name },
111
143
  acts: [],
112
- mutateRuntime: (runtime) => {
113
- const current = runtime.cursors[name];
114
- runtime.cursors[name] = {
115
- consumedThroughIndex: Math.max(current?.consumedThroughIndex ?? -1, intent.throughIndex),
116
- updatedAt: Math.max(current?.updatedAt ?? 0, intent.at),
117
- source: intent.source ?? 'watch',
118
- };
119
- },
120
- afterCommit: [],
144
+ mutateRuntime: (nextDoc) => { advanceCursor(nextDoc, name, intent.throughIndex, intent.at); },
121
145
  };
122
146
  }
123
147
  case 'compact': {
@@ -134,36 +158,8 @@ function plan(doc, intent) {
134
158
  const block = archive
135
159
  .map((act, index) => renderArtifactAct(act, { first: existing === '' && index === 0 }))
136
160
  .join('\n');
137
- return prepareFileReplacement(intent.archivePath, `${existing}${existing === '' ? '' : '\n'}${block}\n`);
161
+ return prepareAppend(intent.archivePath, block, existing);
138
162
  },
139
- afterCommit: [],
140
- };
141
- }
142
- case 'reconcile-delivery-backlog': {
143
- const { recent, historical } = partitionPendingDeliveries('', { now: intent.now, doc });
144
- const actor = intent.actor ?? 'doctor --fix reconcile-backlog';
145
- const receipts = historical.filter((item) => doc.runtime.mentionReceipts[item.recipient]?.[actId(item.actIndex)]?.status !== 'delivered');
146
- const result = { reconciled: receipts.length, skippedRecent: recent.length, items: historical };
147
- return {
148
- result,
149
- acts: [],
150
- ...(receipts.length === 0
151
- ? {}
152
- : {
153
- mutateRuntime: (runtime) => {
154
- for (const item of receipts) {
155
- const recipientReceipts = runtime.mentionReceipts[item.recipient] ?? {};
156
- recipientReceipts[actId(item.actIndex)] = {
157
- status: 'delivered',
158
- at: intent.now,
159
- reason: 'reconciled',
160
- actor,
161
- };
162
- runtime.mentionReceipts[item.recipient] = recipientReceipts;
163
- }
164
- },
165
- }),
166
- afterCommit: [],
167
163
  };
168
164
  }
169
165
  case 'repair':
@@ -174,36 +170,22 @@ function plan(doc, intent) {
174
170
  preparePersistence: intent.quarantine === undefined || intent.quarantine.blocks.length === 0
175
171
  ? undefined
176
172
  : () => {
177
- const existing = fs.existsSync(intent.quarantine.path) ? fs.readFileSync(intent.quarantine.path, 'utf8') : '';
178
173
  const block = intent.quarantine.blocks.join('\n\n');
179
- return prepareFileReplacement(intent.quarantine.path, `${existing}${existing === '' ? '' : '\n'}${block}\n`);
174
+ return prepareAppend(intent.quarantine.path, block);
180
175
  },
181
- afterCommit: [],
182
176
  };
183
177
  }
184
178
  }
185
- async function runEffects(squarePath, effects) {
186
- for (const effect of effects) {
187
- if (effect.type === 'dispatch-act-notifications') {
188
- await dispatchActNotifications(squarePath, effect.item);
189
- }
190
- }
191
- }
192
179
  function commitPlan(squarePath, doc, planned) {
193
180
  const nextDoc = planned.replaceDoc ?? doc;
194
- const applied = squareStore.apply(nextDoc, planned.acts, planned.mutateRuntime);
195
- const afterCommit = [
196
- ...planned.afterCommit,
197
- ...applied.acts.map((item) => ({ type: 'dispatch-act-notifications', item })),
198
- ];
199
- const committed = { result: planned.result, acts: applied.acts, afterCommit };
181
+ const committed = { result: planned.result, acts: applyActs(nextDoc, planned.acts, planned.mutateRuntime) };
200
182
  if (planned.acts.length === 0 && planned.mutateRuntime === undefined && planned.replaceDoc === undefined) {
201
183
  return committed;
202
184
  }
203
185
  let persistence;
204
186
  try {
205
187
  persistence = planned.preparePersistence?.();
206
- squareStore.commitOnce(squarePath, nextDoc, committed);
188
+ writeSquareDoc(squarePath, nextDoc);
207
189
  persistence?.finalize();
208
190
  return committed;
209
191
  }
@@ -217,31 +199,56 @@ function commitPlan(squarePath, doc, planned) {
217
199
  }
218
200
  /** The one mutation pipeline shared by package and CLI adapters. */
219
201
  export async function execute(squarePath, intent) {
220
- const committed = await squareStore.transact(squarePath, (doc) => commitPlan(squarePath, doc, plan(doc, intent)));
221
- await runEffects(squarePath, committed.afterCommit);
202
+ const committed = await withSquareLock(squarePath, () => {
203
+ const doc = loadSquare(squarePath);
204
+ return commitPlan(squarePath, doc, plan(doc, intent));
205
+ });
206
+ for (const act of committed.acts) {
207
+ if (act.kind === 'say')
208
+ await dispatchActNotifications(squarePath, act);
209
+ }
222
210
  return committed;
223
211
  }
224
212
  /** Application-owned artifact creation; adapters provide validated options and stdin text only. */
225
213
  export async function createSquare(squarePath, options, snippet) {
226
- await squareStore.create(squarePath, {
227
- force: options.force,
228
- text: renderSquare(options, snippet),
229
- result: undefined,
230
- });
231
- }
232
- export async function reconcileBacklog(squarePath, now = Date.now()) {
233
- const committed = await execute(squarePath, {
234
- type: 'reconcile-delivery-backlog',
235
- now,
214
+ await withSquareLock(squarePath, () => {
215
+ if (fs.existsSync(squarePath) && !options.force) {
216
+ throw new SquareError('conflict', `Refusing to overwrite existing square: ${squarePath}\nPass -f to overwrite.`);
217
+ }
218
+ const temporary = path.join(path.dirname(squarePath), `.${path.basename(squarePath)}.${process.pid}.${Date.now()}.tmp`);
219
+ fs.mkdirSync(path.dirname(squarePath), { recursive: true });
220
+ fs.writeFileSync(temporary, renderSquare(options, snippet));
221
+ fs.renameSync(temporary, squarePath);
222
+ saveRuntimeSidecar(squarePath, emptyRuntimeState(0));
236
223
  });
237
- return committed.result;
238
224
  }
239
225
  /** Keep artifact repair planning and dependent quarantine persistence inside the application boundary. */
240
226
  export async function repairSquare(squarePath) {
241
- const result = await squareStore.transactText(squarePath, (text) => {
227
+ const result = await withSquareLock(squarePath, () => {
228
+ let text;
229
+ try {
230
+ text = fs.readFileSync(squarePath, 'utf8');
231
+ }
232
+ catch (error) {
233
+ if (error.code === 'ENOENT')
234
+ throw new SquareError('not_found', `square file not found: ${squarePath}`);
235
+ throw error;
236
+ }
242
237
  const repair = planRepair(text);
243
238
  if (repair.diagnosis.unfixable || repair.repaired === undefined)
244
- return { repair, afterCommit: [] };
239
+ return { repair };
240
+ // Repair changes Markdown only. Keep the sidecar's runtime metadata and
241
+ // merge history boundaries so a doctor run cannot erase delivery state or
242
+ // reuse a stable activity index.
243
+ const sidecarRuntime = loadRuntimeSidecar(squarePath, repair.repaired.doc.runtime);
244
+ const indexesPreserved = repair.diagnosis.acts.every(({ act }, index) => repair.repaired.doc.acts[index]?.index === act.index);
245
+ if (indexesPreserved) {
246
+ repair.repaired.doc.runtime = mergeRuntimeState(repair.repaired.doc.runtime, sidecarRuntime);
247
+ }
248
+ else {
249
+ repair.repaired.doc.runtime = emptyRuntimeState(Math.max(repair.repaired.doc.runtime.nextActIndex, sidecarRuntime.nextActIndex));
250
+ repair.repaired.actions.push({ message: 'reset runtime delivery metadata because act indexes changed' });
251
+ }
245
252
  const quarantinePath = squarePath.replace(/\.md$/, '') + '.quarantine.md';
246
253
  const intent = {
247
254
  type: 'repair',
@@ -250,10 +257,8 @@ export async function repairSquare(squarePath) {
250
257
  ? {}
251
258
  : { quarantine: { path: quarantinePath, blocks: repair.repaired.quarantinedBlocks } }),
252
259
  };
253
- const committed = commitPlan(squarePath, repair.repaired.doc, plan(repair.repaired.doc, intent));
254
- return { repair, afterCommit: committed.afterCommit };
260
+ commitPlan(squarePath, repair.repaired.doc, plan(repair.repaired.doc, intent));
261
+ return { repair };
255
262
  });
256
- await runEffects(squarePath, result.afterCommit);
257
263
  return result.repair;
258
264
  }
259
- export const application = { execute, plan };
@@ -26,14 +26,6 @@ function touchParticipant(byKey, ordered, actor) {
26
26
  ordered.push(created);
27
27
  return created;
28
28
  }
29
- export function isWarm(lastSeen, now, threshold) {
30
- if (lastSeen === undefined)
31
- return false;
32
- if (!Number.isFinite(lastSeen) || !Number.isFinite(now) || !Number.isFinite(threshold) || threshold <= 0)
33
- return false;
34
- const delta = now - lastSeen;
35
- return delta >= 0 && delta <= threshold;
36
- }
37
29
  function pushThrottleAt(state, at) {
38
30
  if (typeof at === 'number' && Number.isFinite(at))
39
31
  state.throttleActivityAts.push(at);
@@ -59,7 +51,7 @@ function bellRecentAt(state, actor, at, windowMs) {
59
51
  }
60
52
  return latest;
61
53
  }
62
- export function fold(acts, options = {}) {
54
+ export function fold(acts) {
63
55
  const ordered = [];
64
56
  const byKey = new Map();
65
57
  const hold = { active: false };
@@ -168,12 +160,12 @@ export function validate(state, act, options = {}) {
168
160
  return { ok: true };
169
161
  }
170
162
  }
171
- export function perceive(state, act, viewer, options = {}) {
163
+ export function perceive(state, act, viewer) {
172
164
  void state;
173
165
  if (act.kind !== 'say')
174
166
  return 'full';
175
167
  const actor = act.actor;
176
- if ((options.includeActor ?? true) && sameName(actor, viewer))
168
+ if (sameName(actor, viewer))
177
169
  return 'full';
178
170
  if (act.reach === undefined || act.reach === 'bell')
179
171
  return 'full';
package/dist/stream.js CHANGED
@@ -1,149 +1,50 @@
1
- // stream.ts — live activity feed
2
1
  import fs from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import { setTimeout as sleep } from 'node:timers/promises';
5
4
  import { loadSquare } from './artifact.js';
6
- import { planActNotifications } from './notifications.js';
5
+ import { planActNotifications } from './delivery.js';
7
6
  import { sameName } from './model.js';
8
- import { indexedDelta } from './activity-feed.js';
9
- import { SLEEP_MS, actStableIndex, inSquareCount, latestIndexedActIndex, nowMs, rosterNames, sayNumberFor } from './runtime.js';
10
7
  import { quoteShell } from './presentation.js';
11
- import { enableRawMode, disableRawMode, enterAlternateScreen, leaveAlternateScreen, clearScreen, hideCursor, showCursor, renderStreamHeader, renderStreamEvent, renderWaiting, cursorUp, clearLine, } from './terminal.js';
12
- const INITIAL_DUMP = 20;
8
+ import { SLEEP_MS } from './runtime.js';
13
9
  export function streamNotificationFor(doc, item, recipient) {
14
10
  return planActNotifications(doc, item).find((notification) => sameName(notification.recipient, recipient));
15
11
  }
16
- export function matchesStreamRecipient(doc, item, recipient) {
17
- return streamNotificationFor(doc, item, recipient) !== undefined;
18
- }
19
- function renderDump(squarePath, doc, events, now) {
20
- const relevant = events.filter((item) => item.act.kind !== 'read');
21
- const active = inSquareCount(doc);
22
- if (relevant.length === 0)
23
- return `${renderStreamHeader(squarePath, rosterNames(doc).length, active)}\n\n (no activity yet)\n`;
24
- const header = renderStreamHeader(squarePath, rosterNames(doc).length, active);
25
- const body = relevant.map((item) => renderStreamEvent(item.act, now, item.act.kind === 'say' ? sayNumberFor(doc.acts, item.act) : undefined)).join('');
26
- return `${header}\n${body}`;
12
+ function streamRows(squarePath, doc, cursor, recipient) {
13
+ return doc.acts.filter((act) => act.index > cursor).flatMap((act) => {
14
+ const notification = recipient === undefined ? undefined : streamNotificationFor(doc, act, recipient);
15
+ if (recipient !== undefined && notification === undefined)
16
+ return [];
17
+ return [JSON.stringify({
18
+ seq: act.index,
19
+ square: squarePath,
20
+ ...act,
21
+ ...(notification === undefined ? {} : { route: notification.route }),
22
+ })];
23
+ });
27
24
  }
28
- export async function cmdStreamNdjson(squarePath, forName) {
25
+ /** Machine-readable tailing stays available; interactive terminal rendering was retired. */
26
+ export async function cmdStreamNdjson(squarePath, recipient) {
29
27
  if (!fs.existsSync(squarePath)) {
30
28
  process.stderr.write(`square not found: ${squarePath}\n`);
31
- process.exit(2);
29
+ process.exitCode = 2;
30
+ return;
32
31
  }
33
- let doc = loadSquare(squarePath);
34
32
  let cursor = -1;
35
- const emit = (events) => {
36
- for (const { act, index } of events) {
37
- const item = { act, index };
38
- const notification = forName ? streamNotificationFor(doc, item, forName) : undefined;
39
- if (forName && !notification)
40
- continue;
41
- process.stdout.write(`${JSON.stringify({
42
- seq: index,
43
- square: squarePath,
44
- ...act,
45
- ...(notification ? { via: notification.via } : {}),
46
- })}\n`);
47
- }
48
- };
49
- const backlog = indexedDelta(doc.acts, cursor);
50
- emit(backlog);
51
- cursor = latestIndexedActIndex(doc.acts.map((act) => ({ act, index: actStableIndex(act) })));
52
33
  while (true) {
53
- await sleep(SLEEP_MS);
54
34
  try {
55
- doc = loadSquare(squarePath);
35
+ const doc = loadSquare(squarePath);
36
+ for (const row of streamRows(squarePath, doc, cursor, recipient))
37
+ process.stdout.write(`${row}\n`);
38
+ cursor = Math.max(cursor, ...doc.acts.map((act) => act.index));
56
39
  }
57
40
  catch {
58
- // Transient read failure (e.g. concurrent writer mid-rename) retry next poll.
59
- continue;
41
+ // A concurrent artifact replacement is retried on the next poll.
60
42
  }
61
- const delta = indexedDelta(doc.acts, cursor);
62
- if (delta.length === 0)
63
- continue;
64
- emit(delta);
65
- cursor = latestIndexedActIndex(doc.acts.map((act) => ({ act, index: actStableIndex(act) })));
43
+ await sleep(SLEEP_MS);
66
44
  }
67
45
  }
68
- async function readKey() {
69
- return new Promise((resolve) => {
70
- const finish = (value) => {
71
- clearTimeout(timer);
72
- process.stdin.removeListener('data', onData);
73
- resolve(value);
74
- };
75
- const onData = (chunk) => finish(chunk.toString());
76
- const timer = setTimeout(() => finish(null), 100);
77
- timer.unref();
78
- process.stdin.once('data', onData);
79
- });
80
- }
81
46
  export async function cmdStream(squarePath) {
82
- if (!fs.existsSync(squarePath)) {
83
- process.stderr.write(`square not found: ${squarePath}\n`);
84
- process.exit(2);
85
- }
86
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
87
- process.stderr.write('✕ interactive stream requires a TTY\n');
88
- process.stderr.write(`» square --square-path ${quoteShell(path.resolve(squarePath))} stream --ndjson\n`);
89
- process.exit(2);
90
- }
91
- let doc = loadSquare(squarePath);
92
- let cursor = latestIndexedActIndex(doc.acts.map((act) => ({ act, index: actStableIndex(act) })));
93
- let stoppedBy;
94
- const stop = (signal) => {
95
- stoppedBy = signal;
96
- };
97
- const onSigint = () => stop('SIGINT');
98
- const onSigterm = () => stop('SIGTERM');
99
- process.once('SIGINT', onSigint);
100
- process.once('SIGTERM', onSigterm);
101
- enterAlternateScreen();
102
- enableRawMode();
103
- hideCursor();
104
- clearScreen();
105
- try {
106
- const allIndexed = doc.acts.map((act) => ({ act, index: actStableIndex(act) }));
107
- const initial = allIndexed.filter((item) => item.act.kind !== 'read').slice(-INITIAL_DUMP);
108
- cursor = Math.max(cursor, latestIndexedActIndex(initial));
109
- process.stdout.write(renderDump(squarePath, doc, initial, nowMs()));
110
- process.stdout.write(renderWaiting());
111
- while (stoppedBy === undefined) {
112
- const key = await readKey();
113
- if (key === 'q' || key === '\x1b' || key === '\x03')
114
- break;
115
- try {
116
- doc = loadSquare(squarePath);
117
- }
118
- catch {
119
- await sleep(SLEEP_MS);
120
- continue;
121
- }
122
- const delta = indexedDelta(doc.acts, cursor);
123
- if (delta.length === 0) {
124
- await sleep(SLEEP_MS);
125
- continue;
126
- }
127
- cursor = latestIndexedActIndex(doc.acts.map((act) => ({ act, index: actStableIndex(act) })));
128
- cursorUp(1);
129
- clearLine();
130
- const fresh = nowMs();
131
- for (const item of delta) {
132
- if (item.act.kind !== 'read') {
133
- process.stdout.write(renderStreamEvent(item.act, fresh, item.act.kind === 'say' ? sayNumberFor(doc.acts, item.act) : undefined));
134
- }
135
- }
136
- process.stdout.write(renderWaiting());
137
- }
138
- }
139
- finally {
140
- process.off('SIGINT', onSigint);
141
- process.off('SIGTERM', onSigterm);
142
- disableRawMode();
143
- showCursor();
144
- leaveAlternateScreen();
145
- showCursor();
146
- }
147
- if (stoppedBy !== undefined)
148
- process.exitCode = stoppedBy === 'SIGINT' ? 130 : 143;
47
+ process.stderr.write('✕ interactive stream was removed\n');
48
+ process.stderr.write(square --square-path ${quoteShell(path.resolve(squarePath))} stream --ndjson\n`);
49
+ process.exitCode = 2;
149
50
  }