team 0.1.2 → 0.2.0

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 (114) hide show
  1. package/README.md +22 -11
  2. package/dist/approve/approval.js +14 -9
  3. package/dist/approve/fingerprint.d.ts +14 -2
  4. package/dist/approve/fingerprint.js +21 -21
  5. package/dist/budgets/gate.js +7 -2
  6. package/dist/budgets/readings.d.ts +6 -2
  7. package/dist/budgets/readings.js +28 -2
  8. package/dist/budgets/run.js +8 -2
  9. package/dist/budgets/table.d.ts +2 -0
  10. package/dist/budgets/table.js +10 -5
  11. package/dist/cli.d.ts +2 -1
  12. package/dist/cli.js +8 -5
  13. package/dist/commands/add.d.ts +0 -1
  14. package/dist/commands/add.js +7 -5
  15. package/dist/commands/approve.js +17 -1
  16. package/dist/commands/doctor.d.ts +15 -1
  17. package/dist/commands/doctor.js +126 -8
  18. package/dist/commands/down.d.ts +4 -0
  19. package/dist/commands/down.js +32 -13
  20. package/dist/commands/init.d.ts +1 -1
  21. package/dist/commands/init.js +4 -2
  22. package/dist/commands/remove.js +5 -2
  23. package/dist/commands/status.d.ts +2 -0
  24. package/dist/commands/status.js +19 -1
  25. package/dist/commands/up.js +3 -1
  26. package/dist/commands/watch.d.ts +2 -0
  27. package/dist/commands/watch.js +28 -4
  28. package/dist/conformance/adapter.d.ts +5 -0
  29. package/dist/conformance/adapter.js +44 -0
  30. package/dist/file/check.d.ts +20 -0
  31. package/dist/file/check.js +112 -0
  32. package/dist/file/lines.js +13 -2
  33. package/dist/file/sections/budgets.d.ts +15 -0
  34. package/dist/file/sections/budgets.js +180 -0
  35. package/dist/file/sections/coordinator.d.ts +2 -0
  36. package/dist/file/sections/coordinator.js +11 -0
  37. package/dist/file/sections/format.d.ts +2 -0
  38. package/dist/file/sections/format.js +13 -0
  39. package/dist/file/sections/identity.d.ts +2 -0
  40. package/dist/file/sections/identity.js +103 -0
  41. package/dist/file/sections/index.d.ts +9 -0
  42. package/dist/file/sections/index.js +43 -0
  43. package/dist/file/sections/lead.d.ts +4 -0
  44. package/dist/file/sections/lead.js +18 -0
  45. package/dist/file/sections/limits.d.ts +2 -0
  46. package/dist/file/sections/limits.js +43 -0
  47. package/dist/file/sections/machine.d.ts +2 -0
  48. package/dist/file/sections/machine.js +38 -0
  49. package/dist/file/sections/operator.d.ts +2 -0
  50. package/dist/file/sections/operator.js +11 -0
  51. package/dist/file/sections/project.d.ts +2 -0
  52. package/dist/file/sections/project.js +13 -0
  53. package/dist/file/sections/rules.d.ts +2 -0
  54. package/dist/file/sections/rules.js +9 -0
  55. package/dist/file/sections/seats.d.ts +7 -0
  56. package/dist/file/sections/seats.js +161 -0
  57. package/dist/file/sections/section.d.ts +32 -0
  58. package/dist/file/sections/section.js +7 -0
  59. package/dist/file/sections/session.d.ts +2 -0
  60. package/dist/file/sections/session.js +16 -0
  61. package/dist/file/sections/tools.d.ts +2 -0
  62. package/dist/file/sections/tools.js +43 -0
  63. package/dist/file/sections/trust.d.ts +2 -0
  64. package/dist/file/sections/trust.js +20 -0
  65. package/dist/file/sections/units.d.ts +16 -0
  66. package/dist/file/sections/units.js +8 -0
  67. package/dist/file/sections/visibility.d.ts +2 -0
  68. package/dist/file/sections/visibility.js +12 -0
  69. package/dist/file/sections/watch-checks.d.ts +9 -0
  70. package/dist/file/sections/watch-checks.js +14 -0
  71. package/dist/file/sections/watch.d.ts +14 -0
  72. package/dist/file/sections/watch.js +111 -0
  73. package/dist/file/sections/workspace.d.ts +4 -0
  74. package/dist/file/sections/workspace.js +77 -0
  75. package/dist/file/types.d.ts +1 -0
  76. package/dist/file/validate.d.ts +4 -12
  77. package/dist/file/validate.js +45 -688
  78. package/dist/herdr.d.ts +1 -0
  79. package/dist/herdr.js +11 -1
  80. package/dist/launch/agent.js +4 -3
  81. package/dist/launch/deliver.d.ts +4 -0
  82. package/dist/launch/deliver.js +151 -8
  83. package/dist/launch/execute.d.ts +2 -0
  84. package/dist/launch/execute.js +21 -11
  85. package/dist/launch/plan.js +16 -2
  86. package/dist/profiles/antigravity.yaml +12 -2
  87. package/dist/profiles/claude-code.yaml +21 -6
  88. package/dist/profiles/codex.yaml +14 -1
  89. package/dist/profiles/cursor.yaml +45 -5
  90. package/dist/profiles/overrides.d.ts +67 -0
  91. package/dist/profiles/overrides.js +190 -0
  92. package/dist/profiles/profile.d.ts +3 -0
  93. package/dist/profiles/profile.js +5 -1
  94. package/dist/state.d.ts +2 -0
  95. package/dist/status/compare.js +7 -4
  96. package/dist/store/store.d.ts +5 -0
  97. package/dist/version.d.ts +1 -0
  98. package/dist/version.js +7 -0
  99. package/dist/watch/checks/budget.js +49 -53
  100. package/dist/watch/dialect.d.ts +6 -1
  101. package/dist/watch/dialect.js +68 -12
  102. package/dist/watch/pass.d.ts +8 -1
  103. package/dist/watch/pass.js +19 -9
  104. package/dist/watch/screen-core.d.ts +41 -4
  105. package/dist/watch/screen-core.js +458 -93
  106. package/dist/watch/screen-data.d.ts +25 -0
  107. package/dist/watch/screen-file.d.ts +8 -2
  108. package/dist/watch/screen-file.js +325 -38
  109. package/dist/watch/screen-profile.d.ts +19 -0
  110. package/dist/watch/screen-profile.js +1 -0
  111. package/dist/watch/screen.d.ts +22 -4
  112. package/dist/watch/screen.js +39 -5
  113. package/package.json +9 -3
  114. package/schema/team.schema.json +498 -0
@@ -43,75 +43,375 @@ export function statusRowOf(data, lines) {
43
43
  const at = statusIndex(composer, lines, composer.mode === 'status-then-one', () => false);
44
44
  return at === 'stop' || at < 0 ? null : (lines[at] ?? null);
45
45
  }
46
+ /**
47
+ * The fold-shaped frame a window shows, or null: the marker row and the count it names (any
48
+ * integer, zero included), the tail rows below it, the box's two rules around both, and a footer
49
+ * under the bottom one. The top rule's width is the width the text wrapped at. The shape alone;
50
+ * whether the fold holds the typed text is the caller's to verify.
51
+ */
52
+ function foldFrame(data, lines) {
53
+ const composer = data.composer;
54
+ if (composer.mode !== 'two-rules-footer-below' || !composer.fold)
55
+ return null;
56
+ let marker = -1;
57
+ let found = null;
58
+ for (let i = lines.length - 1; i >= 0; i--) {
59
+ const hit = composer.fold.exec(lines[i] ?? '');
60
+ if (hit) {
61
+ marker = i;
62
+ found = hit;
63
+ break;
64
+ }
65
+ }
66
+ if (marker < 0 || !found)
67
+ return null;
68
+ // The frame, as the capture draws it (folded-rules.txt): the box's opening rule at the pane's
69
+ // first column — the profile's rule pattern is anchored to it — sits directly above the
70
+ // marker, and the box's closing rule is the window's last rule row, drawn at the opening
71
+ // rule's own width. A rule-looking row between the marker and the closing rule is a tail row
72
+ // then, content that cannot match the typed text's own rows; a rule row where the capture
73
+ // draws none — directly above the opening rule, or at a width the opening rule does not have
74
+ // — leaves the read unable to tell the frame from content, and it fails closed.
75
+ let top = -1;
76
+ for (let i = marker - 1; i >= 0; i--) {
77
+ if (composer.rule.test(lines[i] ?? '')) {
78
+ top = i;
79
+ break;
80
+ }
81
+ }
82
+ if (top < 0)
83
+ return null;
84
+ if (top > 0 && composer.rule.test(lines[top - 1] ?? ''))
85
+ return null;
86
+ for (let i = top + 1; i < marker; i++) {
87
+ if ((lines[i] ?? '').trim())
88
+ return null;
89
+ }
90
+ let bottom = -1;
91
+ for (let i = lines.length - 1; i > marker; i--) {
92
+ if (composer.rule.test(lines[i] ?? '')) {
93
+ bottom = i;
94
+ break;
95
+ }
96
+ }
97
+ if (bottom < 0)
98
+ return null;
99
+ const width = (lines[top] ?? '').trim().length;
100
+ if ((lines[bottom] ?? '').trim().length !== width)
101
+ return null;
102
+ const rows = lines.slice(marker + 1, bottom).filter((line) => line.trim());
103
+ if (rows.length === 0)
104
+ return null;
105
+ if (!lines.slice(bottom + 1).some((line) => composer.footers.some((pattern) => pattern.test(line))))
106
+ return null;
107
+ const count = Number(found[1]);
108
+ if (!Number.isInteger(count))
109
+ return null;
110
+ return { count, rows, width };
111
+ }
112
+ /**
113
+ * The fold a `two-rules-footer-below` composer's window shows, or null. A marker whose count is
114
+ * zero names no hidden rows to compare against, so it is no fold; it is still a marker, and
115
+ * `foldMarked` catches it.
116
+ */
117
+ export function foldOf(data, lines) {
118
+ const frame = foldFrame(data, lines);
119
+ if (frame === null || frame.count < 1)
120
+ return null;
121
+ return frame;
122
+ }
123
+ /**
124
+ * Whether the window shows a fold marker at all, whatever count it names: a marker claiming zero
125
+ * hidden rows (or a count a fold could not have) is still a box that is not showing the whole
126
+ * text, and it must never be taken for an ordinary unsent box and submitted unverified.
127
+ */
128
+ export function foldMarked(data, lines) {
129
+ return foldFrame(data, lines) !== null;
130
+ }
46
131
  /** `lines` is already the window: the last 20 lines, each keeping any ANSI styling. Matching
47
132
  * runs on each line's plain form, trimmed at the end; the styled form is kept for the one
48
133
  * question styling can answer, whether the composer's input line is all dim. */
49
134
  function plainLines(lines) {
50
135
  return lines.map((line) => stripSgr(line).trimEnd());
51
136
  }
137
+ const SCREEN_KINDS = new Set([
138
+ 'idle',
139
+ 'working',
140
+ 'unsent',
141
+ 'permission',
142
+ 'trust',
143
+ 'question',
144
+ 'unknown',
145
+ ]);
146
+ function getProfileFn(profile, key) {
147
+ if (!profile || (typeof profile !== 'object' && typeof profile !== 'function'))
148
+ return undefined;
149
+ try {
150
+ const fn = profile[key];
151
+ if (fn === undefined || fn === null)
152
+ return undefined;
153
+ if (typeof fn !== 'function')
154
+ return 'fail';
155
+ return fn;
156
+ }
157
+ catch {
158
+ return 'fail';
159
+ }
160
+ }
161
+ function evalPredicate(predicate, lines) {
162
+ if (predicate === 'fail')
163
+ return 'fail';
164
+ if (typeof predicate !== 'function')
165
+ return 'fail';
166
+ try {
167
+ const res = predicate(lines);
168
+ if (res && (typeof res === 'object' || typeof res === 'function') && typeof res.catch === 'function') {
169
+ try {
170
+ res.catch(() => { });
171
+ }
172
+ catch { }
173
+ return 'fail';
174
+ }
175
+ if (res === true)
176
+ return 'match';
177
+ if (res === false)
178
+ return 'miss';
179
+ return 'fail';
180
+ }
181
+ catch {
182
+ return 'fail';
183
+ }
184
+ }
185
+ function callComposer(composer, lines) {
186
+ if (composer === 'fail')
187
+ return { kind: 'unknown' };
188
+ if (typeof composer !== 'function')
189
+ return { kind: 'unknown' };
190
+ try {
191
+ const reading = composer(lines);
192
+ if (reading && (typeof reading === 'object' || typeof reading === 'function') && typeof reading.catch === 'function') {
193
+ try {
194
+ reading.catch(() => { });
195
+ }
196
+ catch { }
197
+ return { kind: 'unknown' };
198
+ }
199
+ if (!reading || typeof reading !== 'object')
200
+ return { kind: 'unknown' };
201
+ let kind;
202
+ try {
203
+ kind = reading.kind;
204
+ }
205
+ catch {
206
+ return { kind: 'unknown' };
207
+ }
208
+ if (typeof kind !== 'string' || !SCREEN_KINDS.has(kind)) {
209
+ return { kind: 'unknown' };
210
+ }
211
+ return {
212
+ kind: kind,
213
+ from: 0,
214
+ input: -1,
215
+ };
216
+ }
217
+ catch {
218
+ return { kind: 'unknown' };
219
+ }
220
+ }
52
221
  /** `lines` is already the window: the last 20 lines, each trimmed at the end. */
53
222
  export function classifyLines(data, lines, clock) {
54
- const now = clock?.now ?? Date.now;
55
- const budget = clock?.budgetMs ?? BUDGET_MS;
56
- const start = now();
57
- const tick = () => now() - start > budget;
58
- const plain = plainLines(lines);
59
- const order = [
60
- ['unknown', data.unknown],
61
- ['trust', data.trust],
62
- ['permission', data.permission],
63
- ['question', data.question],
64
- ['working', data.working],
65
- ];
66
- for (const [kind, stage] of order) {
67
- if (!stage)
68
- continue;
69
- for (const rule of stage.rules) {
70
- const hit = ruleMatches(data, plain, rule, tick);
71
- if (hit === 'stop')
223
+ try {
224
+ const now = clock?.now ?? Date.now;
225
+ const budget = clock?.budgetMs ?? BUDGET_MS;
226
+ const start = now();
227
+ const tick = () => now() - start > budget;
228
+ const plain = plainLines(lines);
229
+ // Rule (a): Every hatch predicate is monotone toward caution: hatch OR data, for working,
230
+ // every dialog (trust, permission, question) and unknown. The data stage of a profile always runs;
231
+ // a hatch predicate can only add a match. A hatch can only add caution, never remove it.
232
+ const cautionStages = [
233
+ ['unknown', data.unknown],
234
+ ['trust', data.trust],
235
+ ['permission', data.permission],
236
+ ['question', data.question],
237
+ ['working', data.working],
238
+ ];
239
+ for (const [kind, stage] of cautionStages) {
240
+ if (tick())
72
241
  return { kind: 'unknown' };
73
- if (hit)
74
- return { kind };
242
+ const fn = getProfileFn(data.profile, kind);
243
+ if (fn !== undefined) {
244
+ const outcome = evalPredicate(fn, plain);
245
+ if (outcome === 'match')
246
+ return { kind };
247
+ if (outcome === 'fail')
248
+ return { kind: 'unknown' };
249
+ }
250
+ if (stage) {
251
+ for (const rule of stage.rules) {
252
+ const hit = ruleMatches(data, plain, rule, tick);
253
+ if (hit === 'stop')
254
+ return { kind: 'unknown' };
255
+ if (hit)
256
+ return { kind };
257
+ }
258
+ }
75
259
  }
76
- }
77
- const composed = compose(data, plain, lines, tick);
78
- if (composed.kind === 'stop' || composed.kind === 'unknown')
79
- return { kind: 'unknown' };
80
- if (composed.kind !== 'idle' && composed.kind !== 'unsent')
260
+ // Composer stage: comes from hatch OR from data (refused if both at load).
261
+ const composerFn = getProfileFn(data.profile, 'composer');
262
+ if (composerFn !== undefined) {
263
+ const composed = callComposer(composerFn, plain);
264
+ if (composed.kind === 'stop' || composed.kind === 'unknown')
265
+ return { kind: 'unknown' };
266
+ const marked = floorHits(plain, 0, -1, data.chrome, tick);
267
+ if (marked === 'stop' || marked)
268
+ return { kind: 'unknown' };
269
+ return { kind: composed.kind };
270
+ }
271
+ const composed = compose(data, plain, lines, tick);
272
+ if (composed.kind === 'stop' || composed.kind === 'unknown')
273
+ return { kind: 'unknown' };
274
+ if (composed.kind !== 'idle' && composed.kind !== 'unsent')
275
+ return { kind: composed.kind };
276
+ const marked = floorHits(plain, composed.from, composed.input, data.chrome, tick);
277
+ if (marked === 'stop' || marked)
278
+ return { kind: 'unknown' };
81
279
  return { kind: composed.kind };
82
- const marked = floorHits(plain, composed.from, composed.input, data.chrome, tick);
83
- if (marked === 'stop' || marked)
280
+ }
281
+ catch {
84
282
  return { kind: 'unknown' };
85
- return { kind: composed.kind };
283
+ }
86
284
  }
87
285
  /**
88
- * The composer alone, for a turn that is still running. The unknown stage is
89
- * read first, as in the full classification, and the floor still applies.
286
+ * The composer alone, for a turn that is still running. The dialog stages and
287
+ * floor must not be overridden by composer readings returning idle or unsent.
90
288
  */
91
289
  export function composeLines(data, lines, clock) {
92
- const now = clock?.now ?? Date.now;
93
- const budget = clock?.budgetMs ?? BUDGET_MS;
94
- const start = now();
95
- const tick = () => now() - start > budget;
96
- const plain = plainLines(lines);
97
- if (data.unknown) {
98
- for (const rule of data.unknown.rules) {
99
- const hit = ruleMatches(data, plain, rule, tick);
100
- if (hit === 'stop')
290
+ try {
291
+ const now = clock?.now ?? Date.now;
292
+ const budget = clock?.budgetMs ?? BUDGET_MS;
293
+ const start = now();
294
+ const tick = () => now() - start > budget;
295
+ const plain = plainLines(lines);
296
+ const composerFn = getProfileFn(data.profile, 'composer');
297
+ if (composerFn !== undefined) {
298
+ // Profile has a HATCH composer (no data composer).
299
+ // Check if any caution stage (data or hatch) matches:
300
+ const stages = [
301
+ ['unknown', data.unknown],
302
+ ['trust', data.trust],
303
+ ['permission', data.permission],
304
+ ['question', data.question],
305
+ ['working', data.working],
306
+ ];
307
+ for (const [kind, stage] of stages) {
308
+ if (tick())
309
+ return { kind: 'unknown' };
310
+ const fn = getProfileFn(data.profile, kind);
311
+ if (fn !== undefined) {
312
+ const outcome = evalPredicate(fn, plain);
313
+ if (outcome === 'match' || outcome === 'fail')
314
+ return { kind: 'unknown' };
315
+ }
316
+ if (stage) {
317
+ for (const rule of stage.rules) {
318
+ const hit = ruleMatches(data, plain, rule, tick);
319
+ if (hit === 'stop')
320
+ return { kind: 'unknown' };
321
+ if (hit)
322
+ return { kind: 'unknown' };
323
+ }
324
+ }
325
+ }
326
+ const composed = callComposer(composerFn, plain);
327
+ if (composed.kind === 'stop' || composed.kind === 'unknown')
101
328
  return { kind: 'unknown' };
102
- if (hit)
329
+ const marked = floorHits(plain, 0, -1, data.chrome, tick);
330
+ if (marked === 'stop' || marked)
103
331
  return { kind: 'unknown' };
332
+ return { kind: composed.kind };
104
333
  }
105
- }
106
- const composed = compose(data, plain, lines, tick);
107
- if (composed.kind === 'stop' || composed.kind === 'unknown')
108
- return { kind: 'unknown' };
109
- if (composed.kind !== 'idle' && composed.kind !== 'unsent')
334
+ // Profile has a DATA composer.
335
+ // Check hatch dialog predicates:
336
+ const dialogKinds = ['unknown', 'trust', 'permission', 'question'];
337
+ for (const kind of dialogKinds) {
338
+ if (tick())
339
+ return { kind: 'unknown' };
340
+ const fn = getProfileFn(data.profile, kind);
341
+ if (fn !== undefined) {
342
+ const outcome = evalPredicate(fn, plain);
343
+ if (outcome === 'match' || outcome === 'fail')
344
+ return { kind: 'unknown' };
345
+ }
346
+ }
347
+ // Also if hatch working returns 'fail', fail safe:
348
+ const workingFn = getProfileFn(data.profile, 'working');
349
+ if (workingFn !== undefined) {
350
+ const outcome = evalPredicate(workingFn, plain);
351
+ if (outcome === 'fail')
352
+ return { kind: 'unknown' };
353
+ }
354
+ if (data.unknown) {
355
+ for (const rule of data.unknown.rules) {
356
+ const hit = ruleMatches(data, plain, rule, tick);
357
+ if (hit === 'stop')
358
+ return { kind: 'unknown' };
359
+ if (hit)
360
+ return { kind: 'unknown' };
361
+ }
362
+ }
363
+ const composed = compose(data, plain, lines, tick);
364
+ if (composed.kind === 'stop' || composed.kind === 'unknown')
365
+ return { kind: 'unknown' };
366
+ if (composed.kind !== 'idle' && composed.kind !== 'unsent')
367
+ return { kind: composed.kind };
368
+ const marked = floorHits(plain, composed.from, composed.input, data.chrome, tick);
369
+ if (marked === 'stop' || marked)
370
+ return { kind: 'unknown' };
110
371
  return { kind: composed.kind };
111
- const marked = floorHits(plain, composed.from, composed.input, data.chrome, tick);
112
- if (marked === 'stop' || marked)
372
+ }
373
+ catch {
113
374
  return { kind: 'unknown' };
114
- return { kind: composed.kind };
375
+ }
376
+ }
377
+ /**
378
+ * The input box a composer draws for its current text, or null when the composer does not read
379
+ * `unsent`: an idle box holds nothing, and every other shape is not the box. The rows are the
380
+ * ones the mode's own frame delimits — between the prompt row and the box's closing line; the
381
+ * `status-then-one` fallback (the captured completion frame) delimits nothing, so its box is
382
+ * its one input row, the popup rows below it being its filter's to exclude. The caller compares
383
+ * the box to the text it typed; nothing here decides that.
384
+ */
385
+ export function composerBox(data, lines) {
386
+ if (!data.composer || data.profile?.composer)
387
+ return null;
388
+ const plain = plainLines(lines);
389
+ const hit = compose(data, plain, lines, () => false);
390
+ if (hit.kind === 'stop' || hit.kind === 'unknown')
391
+ return null;
392
+ if (hit.kind !== 'unsent')
393
+ return null;
394
+ const input = plain[hit.input] ?? '';
395
+ const found = data.composer.prompt.exec(input);
396
+ if (!found)
397
+ return null;
398
+ let rest = input.slice(found[0].length);
399
+ const suffix = 'stripSuffix' in data.composer ? data.composer.stripSuffix : undefined;
400
+ if (suffix)
401
+ rest = rest.replace(suffix, '');
402
+ const indent = found[0].length + rest.length - rest.trimStart().length;
403
+ const rows = hit.rows ?? [];
404
+ // The pane draws empty rows of its own under the text, inside the box's frame — the drop
405
+ // before the status line, or before the closing rule. The captures name them per profile
406
+ // (`frame_rows`), and only those are stripped: they are the box's frame, not its content.
407
+ // Every row after them stays, so a box that shows an empty row the typed text does not have
408
+ // is not the typed text, and the caller refuses it.
409
+ let frame = data.composer.frameRows;
410
+ while (frame > 0 && rows.length > 0 && rows[rows.length - 1] === '') {
411
+ rows.pop();
412
+ frame -= 1;
413
+ }
414
+ return { first: rest.trimStart(), indent, rows, wrap: 'wrap' in data.composer ? data.composer.wrap : undefined };
115
415
  }
116
416
  function ruleMatches(data, lines, rule, tick) {
117
417
  const where = rule.onFooter ? [footerLine(data, lines)] : lines;
@@ -233,59 +533,110 @@ function compose(data, plain, styled, tick) {
233
533
  return statusThenOne(plain, styled, composer, tick);
234
534
  return twoRules(plain, styled, composer, tick);
235
535
  }
536
+ /** The rule a line carries, or null: the profile's rule pattern anchored to the line's own
537
+ * first column, as the captures draw the box's rules. */
538
+ function ruleRun(composer, line) {
539
+ const hit = composer.rule.exec(line);
540
+ return hit === null ? null : hit[0];
541
+ }
236
542
  function boxToRule(lines, styled, composer, tick) {
237
- let input = -1;
543
+ // The frame first, as the captures draw it (unsent-typed-ansi.txt): a rule is an unbroken run
544
+ // from the pane's first column — the profile's rule pattern is anchored to it — and the box's
545
+ // closing rule is the window's last rule row; nothing rule-shaped sits under the box's
546
+ // footers, and a rule-looking row the box holds is drawn at the content column: content,
547
+ // never the frame.
548
+ let close = -1;
238
549
  for (let i = lines.length - 1; i >= 0; i--) {
239
550
  if (tick())
240
551
  return { kind: 'stop' };
241
- if (composer.prompt.test(lines[i] ?? '')) {
242
- input = i;
552
+ if (ruleRun(composer, lines[i] ?? '') !== null) {
553
+ close = i;
243
554
  break;
244
555
  }
245
556
  }
246
- if (input < 0)
557
+ if (close < 0)
247
558
  return { kind: 'unknown' };
248
- // The frame is the box itself: a closing rule under this prompt. A rule anywhere
249
- // above, or a rule with shell output under it, is not the box. A shell's last
250
- // prompt never has that closing rule.
251
- let close = -1;
252
- for (let i = input + 1; i < lines.length; i++) {
559
+ // The capture draws only the box's own footers under the closing rule. A prompt-looking row
560
+ // there is a row the frame does not draw — leftover-box.txt shows a second, leftover prompt
561
+ // under the box's status — so this pane is not the one box the caller can compare against
562
+ // typed text, and the read fails closed.
563
+ for (let i = close + 1; i < lines.length; i++) {
253
564
  if (tick())
254
565
  return { kind: 'stop' };
255
- if (composer.rule.test(lines[i] ?? '')) {
256
- close = i;
257
- break;
258
- }
566
+ if (composer.prompt.test(lines[i] ?? ''))
567
+ return { kind: 'unknown' };
259
568
  }
260
- if (close < 0)
261
- return { kind: 'unknown' };
262
- const above = input > 0 && composer.rule.test(lines[input - 1] ?? '');
263
- let footer = false;
264
- for (let j = close + 1; j < lines.length; j++) {
569
+ let open = -1;
570
+ for (let i = close - 1; i >= 0; i--) {
265
571
  if (tick())
266
572
  return { kind: 'stop' };
267
- if (statusFooter(lines[j] ?? '')) {
268
- footer = true;
573
+ if (ruleRun(composer, lines[i] ?? '') !== null) {
574
+ open = i;
269
575
  break;
270
576
  }
271
577
  }
272
- // The opening rule on the line directly above, or the status footer when that
273
- // rule has scrolled out of the window.
274
- if (!above && !footer)
275
- return { kind: 'unknown' };
276
- const from = above ? input - 1 : input;
578
+ let input;
579
+ let from;
580
+ if (open >= 0) {
581
+ // The two rules of one box are drawn at one width (53 columns in unsent-typed-ansi.txt).
582
+ if (ruleRun(composer, lines[close] ?? '') !== ruleRun(composer, lines[open] ?? ''))
583
+ return { kind: 'unknown' };
584
+ // The input row is the box's first row under its opening frame, and it carries the prompt
585
+ // at the capture's own column. A row there that does not is not this box's input — the read
586
+ // fails closed rather than take some later row whose content begins with a prompt glyph,
587
+ // which would drop the rows above it from the box the caller compares.
588
+ input = open + 1;
589
+ if (input >= close || !composer.prompt.test(lines[input] ?? ''))
590
+ return { kind: 'unknown' };
591
+ from = open;
592
+ }
593
+ else {
594
+ // The opening rule has scrolled out of the window. The frame is then the ordered footers
595
+ // under the closing rule — every pattern matched in sequence, equal count, as the capture
596
+ // draws them — and the input row is the lowest row above the closing rule carrying the
597
+ // prompt at that column: content is drawn at the text's column, so a row at the prompt's
598
+ // own can only be the box's first. Any non-blank row above it is a row the frame does not
599
+ // explain, and the read fails closed.
600
+ let footer = false;
601
+ const rawFooters = [];
602
+ for (let j = close + 1; j < lines.length; j++) {
603
+ if (tick())
604
+ return { kind: 'stop' };
605
+ const raw = lines[j] ?? '';
606
+ if (raw.trim())
607
+ rawFooters.push(raw);
608
+ }
609
+ if (composer.footers.length > 0 && rawFooters.length === composer.footers.length) {
610
+ footer = composer.footers.every((pattern, idx) => pattern.test(rawFooters[idx] ?? ''));
611
+ }
612
+ if (!footer)
613
+ return { kind: 'unknown' };
614
+ input = -1;
615
+ for (let i = close - 1; i >= 0; i--) {
616
+ if (tick())
617
+ return { kind: 'stop' };
618
+ if (composer.prompt.test(lines[i] ?? '')) {
619
+ input = i;
620
+ break;
621
+ }
622
+ }
623
+ if (input < 0)
624
+ return { kind: 'unknown' };
625
+ for (let i = 0; i < input; i++) {
626
+ if (tick())
627
+ return { kind: 'stop' };
628
+ if ((lines[i] ?? '').trim())
629
+ return { kind: 'unknown' };
630
+ }
631
+ from = input;
632
+ }
633
+ const rows = lines.slice(input + 1, close);
277
634
  for (let i = input + 1; i < close; i++) {
278
635
  if ((lines[i] ?? '').trim())
279
- return { kind: 'unsent', from, input };
636
+ return { kind: 'unsent', from, input, rows };
280
637
  }
281
638
  const typed = (lines[input] ?? '').replace(composer.prompt, '').trim();
282
- return { kind: placeholder(typed, composer, lines[input] ?? '', styled[input]) ? 'idle' : 'unsent', from, input };
283
- }
284
- // The composer footer, not a line of shell output under a stray rule. The model
285
- // row uses a middot; the permissions line is the other footer.
286
- function statusFooter(line) {
287
- const text = line.trim();
288
- return text.includes('·') || /bypass permissions/i.test(text);
639
+ return { kind: placeholder(typed, composer, lines[input] ?? '', styled[input]) ? 'idle' : 'unsent', from, input, rows };
289
640
  }
290
641
  function statusLast(lines, styled, composer, tick, allowOneTrailing) {
291
642
  const status = statusIndex(composer, lines, allowOneTrailing, tick);
@@ -298,10 +649,11 @@ function statusLast(lines, styled, composer, tick, allowOneTrailing) {
298
649
  input--;
299
650
  if (input < 0)
300
651
  return { kind: 'unknown' };
652
+ const rows = lines.slice(input + 1, status);
301
653
  for (let i = input + 1; i < status; i++)
302
654
  if ((lines[i] ?? '').trim())
303
- return { kind: 'unsent', from: input, input };
304
- return { kind: placeholder(stripTyped(lines[input] ?? '', composer), composer, lines[input] ?? '', styled[input]) ? 'idle' : 'unsent', from: input, input };
655
+ return { kind: 'unsent', from: input, input, rows };
656
+ return { kind: placeholder(stripTyped(lines[input] ?? '', composer), composer, lines[input] ?? '', styled[input]) ? 'idle' : 'unsent', from: input, input, rows };
305
657
  }
306
658
  function statusThenOne(lines, styled, composer, tick) {
307
659
  const found = statusLast(lines, styled, composer, tick, true);
@@ -332,25 +684,37 @@ function statusThenOne(lines, styled, composer, tick) {
332
684
  return { kind: 'unknown' };
333
685
  }
334
686
  function twoRules(lines, styled, composer, tick) {
687
+ // The frame first: the box's bottom rule is the window's last rule row, and its top rule is
688
+ // the nearest rule row above it — the captures draw exactly two rules around the box, both
689
+ // unbroken runs from the pane's first column (unsent.txt draws them at rows 7 and 12). A
690
+ // rule-looking row the box holds is drawn at the content column and is content, never frame.
335
691
  let bottom = -1;
336
692
  let top = -1;
337
- let input = -1;
338
693
  for (let i = lines.length - 1; i >= 0; i--) {
339
694
  if (tick())
340
695
  return { kind: 'stop' };
341
- const line = lines[i] ?? '';
342
- if (composer.rule.test(line)) {
696
+ if (composer.rule.test(lines[i] ?? '')) {
343
697
  if (bottom === -1)
344
698
  bottom = i;
345
- else if (top === -1 && input !== -1) {
699
+ else {
346
700
  top = i;
347
701
  break;
348
702
  }
349
703
  }
350
- else if (bottom !== -1 && input === -1 && composer.prompt.test(line))
351
- input = i;
352
704
  }
353
- if (top < 0 || bottom < 0 || input <= top || input >= bottom)
705
+ if (top < 0 || bottom < 0)
706
+ return { kind: 'unknown' };
707
+ // The two rules of one box are drawn at one width (53 columns in unsent.txt, 54 in
708
+ // folded-rules.txt). A window whose rules differ is not that frame — the box the caller
709
+ // compares against typed text cannot be established — and the read fails closed.
710
+ if (ruleRun(composer, lines[bottom] ?? '') !== ruleRun(composer, lines[top] ?? ''))
711
+ return { kind: 'unknown' };
712
+ // The input row is the box's first row under its opening frame, and it carries the prompt at
713
+ // the capture's own column. A row there that does not — a folded box's marker row
714
+ // (folded-rules.txt) — is not this box's input, and the read fails closed rather than take
715
+ // some later row whose content begins with a prompt glyph.
716
+ const input = top + 1;
717
+ if (input >= bottom || !composer.prompt.test(lines[input] ?? ''))
354
718
  return { kind: 'unknown' };
355
719
  let footer = false;
356
720
  for (let i = bottom + 1; i < lines.length; i++) {
@@ -361,10 +725,11 @@ function twoRules(lines, styled, composer, tick) {
361
725
  }
362
726
  if (!footer)
363
727
  return { kind: 'unknown' };
364
- if (lines.slice(input + 1, bottom).some((line) => line.trim()))
365
- return { kind: 'unsent', from: top, input };
728
+ const rows = lines.slice(input + 1, bottom);
729
+ if (rows.some((line) => line.trim()))
730
+ return { kind: 'unsent', from: top, input, rows };
366
731
  const typed = (lines[input] ?? '').replace(composer.prompt, '').trim();
367
- return { kind: placeholder(typed, composer, lines[input] ?? '', styled[input]) ? 'idle' : 'unsent', from: top, input };
732
+ return { kind: placeholder(typed, composer, lines[input] ?? '', styled[input]) ? 'idle' : 'unsent', from: top, input, rows };
368
733
  }
369
734
  function stripTyped(line, composer) {
370
735
  const stripped = composer.stripSuffix ? line.replace(composer.prompt, '').replace(composer.stripSuffix, '') : line.replace(composer.prompt, '');