@north-light/crouter 0.3.301 → 0.3.303

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 (45) hide show
  1. package/dist/api/dto/reports.d.ts +8 -8
  2. package/dist/clients/attach/viewer.js +394 -394
  3. package/dist/commands/memory/__tests__/command-selector-and-mutation-guards.test.js +30 -1
  4. package/dist/commands/memory/read.js +12 -14
  5. package/dist/commands/node/lifecycle.js +7 -6
  6. package/dist/commands/push.js +4 -4
  7. package/dist/core/__tests__/bash-guard.test.d.ts +1 -0
  8. package/dist/core/__tests__/bash-guard.test.js +190 -0
  9. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  10. package/dist/core/__tests__/fixtures/fake-engine.js +51 -11
  11. package/dist/core/__tests__/integration/revive.test.js +3 -3
  12. package/dist/core/__tests__/seam/broker-provider-retry.test.js +57 -0
  13. package/dist/core/__tests__/seam/dormancy-release.test.js +42 -0
  14. package/dist/core/__tests__/stop-guard.test.js +64 -0
  15. package/dist/core/bash-guard.d.ts +6 -0
  16. package/dist/core/bash-guard.js +393 -0
  17. package/dist/core/canvas/crons.d.ts +20 -4
  18. package/dist/core/canvas/crons.js +54 -7
  19. package/dist/core/memory-resolver.d.ts +3 -1
  20. package/dist/core/memory-resolver.js +3 -3
  21. package/dist/core/nested-stores.d.ts +1 -1
  22. package/dist/core/nested-stores.js +3 -3
  23. package/dist/core/runtime/bearings-render.js +1 -1
  24. package/dist/core/runtime/broker/fault-retry.d.ts +4 -0
  25. package/dist/core/runtime/broker/fault-retry.js +66 -6
  26. package/dist/core/runtime/close.js +8 -6
  27. package/dist/core/runtime/stop-guard.js +6 -6
  28. package/dist/core/shell-segments.d.ts +42 -0
  29. package/dist/core/shell-segments.js +169 -0
  30. package/dist/core/substrate/surface-match.d.ts +0 -9
  31. package/dist/core/substrate/surface-match.js +4 -160
  32. package/dist/core/worktree.js +3 -6
  33. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +21 -11
  34. package/dist/daemon/api/handlers/reports.js +1 -1
  35. package/dist/daemon/api/map.d.ts +1 -1
  36. package/dist/daemon/api/map.js +2 -2
  37. package/dist/daemon/cron/sinks.d.ts +1 -1
  38. package/dist/daemon/cron/sinks.js +2 -2
  39. package/dist/daemon/reconcilers/live-obligation.js +2 -2
  40. package/dist/pi-extensions/canvas-bash-valve.d.ts +0 -3
  41. package/dist/pi-extensions/canvas-bash-valve.js +2 -34
  42. package/package.json +1 -1
  43. package/runtime.lock.json +5 -5
  44. package/dist/daemon/cron-sink.d.ts +0 -26
  45. package/dist/daemon/cron-sink.js +0 -43
@@ -10,6 +10,7 @@
10
10
  // container (its owning repo dir for `read`, its canonical routing anchor for
11
11
  // `memory-read`).
12
12
  import { basename, isAbsolute, matchesGlob, relative, sep } from 'node:path';
13
+ import { commandSegments, stripHeredocs } from '../shell-segments.js';
13
14
  import { CRTR_DIR_NAME } from '../../types.js';
14
15
  import { realpathOrSelf } from '../fs-utils.js';
15
16
  import { evalCondition } from '../predicate.js';
@@ -102,129 +103,6 @@ export function matchesMemoryReadEntry(entry, routingAnchor, subject, name) {
102
103
  return false;
103
104
  return entry.match.some((g) => memoryReadGlobMatches(g, routingAnchor, name));
104
105
  }
105
- /** Split a command into shell segments on unquoted `;`, newline, `&&`, `||`,
106
- * and `|`. A single `&`, subshell parentheses, and backticks are left inside
107
- * their segment — this is a best-effort guardrail, not a shell parser.
108
- * Heredoc bodies are a separate pre-match concern (see `stripHeredocs`). */
109
- function splitCommandSegments(command) {
110
- const segments = [];
111
- let current = '';
112
- let quote = null;
113
- let escaped = false;
114
- const finish = () => {
115
- segments.push(current);
116
- current = '';
117
- };
118
- for (let i = 0; i < command.length; i += 1) {
119
- const ch = command[i];
120
- if (escaped) {
121
- current += ch;
122
- escaped = false;
123
- continue;
124
- }
125
- if (ch === '\\' && quote !== "'") {
126
- current += ch;
127
- escaped = true;
128
- continue;
129
- }
130
- if (quote !== null) {
131
- current += ch;
132
- if (ch === quote)
133
- quote = null;
134
- continue;
135
- }
136
- if (ch === "'" || ch === '"') {
137
- current += ch;
138
- quote = ch;
139
- continue;
140
- }
141
- if (ch === '\n' || ch === ';') {
142
- finish();
143
- continue;
144
- }
145
- if ((ch === '&' || ch === '|') && command[i + 1] === ch) {
146
- finish();
147
- i += 1;
148
- continue;
149
- }
150
- if (ch === '|') {
151
- finish();
152
- continue;
153
- }
154
- current += ch;
155
- }
156
- finish();
157
- return segments;
158
- }
159
- /** Tokenize one segment into shell words, marking quoted spans opaque. */
160
- function tokenizeCommandSegment(segment) {
161
- const tokens = [];
162
- let text = '';
163
- let opaque = false;
164
- let started = false;
165
- let quote = null;
166
- let escaped = false;
167
- const finish = () => {
168
- if (started)
169
- tokens.push({ text, opaque });
170
- text = '';
171
- opaque = false;
172
- started = false;
173
- };
174
- for (let i = 0; i < segment.length; i += 1) {
175
- const ch = segment[i];
176
- if (escaped) {
177
- text += ch;
178
- started = true;
179
- escaped = false;
180
- continue;
181
- }
182
- if (ch === '\\' && quote !== "'") {
183
- escaped = true;
184
- started = true;
185
- continue;
186
- }
187
- if (quote !== null) {
188
- started = true;
189
- if (ch === quote) {
190
- quote = null;
191
- }
192
- else {
193
- text += ch;
194
- }
195
- continue;
196
- }
197
- if (ch === "'" || ch === '"') {
198
- quote = ch;
199
- opaque = true;
200
- started = true;
201
- continue;
202
- }
203
- if (/\s/.test(ch)) {
204
- finish();
205
- continue;
206
- }
207
- text += ch;
208
- started = true;
209
- }
210
- if (escaped)
211
- text += '\\';
212
- finish();
213
- return tokens;
214
- }
215
- /** Every non-empty command segment as its env-stripped token list: leading
216
- * `VAR=value` assignments are dropped so `tokens[0]` is the executable. */
217
- function commandSegments(command) {
218
- return splitCommandSegments(command)
219
- .map(tokenizeCommandSegment)
220
- .map((tokens) => {
221
- let first = 0;
222
- while (first < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[first].text))
223
- first += 1;
224
- return tokens.slice(first);
225
- })
226
- .filter((tokens) => tokens.length > 0);
227
- }
228
106
  function commandGlobPattern(glob) {
229
107
  const words = glob.trim().split(/\s+/);
230
108
  if (words.length === 1 && words[0] === '')
@@ -275,7 +153,9 @@ function commandGlobMatches(glob, command) {
275
153
  const pattern = commandGlobPattern(glob);
276
154
  if (pattern === null)
277
155
  return false;
278
- return commandSegments(command).some((tokens) => segmentMatches(pattern, tokens));
156
+ return commandSegments(command)
157
+ .filter(({ tokens }) => tokens.length > 0)
158
+ .some(({ tokens }) => segmentMatches(pattern, tokens));
279
159
  }
280
160
  /** Does a `command` entry fit the executed command string? */
281
161
  export function matchesCommandEntry(entry, subject, command) {
@@ -283,42 +163,6 @@ export function matchesCommandEntry(entry, subject, command) {
283
163
  return false;
284
164
  return entry.match.some((g) => commandGlobMatches(g, command));
285
165
  }
286
- const HEREDOC_OPEN = /<<(-?)\s*(["']?)([A-Za-z_][A-Za-z0-9_]*)\2/g;
287
- /** Remove heredoc BODIES (the stdin data between `<<DELIM` and its closing
288
- * `DELIM` line) from a command before it is matched. The opening line is kept
289
- * — `crtr push final <<'EOF'` is still a real `push final` invocation — but the
290
- * body lines, which are data fed on stdin and never executed as commands, are
291
- * dropped. Without this a doc-writing command whose body merely MENTIONS a
292
- * matched invocation would have its whole call held. Post-execution `command`
293
- * delivery does not need this (a spurious late injection costs a paragraph);
294
- * a `pre-command` block costs the agent a turn, so it does. */
295
- export function stripHeredocs(command) {
296
- const lines = command.split('\n');
297
- const out = [];
298
- let i = 0;
299
- while (i < lines.length) {
300
- const line = lines[i];
301
- out.push(line);
302
- i += 1;
303
- // Every heredoc opened on this line, left to right; their bodies stack and
304
- // are consumed in that order.
305
- const delims = [];
306
- HEREDOC_OPEN.lastIndex = 0;
307
- let m;
308
- while ((m = HEREDOC_OPEN.exec(line)) !== null)
309
- delims.push({ name: m[3], dash: m[1] === '-' });
310
- for (const d of delims) {
311
- while (i < lines.length) {
312
- // `<<-` strips leading TABS from the body and the closing delimiter.
313
- const probe = d.dash ? lines[i].replace(/^\t+/, '') : lines[i];
314
- i += 1;
315
- if (probe === d.name)
316
- break; // closing delimiter line — drop it, stop
317
- }
318
- }
319
- }
320
- return out.join('\n');
321
- }
322
166
  /** Does a `pre-command` entry fit a command that is ABOUT to run? The same
323
167
  * glob machinery `command` uses — one matcher, one semantics — applied to the
324
168
  * command with its heredoc bodies stripped. Subshell parentheses, backticks,
@@ -954,12 +954,9 @@ export function autoDropCleanManagedWorktreeLocked(nodeId, wt, proof) {
954
954
  * `open_managed_worktree` guard then still applies unchanged — real
955
955
  * commits, real uncommitted changes, and a lost race all require the
956
956
  * explicit path. */
957
- function cleanCurrentBranchTip(wt) {
957
+ function cleanCurrentCheckoutTip(wt) {
958
958
  if (wt.state !== 'open' || !existsSync(wt.path) || isRebaseInProgress(wt.path))
959
959
  return null;
960
- const branch = gitSync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path);
961
- if (branch.status !== 0 || branch.stdout.trim() !== wt.branch)
962
- return null;
963
960
  const status = gitSync(['status', '--porcelain'], wt.path);
964
961
  if (status.status !== 0 || status.stdout.trim() !== '')
965
962
  return null;
@@ -967,7 +964,7 @@ function cleanCurrentBranchTip(wt) {
967
964
  return head.status === 0 ? head.stdout.trim() : null;
968
965
  }
969
966
  export async function branchIsPushedAtCurrentTip(wt) {
970
- const initialTip = cleanCurrentBranchTip(wt);
967
+ const initialTip = cleanCurrentCheckoutTip(wt);
971
968
  if (initialTip === null)
972
969
  return false;
973
970
  // A cached origin branch can be stale in either direction. Query origin's
@@ -989,7 +986,7 @@ export async function branchIsPushedAtCurrentTip(wt) {
989
986
  return false;
990
987
  // The remote proof is useful only if the local checkout is still the exact
991
988
  // clean tip it inspected when finalization begins.
992
- return cleanCurrentBranchTip(wt) === initialTip;
989
+ return cleanCurrentCheckoutTip(wt) === initialTip;
993
990
  }
994
991
  export function autoDropCleanManagedWorktree(nodeId) {
995
992
  const wt = openManagedWorktreeForNode(nodeId);
@@ -29,7 +29,7 @@ import { CrtrClient } from '../../../../api/index.js';
29
29
  import { registerPush } from '../../../../commands/push.js';
30
30
  import { registerNode } from '../../../../commands/node.js';
31
31
  import { createNode, getNode } from '../../../../core/canvas/canvas.js';
32
- import { createManagedWorktree } from '../../../../core/worktree.js';
32
+ import { branchIsPushedAtCurrentTip, createManagedWorktree } from '../../../../core/worktree.js';
33
33
  import { readInboxSince } from '../../../../core/feed/inbox.js';
34
34
  import { gitSync } from '../../../../core/git.js';
35
35
  import { InputError } from '../../../../core/io.js';
@@ -187,28 +187,38 @@ test('re-homed guard: `crtr push final` on a node with a managed worktree holdin
187
187
  rmSync(repo, { recursive: true, force: true });
188
188
  }
189
189
  });
190
- test('`crtr push final` concludes a clean managed worktree whose tip exists on origin only under a different branch name (#912)', async () => {
190
+ test('`crtr push final` retains a clean managed worktree checked out on a pushed PR branch (#1109, #1061)', async () => {
191
191
  const repo = initRepo();
192
192
  const remote = mkdtempSync(join(tmpdir(), 'crtr-leafparity-remote-'));
193
193
  try {
194
194
  git(['init', '--bare'], remote);
195
195
  git(['remote', 'add', 'origin', remote], repo);
196
196
  git(['push', '-u', 'origin', 'main'], repo);
197
- const id = 'wt-912-differently-named-remote-branch';
197
+ const id = 'wt-pushed-pr-branch-final';
198
198
  const wt = createManagedWorktree(repo, id);
199
199
  writeFileSync(join(wt.path, 'delivered-via-pr.txt'), 'delivered via a PR branch\n');
200
200
  git(['add', '.'], wt.path);
201
- git(['commit', '-m', 'delivered via differently named branch'], wt.path);
201
+ git(['commit', '-m', 'delivered via PR branch'], wt.path);
202
202
  const tip = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
203
- const remoteBranch = 'fix/issue-912-worktree-final';
204
- git(['push', 'origin', `HEAD:refs/heads/${remoteBranch}`], wt.path);
205
- assert.equal(gitSync(['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${wt.branch}`], wt.path).status, 2, 'the recorded crtr branch is absent from origin');
206
- assert.equal(gitSync(['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${remoteBranch}`], wt.path).stdout.split('\t')[0], tip, 'origin carries the exact tip under the PR branch name');
203
+ const prBranch = 'fix/pushed-pr-worktree-final';
204
+ git(['switch', '-c', prBranch], wt.path);
205
+ git(['push', 'origin', `HEAD:refs/heads/${wt.branch}`], wt.path);
206
+ git(['push', 'origin', `HEAD:refs/heads/${prBranch}`], wt.path);
207
+ assert.equal(gitSync(['rev-parse', `refs/heads/${wt.branch}`], wt.path).stdout.trim(), tip, 'the recorded branch remains at the checkout tip');
208
+ assert.equal(gitSync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path).stdout.trim(), prBranch, 'the checkout uses the PR branch instead of the recorded branch');
209
+ assert.equal(gitSync(['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${wt.branch}`], wt.path).stdout.split('\t')[0], tip, 'origin carries the recorded branch tip');
210
+ assert.equal(gitSync(['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${prBranch}`], wt.path).stdout.split('\t')[0], tip, 'origin carries the PR branch tip');
211
+ const untracked = join(wt.path, 'generated.uid');
212
+ writeFileSync(untracked, 'untracked generated file\n');
213
+ assert.equal(await branchIsPushedAtCurrentTip(wt), false, 'an untracked file still rejects the origin-retention proof');
214
+ rmSync(untracked);
207
215
  createNode(worktreeNode(id, wt.path, wt));
208
- const out = await runPushAs(id, 'final', 'delivered through a differently named PR branch');
216
+ const out = await runPushAs(id, 'final', 'delivered through a PR branch');
209
217
  assert.equal(out['worktree_auto_dropped'], undefined, 'the remote-tip allowance retains the open worktree');
210
- assert.equal(getNode(id)?.managed_worktree?.state, 'open', 'the remote-tip allowance does not close the worktree');
211
- assert.equal(getNode(id)?.status, 'done', 'the node finalizes when origin carries its exact tip under the PR branch name');
218
+ assert.equal(getNode(id)?.managed_worktree?.state, 'open', 'the remote-tip allowance does not close the worktree record');
219
+ assert.equal(getNode(id)?.managed_worktree?.branch, wt.branch, 'the retained record keeps its recorded branch');
220
+ assert.equal(gitSync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path).stdout.trim(), prBranch, 'finalization does not change the checked-out PR branch');
221
+ assert.equal(getNode(id)?.status, 'done', 'the node finalizes when both pushed branches have its exact tip');
212
222
  }
213
223
  finally {
214
224
  rmSync(repo, { recursive: true, force: true });
@@ -100,7 +100,7 @@ async function guardFinalWorktree(id) {
100
100
  // command-local mapper needed (plan C-5).
101
101
  throw new InputError({
102
102
  error: 'open_managed_worktree',
103
- message: 'This node still owns an open managed worktree — `push final` is blocked until it is closed.',
103
+ message: 'This node still owns an open managed worktree that is neither clean and locally landed nor clean with its exact tip on origin — `push final` cannot finish.',
104
104
  next: (() => {
105
105
  const wt = getNode(id)?.managed_worktree;
106
106
  return wt !== null && wt !== undefined && wt.state === 'open' && !existsSync(wt.path)
@@ -1,7 +1,7 @@
1
1
  import type { ManagedWorktree, NodeMeta, NodeRow, NodeRuntime, SubscriptionRef } from '../../core/canvas/types.js';
2
2
  import type { OutcomeDeliveryRecord } from '../../core/canvas/node-outcome-deliveries.js';
3
3
  import type { ReviewComment, ReviewCommentEvent, ReviewErrorCode, ReviewRecord, ReviewResult } from '../../core/review/types.js';
4
- import type { Cron, CronRunRecord } from '../../core/canvas/crons.js';
4
+ import { type Cron, type CronRunRecord } from '../../core/canvas/crons.js';
5
5
  import type { ProfileEntry } from '../../core/profiles/manifest.js';
6
6
  import type { ReviveResult } from '../../core/runtime/revive.js';
7
7
  import type { CloseNodeResult } from '../../core/runtime/close.js';
@@ -12,6 +12,7 @@
12
12
  import { existsSync, readFileSync } from 'node:fs';
13
13
  import { outputResultPath } from '../../core/runtime/structured-output.js';
14
14
  import { ReviewOperationError as ReviewOperationErrorClass } from '../../core/review/types.js';
15
+ import { cronSinkDisplay } from '../../core/canvas/crons.js';
15
16
  import { isProfilePaused, sanitizeProfileMetadata } from '../../core/profiles/manifest.js';
16
17
  import { contextDir, inboxPath, nodeDir, nodeMetaPath, reportsDir, reviewResultPath, transcriptPath, viewSocketPath, } from '../../core/canvas/paths.js';
17
18
  import { FinalizationError } from '../../core/feed/feed.js';
@@ -21,7 +22,6 @@ import { CrtrError } from '../../core/errors.js';
21
22
  import { WorktreeError } from '../../core/worktree.js';
22
23
  import { ApiError } from '../../api/errors.js';
23
24
  import { ExitCode } from '../../types.js';
24
- import { sinkDisplay } from '../cron-sink.js';
25
25
  import { responsePath } from '../../core/human/convention.js';
26
26
  import { ticketDir } from '../../core/human/root.js';
27
27
  // Node projections
@@ -314,7 +314,7 @@ export function toCronDTO(c, lastRun) {
314
314
  run_timeout_s: c.run_timeout_s,
315
315
  overlap: c.overlap,
316
316
  on_output: c.on_output,
317
- sink: sinkDisplay(c.sink),
317
+ sink: cronSinkDisplay(c.sink),
318
318
  tier: c.tier,
319
319
  state: c.state,
320
320
  held: c.held,
@@ -1,2 +1,2 @@
1
- import type { Cron } from '../../core/canvas/crons.js';
1
+ import { type Cron } from '../../core/canvas/crons.js';
2
2
  export declare function deliverToSink(c: Cron, stdout: string): Promise<string>;
@@ -13,7 +13,7 @@ import { reviveNode } from '../../core/runtime/revive.js';
13
13
  import { isBrokerLive } from '../../core/runtime/model-swap.js';
14
14
  import { spawnChild } from '../../core/runtime/spawn.js';
15
15
  import { cronWakeOrigin } from '../../core/runtime/bearings.js';
16
- import { parseSink } from '../cron-sink.js';
16
+ import { parseCronSink } from '../../core/canvas/crons.js';
17
17
  import { resolveLiveProfile, resolveLiveProfileName } from './armed-context.js';
18
18
  async function deliverNodeSink(c, target, stdout) {
19
19
  const meta = getNode(target);
@@ -89,7 +89,7 @@ function deliverHumanSink(c, stdout) {
89
89
  publishPage(ticketDir(id), prepared);
90
90
  }
91
91
  export async function deliverToSink(c, stdout) {
92
- const sink = parseSink(c.sink);
92
+ const sink = parseCronSink(c.sink);
93
93
  if (sink === null)
94
94
  return 'no sink armed — nothing delivered';
95
95
  if (sink.kind === 'node') {
@@ -1,4 +1,4 @@
1
- import { newestReportRef, hasActiveLiveSubscription, hasLiveMessageWait, hasLiveSubscriber, hasPendingCancelOnWakeCron, subscribersOf, } from '../../core/canvas/index.js';
1
+ import { newestReportRef, hasActiveLiveSubscription, hasLiveMessageWait, hasLiveSubscriber, hasPendingCronWake, subscribersOf, } from '../../core/canvas/index.js';
2
2
  import { fullName } from '../../core/canvas/labels.js';
3
3
  import { countTickets } from '../../core/canvas/attention.js';
4
4
  import { hasUnseenWakeMail, readCursor, readInboxSince } from '../../core/feed/inbox.js';
@@ -12,7 +12,7 @@ import { transition } from '../../core/runtime/lifecycle.js';
12
12
  export function hasLiveObligation(nodeId) {
13
13
  return hasActiveLiveSubscription(nodeId)
14
14
  || hasLiveMessageWait(nodeId)
15
- || hasPendingCancelOnWakeCron(nodeId)
15
+ || hasPendingCronWake(nodeId)
16
16
  || countTickets(nodeId) > 0
17
17
  || readOutputRequest(nodeId).state !== 'absent';
18
18
  }
@@ -1,8 +1,5 @@
1
1
  import { createBashToolDefinition, type BashOperations } from '@earendil-works/pi-coding-agent';
2
2
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
3
- /** Seconds a leading `sleep ...` will block for, or null when the command does
4
- * not open with a sleep (or its duration isn't statically knowable). */
5
- export declare function leadingSleepSeconds(command: string): number | null;
6
3
  /** The valve's BashOperations backend. Each tool execution closes over its own
7
4
  * `takePurpose` and its own preview-result path, so concurrent calls cannot
8
5
  * exchange labels or result mirrors. The purpose is consumed even when the
@@ -31,6 +31,7 @@ import { homedir } from 'node:os';
31
31
  import { dirname } from 'node:path';
32
32
  import { performance } from 'node:perf_hooks';
33
33
  import { backgroundBashJob, bashJobPaths, formatBashElapsed, newBashJobId, normalizeBashJobPurpose, readBashNiceness, writeJobDeadlineMs } from '../core/bash-jobs.js';
34
+ import { bashCommandRefusal } from '../core/bash-guard.js';
34
35
  import { readConfig } from '../core/config.js';
35
36
  import { PREVIEW_RESULT_PATH_ENV, previewResultPath } from '../core/preview-result-path.js';
36
37
  import { resolveAdmittedHostCommand } from '../core/runtime/spawn-env.js';
@@ -164,39 +165,6 @@ function readExitCode(path) {
164
165
  return null;
165
166
  }
166
167
  }
167
- // Long-sleep guard. A command that opens with `sleep <long>` is a node burning
168
- // a live window to wait on a clock — the runtime already has waiting primitives
169
- // that cost nothing (`crtr cron` for a scheduled wake, `crtr node wait deadline`
170
- // for an inbox-vs-deadline race). Anything over 10s is refused before it runs.
171
- const MAX_INLINE_SLEEP_SECONDS = 10;
172
- const SLEEP_UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86400 };
173
- /** Seconds a leading `sleep ...` will block for, or null when the command does
174
- * not open with a sleep (or its duration isn't statically knowable). */
175
- export function leadingSleepSeconds(command) {
176
- const match = /^\s*sleep(\s+[^;&|\n]+)/.exec(command);
177
- if (!match)
178
- return null;
179
- // `sleep 1 2m 3` sums its operands.
180
- let total = 0;
181
- for (const operand of match[1].trim().split(/\s+/)) {
182
- const parsed = /^([0-9]*\.?[0-9]+)([smhd]?)$/.exec(operand);
183
- if (!parsed)
184
- return null; // variable/expansion/flag — not statically knowable
185
- total += Number(parsed[1]) * SLEEP_UNIT_SECONDS[parsed[2] || 's'];
186
- }
187
- return total;
188
- }
189
- function longSleepRefusal(command) {
190
- const seconds = leadingSleepSeconds(command);
191
- if (seconds === null || seconds <= MAX_INLINE_SLEEP_SECONDS)
192
- return null;
193
- return (`Refused: this command starts by sleeping ${seconds}s, over the ${MAX_INLINE_SLEEP_SECONDS}s inline limit.\n` +
194
- `Holding a live window on a clock is never how you wait here. Instead:\n` +
195
- ` - schedule the wake and end your turn: crtr cron -h (one-shot at a time, or recurring)\n` +
196
- ` - race an inbox message against a deadline: crtr node wait deadline -h\n` +
197
- ` - waiting on a child, a reply from the user, or any spine event: just stop — the runtime wakes you.\n` +
198
- `If you truly need a short pause inline, sleep ${MAX_INLINE_SLEEP_SECONDS}s or less.`);
199
- }
200
168
  /** A node can legitimately remove its own merged worktree. Never recreate that
201
169
  * path: it would look like a valid checkout but no longer be one. Run later
202
170
  * commands from a durable directory instead, and make the changed semantics
@@ -228,7 +196,7 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
228
196
  reject(new Error('aborted'));
229
197
  return;
230
198
  }
231
- const refusal = longSleepRefusal(command);
199
+ const refusal = bashCommandRefusal(command, cwd);
232
200
  if (refusal !== null) {
233
201
  reject(new Error(refusal));
234
202
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.301",
3
+ "version": "0.3.303",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.301",
3
+ "version": "0.3.303",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.301",
9
+ "version": "0.3.303",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "workspaces": [
@@ -5206,17 +5206,17 @@
5206
5206
  },
5207
5207
  "packages/crouter-api": {
5208
5208
  "name": "@north-light/crouter-api",
5209
- "version": "0.3.301",
5209
+ "version": "0.3.303",
5210
5210
  "license": "UNLICENSED"
5211
5211
  },
5212
5212
  "packages/crouter-env-docker": {
5213
5213
  "name": "@north-light/crouter-env-docker",
5214
- "version": "0.3.301",
5214
+ "version": "0.3.303",
5215
5215
  "license": "UNLICENSED"
5216
5216
  },
5217
5217
  "packages/crouter-sdk": {
5218
5218
  "name": "@north-light/crouter-sdk",
5219
- "version": "0.3.301",
5219
+ "version": "0.3.303",
5220
5220
  "license": "UNLICENSED",
5221
5221
  "dependencies": {
5222
5222
  "@north-light/crouter-api": "^0.3.295"
@@ -1,26 +0,0 @@
1
- /** The one decoder for a cron's stored sink JSON.
2
- *
3
- * A sink is armed as a CLI spec string (`node:<id>` | `spawn:<kind>` |
4
- * `human`), validated and encoded to JSON once at arm time
5
- * (`api/handlers/crons.ts`), and thereafter read back in exactly two places:
6
- * the runner, which dispatches a delivery on it, and the DTO mapper, which
7
- * renders it. Both go through here so the stored shape has a single reader.
8
- *
9
- * A malformed stored value decodes as no-sink rather than throwing: a cron
10
- * that cannot deliver must still run, and must still list.
11
- */
12
- export type ParsedSink = {
13
- kind: 'node';
14
- node: string;
15
- } | {
16
- kind: 'spawn';
17
- spawn: string;
18
- } | {
19
- kind: 'human';
20
- } | null;
21
- /** Stored JSON → typed shape. `{}`, malformed JSON, and an unrecognised shape
22
- * all read as null (no sink). */
23
- export declare function parseSink(sinkJson: string): ParsedSink;
24
- /** Stored JSON → the display spec the CLI armed with, or null when the cron
25
- * never delivers. The exact inverse of the arm-time encoding. */
26
- export declare function sinkDisplay(sinkJson: string): string | null;
@@ -1,43 +0,0 @@
1
- /** The one decoder for a cron's stored sink JSON.
2
- *
3
- * A sink is armed as a CLI spec string (`node:<id>` | `spawn:<kind>` |
4
- * `human`), validated and encoded to JSON once at arm time
5
- * (`api/handlers/crons.ts`), and thereafter read back in exactly two places:
6
- * the runner, which dispatches a delivery on it, and the DTO mapper, which
7
- * renders it. Both go through here so the stored shape has a single reader.
8
- *
9
- * A malformed stored value decodes as no-sink rather than throwing: a cron
10
- * that cannot deliver must still run, and must still list.
11
- */
12
- /** Stored JSON → typed shape. `{}`, malformed JSON, and an unrecognised shape
13
- * all read as null (no sink). */
14
- export function parseSink(sinkJson) {
15
- try {
16
- const s = JSON.parse(sinkJson);
17
- if (typeof s['node'] === 'string')
18
- return { kind: 'node', node: s['node'] };
19
- if (typeof s['spawn'] === 'string')
20
- return { kind: 'spawn', spawn: s['spawn'] };
21
- if (s['human'] === true)
22
- return { kind: 'human' };
23
- }
24
- catch {
25
- // malformed stored sink reads as no-sink; callers report it in their own terms
26
- }
27
- return null;
28
- }
29
- /** Stored JSON → the display spec the CLI armed with, or null when the cron
30
- * never delivers. The exact inverse of the arm-time encoding. */
31
- export function sinkDisplay(sinkJson) {
32
- const sink = parseSink(sinkJson);
33
- if (sink === null)
34
- return null;
35
- switch (sink.kind) {
36
- case 'node':
37
- return `node:${sink.node}`;
38
- case 'spawn':
39
- return `spawn:${sink.spawn}`;
40
- case 'human':
41
- return 'human';
42
- }
43
- }