@north-light/crouter 0.3.281 → 0.3.283
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.
- package/dist/api/client.d.ts +19 -3
- package/dist/api/client.js +34 -0
- package/dist/api/dto/broker-ops.d.ts +11 -0
- package/dist/api/dto/broker.d.ts +3 -8
- package/dist/api/dto/common.d.ts +1 -1
- package/dist/api/dto/messages.d.ts +2 -2
- package/dist/api/dto/node-outcomes.d.ts +80 -0
- package/dist/api/dto/node-outcomes.js +2 -0
- package/dist/api/dto/nodes.d.ts +9 -0
- package/dist/api/dto/profiles.d.ts +5 -0
- package/dist/api/dto/reports.d.ts +45 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/plugin-manifest-schema.d.ts +208 -0
- package/dist/api/plugin-manifest-schema.js +23 -0
- package/dist/api/routes.d.ts +7 -0
- package/dist/api/routes.js +7 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +24 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +31 -4
- package/dist/cli.js +8 -2
- package/dist/clients/attach/overlays/graph.d.ts +5 -0
- package/dist/clients/attach/overlays/graph.js +65 -47
- package/dist/clients/attach/overlays/pickers.js +8 -9
- package/dist/clients/attach/render/card-presentation.js +1 -1
- package/dist/clients/attach/viewer.js +1286 -1260
- package/dist/commands/api-client.js +22 -23
- package/dist/commands/node/create.js +38 -3
- package/dist/commands/node/inspect.js +3 -7
- package/dist/commands/node/message.js +3 -3
- package/dist/commands/node/outcome.d.ts +2 -0
- package/dist/commands/node/outcome.js +134 -0
- package/dist/commands/node.js +2 -1
- package/dist/commands/pkg/browse/catalog.js +1 -1
- package/dist/commands/pkg/plugin-manage.d.ts +46 -1
- package/dist/commands/pkg/plugin-manage.js +170 -20
- package/dist/commands/profile/pause.js +17 -14
- package/dist/commands/push.js +102 -1
- package/dist/commands/sys/__tests__/setup-core.test.js +4 -1
- package/dist/commands/sys/panels/broker-limits-panel.js +7 -1
- package/dist/commands/sys/panels/models-panel.js +2 -2
- package/dist/commands/sys/settings-shell.d.ts +1 -1
- package/dist/commands/sys/settings-shell.js +6 -0
- package/dist/commands/sys/settings.js +3 -3
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +2 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +42 -4
- package/dist/core/__tests__/helpers/harness.d.ts +1 -0
- package/dist/core/__tests__/helpers/harness.js +9 -0
- package/dist/core/__tests__/integration/plugin-revalidate.test.d.ts +1 -0
- package/dist/core/__tests__/integration/plugin-revalidate.test.js +355 -0
- package/dist/core/__tests__/integration/profile-pause.test.d.ts +1 -0
- package/dist/core/__tests__/integration/profile-pause.test.js +115 -0
- package/dist/core/__tests__/node-outcome.test.d.ts +1 -0
- package/dist/core/__tests__/node-outcome.test.js +184 -0
- package/dist/core/__tests__/revive-parked-fresh.test.js +13 -20
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +6 -2
- package/dist/core/__tests__/seam/dormancy-release.test.js +3 -3
- package/dist/core/__tests__/seam/node-outcome-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/seam/node-outcome-delivery.test.js +153 -0
- package/dist/core/bash-jobs.d.ts +6 -0
- package/dist/core/bash-jobs.js +22 -0
- package/dist/core/canvas/canvas.d.ts +41 -3
- package/dist/core/canvas/canvas.js +153 -39
- package/dist/core/canvas/crons.js +2 -1
- package/dist/core/canvas/index.d.ts +1 -0
- package/dist/core/canvas/index.js +1 -0
- package/dist/core/canvas/migrations.js +47 -0
- package/dist/core/canvas/node-outcome-deliveries.d.ts +80 -0
- package/dist/core/canvas/node-outcome-deliveries.js +163 -0
- package/dist/core/canvas/pid.d.ts +7 -0
- package/dist/core/canvas/pid.js +17 -0
- package/dist/core/canvas/render-source.js +2 -2
- package/dist/core/canvas/types.d.ts +42 -1
- package/dist/core/canvas/types.js +2 -1
- package/dist/core/command-manifests/registry.d.ts +3 -1
- package/dist/core/command-manifests/schema.d.ts +2 -75
- package/dist/core/command-manifests/schema.js +5 -0
- package/dist/core/command-plugins/compose.js +1 -1
- package/dist/core/command-plugins/revalidate.d.ts +18 -0
- package/dist/core/command-plugins/revalidate.js +152 -0
- package/dist/core/command-plugins/transport/http-fetch.d.ts +29 -4
- package/dist/core/command-plugins/transport/http-fetch.js +18 -8
- package/dist/core/command-plugins/transport/http-invoke.js +7 -0
- package/dist/core/command.d.ts +8 -0
- package/dist/core/command.js +19 -3
- package/dist/core/config.d.ts +4 -1
- package/dist/core/config.js +25 -1
- package/dist/core/exclusive-lock.d.ts +12 -0
- package/dist/core/exclusive-lock.js +29 -0
- package/dist/core/feed/feed.d.ts +8 -0
- package/dist/core/feed/feed.js +25 -4
- package/dist/core/feed/inbox.d.ts +5 -0
- package/dist/core/feed/inbox.js +13 -0
- package/dist/core/installed-plugins.js +31 -0
- package/dist/core/keybindings/catalog.d.ts +1 -1
- package/dist/core/keybindings/catalog.js +3 -0
- package/dist/core/manifest-recovery.d.ts +15 -0
- package/dist/core/manifest-recovery.js +72 -0
- package/dist/core/manifest-stale.d.ts +18 -0
- package/dist/core/manifest-stale.js +39 -0
- package/dist/core/plugin-swap-lock.d.ts +9 -0
- package/dist/core/plugin-swap-lock.js +31 -0
- package/dist/core/profiles/manifest.d.ts +3 -1
- package/dist/core/profiles/manifest.js +6 -1
- package/dist/core/runtime/broker/daemon-ops.d.ts +3 -1
- package/dist/core/runtime/broker/daemon-ops.js +6 -0
- package/dist/core/runtime/broker/engine-drive.d.ts +2 -3
- package/dist/core/runtime/broker/engine-drive.js +74 -27
- package/dist/core/runtime/broker/frame-dispatch.js +1 -0
- package/dist/core/runtime/broker/read-ops.js +14 -0
- package/dist/core/runtime/broker/rebind.js +0 -7
- package/dist/core/runtime/broker-protocol.d.ts +1 -6
- package/dist/core/runtime/fleet.d.ts +7 -0
- package/dist/core/runtime/headless-pi.d.ts +13 -0
- package/dist/core/runtime/headless-pi.js +10 -2
- package/dist/core/runtime/lifecycle.d.ts +6 -1
- package/dist/core/runtime/lifecycle.js +50 -1
- package/dist/core/runtime/nodes.d.ts +5 -1
- package/dist/core/runtime/nodes.js +2 -1
- package/dist/core/runtime/outcome-document.d.ts +70 -0
- package/dist/core/runtime/outcome-document.js +97 -0
- package/dist/core/runtime/revive.js +16 -21
- package/dist/core/runtime/spawn.d.ts +5 -1
- package/dist/core/runtime/spawn.js +18 -7
- package/dist/core/runtime/stamp/channel.d.ts +3 -5
- package/dist/core/runtime/stamp/channel.js +3 -10
- package/dist/core/runtime/stop-guard.js +2 -2
- package/dist/core/runtime/structured-output.d.ts +24 -3
- package/dist/core/runtime/structured-output.js +42 -3
- package/dist/core/runtime/tmux-bindings.js +4 -0
- package/dist/core/runtime/warm-pool.d.ts +5 -2
- package/dist/core/runtime/warm-pool.js +5 -5
- package/dist/core/subscription-state.js +7 -5
- package/dist/core/substrate/render.js +10 -10
- package/dist/core/user-settings.d.ts +18 -1
- package/dist/core/user-settings.js +37 -0
- package/dist/daemon/__tests__/node-deadline-lane.test.d.ts +1 -0
- package/dist/daemon/__tests__/node-deadline-lane.test.js +76 -0
- package/dist/daemon/__tests__/node-outcome-birth-invariants.test.d.ts +1 -0
- package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +122 -0
- package/dist/daemon/__tests__/node-outcome-delivery.test.d.ts +1 -0
- package/dist/daemon/__tests__/node-outcome-delivery.test.js +86 -0
- package/dist/daemon/api/__tests__/broker-settle-park.test.js +86 -18
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +10 -1
- package/dist/daemon/api/__tests__/seam/node-outcome-api.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/seam/node-outcome-api.test.js +159 -0
- package/dist/daemon/api/__tests__/submit-result.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/submit-result.test.js +214 -0
- package/dist/daemon/api/handlers/broker-ops.js +53 -24
- package/dist/daemon/api/handlers/canvas.js +2 -2
- package/dist/daemon/api/handlers/messages.js +12 -2
- package/dist/daemon/api/handlers/node-outcomes.d.ts +2 -0
- package/dist/daemon/api/handlers/node-outcomes.js +136 -0
- package/dist/daemon/api/handlers/nodes.js +67 -1
- package/dist/daemon/api/handlers/profiles.js +53 -1
- package/dist/daemon/api/handlers/reports.js +197 -33
- package/dist/daemon/api/map.d.ts +7 -2
- package/dist/daemon/api/map.js +78 -4
- package/dist/daemon/api/server.js +2 -0
- package/dist/daemon/crtrd.js +12 -0
- package/dist/daemon/fleet.d.ts +4 -8
- package/dist/daemon/fleet.js +48 -9
- package/dist/daemon/messaging/node-message.js +2 -1
- package/dist/daemon/node-outcome/deliver-outcome.d.ts +14 -0
- package/dist/daemon/node-outcome/deliver-outcome.js +155 -0
- package/dist/daemon/park-activity.d.ts +9 -0
- package/dist/daemon/park-activity.js +30 -0
- package/dist/daemon/park-pending.d.ts +10 -5
- package/dist/daemon/park-pending.js +18 -8
- package/dist/daemon/reconcilers/bash-deadline.js +6 -0
- package/dist/daemon/reconcilers/broker-supervision.d.ts +3 -7
- package/dist/daemon/reconcilers/broker-supervision.js +29 -33
- package/dist/daemon/reconcilers/node-deadline.d.ts +5 -0
- package/dist/daemon/reconcilers/node-deadline.js +82 -0
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -1
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +12 -3
- package/dist/daemon/reconcilers/node-outcome-delivery-lane.d.ts +10 -0
- package/dist/daemon/reconcilers/node-outcome-delivery-lane.js +41 -0
- package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +66 -66
- package/dist/pi-extensions/canvas-structured-output.d.ts +9 -5
- package/dist/pi-extensions/canvas-structured-output.js +23 -297
- package/dist/shared/env.d.ts +3 -0
- package/dist/shared/env.js +6 -0
- package/dist/shared/generated-context.d.ts +3 -9
- package/dist/shared/generated-context.js +8 -16
- package/dist/types.d.ts +19 -0
- package/dist/types.js +1 -0
- package/package.json +9 -8
- package/runtime.lock.json +227 -383
|
@@ -6,7 +6,9 @@ import { defineLeaf } from '../../core/command.js';
|
|
|
6
6
|
import { notFound, usage, general, network } from '../../core/errors.js';
|
|
7
7
|
import { findMarketplaceByName, findPluginByName, listAllPlugins } from '../../core/resolver.js';
|
|
8
8
|
import { NEUTRAL_PROJECT_MEMORY, pluginsDir, ensureProjectScopeRoot, userScopeRoot, resolveScopeArg, projectScopeRoot, requireScopeRoot, } from '../../core/scope.js';
|
|
9
|
-
import { updateConfig, updateState, ensureScopeInitialized, invalidPluginKindsReasons, invalidPluginBinReasons, invalidPluginRequiresReasons, normalizeRequires } from '../../core/config.js';
|
|
9
|
+
import { readConfig, readState, updateConfig, updateState, ensureScopeInitialized, invalidPluginKindsReasons, invalidPluginBinReasons, invalidPluginRequiresReasons, normalizeRequires } from '../../core/config.js';
|
|
10
|
+
import { withExclusiveDirectoryLockAsync } from '../../core/exclusive-lock.js';
|
|
11
|
+
import { bundleSwapLockPath } from '../../core/plugin-swap-lock.js';
|
|
10
12
|
import { invalidPageComponentsReasons } from '../../core/human/page-catalog.js';
|
|
11
13
|
import { pathExists, ensureDir, removePath, nowIso, linkOrCopy, isSymlink, readSymlinkTarget, atomicWriteJson, readText, realpathOrSelf, walkFiles } from '../../core/fs-utils.js';
|
|
12
14
|
import { clone, deriveNameFromUrl, currentSha, gitSync, isGitRepo } from '../../core/git.js';
|
|
@@ -317,26 +319,163 @@ function existingBundleRoot(name, scope, root) {
|
|
|
317
319
|
rejectLegacyHttpPlugin(name);
|
|
318
320
|
throw general(`plugin "${name}" already exists at ${root}; run \`crtr pkg plugin remove ${name}\` before installing the archive plugin.`);
|
|
319
321
|
}
|
|
320
|
-
|
|
322
|
+
function resolveBundleTarget(name, bundleSource, scope, scopeRootPath) {
|
|
321
323
|
const fetchTransport = validateHttpInstall(name, bundleSource.endpoint, bundleSource.authEnv);
|
|
322
324
|
const bundle = { endpoint: fetchTransport.endpoint, ...(fetchTransport.authEnv !== undefined ? { authEnv: fetchTransport.authEnv } : {}) };
|
|
323
|
-
const transport = invocationTransport(bundle);
|
|
324
|
-
const scopeRootPath = scope === 'project' ? ensureProjectScopeRoot() : userScopeRoot();
|
|
325
325
|
const root = join(scopeRootPath, 'plugins', name);
|
|
326
326
|
existingBundleRoot(name, scope, root);
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
327
|
+
return { bundle, transport: invocationTransport(bundle), scopeRootPath, root };
|
|
328
|
+
}
|
|
329
|
+
function fetchTarget(name, bundle) {
|
|
330
|
+
return { name, endpoint: bundle.endpoint, ...(bundle.authEnv !== undefined ? { authEnv: bundle.authEnv } : {}) };
|
|
331
|
+
}
|
|
332
|
+
/** A user-initiated install or update waits out another process's swap; the
|
|
333
|
+
* background pass waits a shorter time, because a wait that long there means
|
|
334
|
+
* the holder is wedged rather than merely slow. */
|
|
335
|
+
const INSTALL_SWAP_WAIT_MS = 60_000;
|
|
336
|
+
const REVALIDATE_SWAP_WAIT_MS = 10_000;
|
|
337
|
+
/**
|
|
338
|
+
* Serialize a plugin's package replacement against every other crtr process.
|
|
339
|
+
*
|
|
340
|
+
* The whole transition runs inside — re-reading the stored validator, the
|
|
341
|
+
* conditional fetch, the identity check, the directory swap, and both ledger
|
|
342
|
+
* writes. Holding it across the FETCH is the point: a process that queued
|
|
343
|
+
* behind another's swap re-reads the validator the winner just stored, so it
|
|
344
|
+
* sends the fresh ETag and is answered 304, instead of downloading the archive
|
|
345
|
+
* it waited for and swapping in a package identical to the one already there.
|
|
346
|
+
*
|
|
347
|
+
* The lock lives beside the staging and `.prev` directories the swap itself
|
|
348
|
+
* uses, and a holder that dies releases it immediately (its marker names a dead
|
|
349
|
+
* pid), so a crashed swap cannot wedge the next one.
|
|
350
|
+
*/
|
|
351
|
+
async function withBundleSwapLock(name, scope, timeoutMs, run) {
|
|
352
|
+
const scopeRootPath = scope === 'project' ? ensureProjectScopeRoot() : userScopeRoot();
|
|
353
|
+
ensureDir(join(scopeRootPath, 'tmp'));
|
|
354
|
+
return withExclusiveDirectoryLockAsync(bundleSwapLockPath(scopeRootPath, name), () => run(scopeRootPath), {
|
|
355
|
+
timeoutMs,
|
|
356
|
+
timeoutError: () => general(`timed out waiting for another crtr process to finish replacing plugin "${name}"`, {
|
|
357
|
+
next: `Re-run once that process has finished, or check for a stalled crtr holding ${bundleSwapLockPath(scopeRootPath, name)}.`,
|
|
358
|
+
}),
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
async function replaceBundlePlugin(name, bundleSource, scope, options) {
|
|
362
|
+
return withBundleSwapLock(name, scope, INSTALL_SWAP_WAIT_MS, async (scopeRootPath) => {
|
|
363
|
+
const target = resolveBundleTarget(name, bundleSource, scope, scopeRootPath);
|
|
364
|
+
const fetched = await fetchHttpPluginBundle(fetchTarget(name, target.bundle));
|
|
365
|
+
if (fetched.status !== 200) {
|
|
366
|
+
const message = `HTTP plugin bundle fetch failed for "${name}": ${fetched.message}`;
|
|
367
|
+
if (fetched.status === 'auth_env_missing')
|
|
368
|
+
throw usage(message);
|
|
369
|
+
if (fetched.status === 'cli_unreachable')
|
|
370
|
+
throw network(message);
|
|
371
|
+
throw general(message);
|
|
372
|
+
}
|
|
373
|
+
return applyBundleArchive(name, scope, target, fetched.raw, fetched.etag, options);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Re-check an installed bundle plugin against its endpoint. A `conditional`
|
|
378
|
+
* check sends the validator stored for the package currently on disk, so an
|
|
379
|
+
* unchanged package is answered 304 and left untouched; an unconditional one
|
|
380
|
+
* asks for the archive outright, which is how recovery from a `manifest_stale`
|
|
381
|
+
* answer gets past a validator that is itself out of date.
|
|
382
|
+
*
|
|
383
|
+
* Returns the swap's report, or `undefined` when the package on disk is already
|
|
384
|
+
* the generation the server serves — whether the server said so with a 304 or
|
|
385
|
+
* we proved it by hashing bytes it re-sent.
|
|
386
|
+
*
|
|
387
|
+
* Every outcome, including a failure, records that the check happened, so an
|
|
388
|
+
* unreachable endpoint costs one timed-out probe per TTL rather than one per
|
|
389
|
+
* command. Failures throw; the caller decides whether a stale-but-present
|
|
390
|
+
* package is still good enough to run on.
|
|
391
|
+
*/
|
|
392
|
+
export async function revalidateBundlePlugin(name, bundleSource, scope, options) {
|
|
393
|
+
return withBundleSwapLock(name, scope, REVALIDATE_SWAP_WAIT_MS, async (scopeRootPath) => {
|
|
394
|
+
try {
|
|
395
|
+
const target = resolveBundleTarget(name, bundleSource, scope, scopeRootPath);
|
|
396
|
+
// Read INSIDE the lock: a process that queued behind another's swap must
|
|
397
|
+
// send the validator that swap stored, not the one it read before waiting.
|
|
398
|
+
const etag = options.conditional ? readState(scope).plugins[name]?.etag : undefined;
|
|
399
|
+
const fetchOptions = {
|
|
400
|
+
...(etag !== undefined ? { ifNoneMatch: etag } : {}),
|
|
401
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
402
|
+
};
|
|
403
|
+
const fetched = await fetchHttpPluginBundle(fetchTarget(name, target.bundle), fetchOptions);
|
|
404
|
+
if (fetched.status === 304)
|
|
405
|
+
return undefined;
|
|
406
|
+
if (fetched.status !== 200) {
|
|
407
|
+
throw general(`HTTP plugin bundle revalidation failed for "${name}": ${fetched.message}`);
|
|
408
|
+
}
|
|
409
|
+
// A server with no validator, or one whose validator moved without the
|
|
410
|
+
// archive changing, re-sends bytes we already have unpacked. Swapping
|
|
411
|
+
// them in would rewrite the package root and republish an identical
|
|
412
|
+
// command tree for nothing, so hash first: identical bytes are a 304 that
|
|
413
|
+
// cost a download, and get a 304's answer.
|
|
414
|
+
const version = bundleVersion(fetched.raw);
|
|
415
|
+
if (isInstalledGeneration(name, scope, target.root, version)) {
|
|
416
|
+
recordCheckedGeneration(scope, name, fetched.etag, { swapped: false });
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
return await applyBundleArchive(name, scope, target, fetched.raw, fetched.etag, options, version);
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
// Widened to cover `resolveBundleTarget` as well as the fetch: a plugin
|
|
423
|
+
// whose endpoint no longer validates throws on every invocation, and
|
|
424
|
+
// without a recorded check it would throw on every invocation forever.
|
|
425
|
+
recordPluginCheck(scope, name);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
/** Mark that the revalidation pass reached this plugin, whatever came of it.
|
|
430
|
+
* Separate from the etag write, which only a served archive earns. */
|
|
431
|
+
function recordPluginCheck(scope, name) {
|
|
432
|
+
updateState(scope, (state) => {
|
|
433
|
+
if (state.plugins[name] === undefined)
|
|
434
|
+
state.plugins[name] = {};
|
|
435
|
+
state.plugins[name].checked_at = nowIso();
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
/** The ledger for a check that reached a served archive. `swapped` says whether
|
|
439
|
+
* that archive replaced the package on disk; the validator and the check time
|
|
440
|
+
* are recorded either way, because both describe the bytes now at the root. */
|
|
441
|
+
function recordCheckedGeneration(scope, name, etag, options) {
|
|
442
|
+
updateState(scope, (state) => {
|
|
443
|
+
if (state.plugins[name] === undefined)
|
|
444
|
+
state.plugins[name] = {};
|
|
445
|
+
if (options.swapped)
|
|
446
|
+
state.plugins[name].last_updated = nowIso();
|
|
447
|
+
state.plugins[name].checked_at = nowIso();
|
|
448
|
+
// The validator names the bytes now unpacked at `root`. A response that
|
|
449
|
+
// carried none clears it, so the next check cannot claim to hold a
|
|
450
|
+
// validator for a package it never received one for.
|
|
451
|
+
if (etag === undefined)
|
|
452
|
+
delete state.plugins[name].etag;
|
|
453
|
+
else
|
|
454
|
+
state.plugins[name].etag = etag;
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
/** The hash recorded as a bundle package's `version`: the exact archive bytes
|
|
458
|
+
* unpacked at its root. Same bytes, same version — which is what lets a
|
|
459
|
+
* re-sent archive be recognized as the generation already installed. */
|
|
460
|
+
function bundleVersion(raw) {
|
|
461
|
+
return createHash('sha256').update(raw).digest('hex').slice(0, 12);
|
|
462
|
+
}
|
|
463
|
+
/** Whether `version` is the generation already unpacked at `root`. Both halves
|
|
464
|
+
* matter: the recorded version says the config agrees, and `commands.json`
|
|
465
|
+
* says the tree that config describes is actually on disk. A swap that died
|
|
466
|
+
* between the rename and the config write leaves the second false, and that is
|
|
467
|
+
* exactly the case a no-op must not claim. */
|
|
468
|
+
function isInstalledGeneration(name, scope, root, version) {
|
|
469
|
+
if (readConfig(scope).plugins[name]?.version !== version)
|
|
470
|
+
return false;
|
|
471
|
+
return pathExists(join(root, 'commands.json'));
|
|
472
|
+
}
|
|
473
|
+
async function applyBundleArchive(name, scope, target, raw, etag, options, knownVersion) {
|
|
474
|
+
const { bundle, transport, scopeRootPath, root } = target;
|
|
475
|
+
const validated = await validatePluginBundle(raw, { reservedCoreNames: new Set(SUBTREE_NAMES), coreCommandPaths: await coreCommandPaths() });
|
|
337
476
|
if (validated.bundle === undefined)
|
|
338
477
|
throw invalidBundleError(`HTTP plugin bundle for "${name}" is invalid`, validated.issues);
|
|
339
|
-
const version =
|
|
478
|
+
const version = knownVersion ?? bundleVersion(raw);
|
|
340
479
|
const manifest = {
|
|
341
480
|
name,
|
|
342
481
|
version,
|
|
@@ -388,15 +527,19 @@ async function replaceBundlePlugin(name, bundleSource, scope, options) {
|
|
|
388
527
|
finally {
|
|
389
528
|
removePath(staging);
|
|
390
529
|
}
|
|
530
|
+
// Directory swap, then the config's version, then the state's validator — in
|
|
531
|
+
// that order, and never the reverse. A process killed mid-sequence leaves the
|
|
532
|
+
// ledger naming an OLDER generation than the disk, which the next check
|
|
533
|
+
// repairs: the server 200s against a stale-or-absent validator, the hash says
|
|
534
|
+
// the tree on disk is already that generation, and only the ledger is
|
|
535
|
+
// rewritten. The forbidden state — a validator for a generation the disk does
|
|
536
|
+
// not have, and so a permanent 304 against a package that was never unpacked
|
|
537
|
+
// — is unreachable from this order.
|
|
391
538
|
ensureScopeInitialized(scope, scopeRootPath);
|
|
392
539
|
updateConfig(scope, (cfg) => {
|
|
393
540
|
cfg.plugins[name] = { enabled: options.enable ? true : (cfg.plugins[name]?.enabled ?? true), version };
|
|
394
541
|
});
|
|
395
|
-
|
|
396
|
-
if (state.plugins[name] === undefined)
|
|
397
|
-
state.plugins[name] = {};
|
|
398
|
-
state.plugins[name].last_updated = nowIso();
|
|
399
|
-
});
|
|
542
|
+
recordCheckedGeneration(scope, name, etag, { swapped: true });
|
|
400
543
|
const commands = await commandReport(name, scope);
|
|
401
544
|
if (commands === undefined)
|
|
402
545
|
throw general(`bundle plugin "${name}" has no staged command report`);
|
|
@@ -838,6 +981,13 @@ export const pluginRemove = defineLeaf({
|
|
|
838
981
|
updateConfig(scope, (cfg) => {
|
|
839
982
|
delete cfg.plugins[name];
|
|
840
983
|
});
|
|
984
|
+
// The state record describes a package that no longer exists. Leaving it
|
|
985
|
+
// would hand a later reinstall of the same name a validator for bytes it
|
|
986
|
+
// never fetched, and keep the revalidation pass considering a plugin that
|
|
987
|
+
// is gone.
|
|
988
|
+
updateState(scope, (state) => {
|
|
989
|
+
delete state.plugins[name];
|
|
990
|
+
});
|
|
841
991
|
removedFrom.push(scope);
|
|
842
992
|
}
|
|
843
993
|
if (removedFrom.length === 0) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineLeaf } from '../../core/command.js';
|
|
2
|
-
import { resolveProfileOperand
|
|
2
|
+
import { resolveProfileOperand } from '../../core/profiles/manifest.js';
|
|
3
|
+
import { cliClient, rethrowAsCliError } from '../api-client.js';
|
|
3
4
|
const profileParam = {
|
|
4
5
|
kind: 'positional',
|
|
5
6
|
name: 'profile',
|
|
@@ -9,7 +10,7 @@ const profileParam = {
|
|
|
9
10
|
export const pauseLeaf = defineLeaf({
|
|
10
11
|
name: 'pause',
|
|
11
12
|
description: 'make a profile inert',
|
|
12
|
-
whenToUse: '
|
|
13
|
+
whenToUse: 'all work under a profile must stop now while in-flight bash commands continue under their detached supervisors.',
|
|
13
14
|
help: {
|
|
14
15
|
name: 'profile pause',
|
|
15
16
|
summary: 'pause a profile so it creates and revives no nodes',
|
|
@@ -19,15 +20,16 @@ export const pauseLeaf = defineLeaf({
|
|
|
19
20
|
{ name: 'paused_at', type: 'string', required: true, constraint: 'ISO 8601 timestamp when the profile became inert.' },
|
|
20
21
|
],
|
|
21
22
|
outputKind: 'object',
|
|
22
|
-
effects: ['Writes `paused_at
|
|
23
|
+
effects: ['Writes `paused_at`, SIGTERMs every live broker under the profile so its foreground bash hands off to its detached supervisor, and holds cron, deadline, revive, and inbox delivery until resume.'],
|
|
23
24
|
},
|
|
24
25
|
run: async (input) => {
|
|
25
26
|
const { profileId } = resolveProfileOperand(input['profile']);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
try {
|
|
28
|
+
return { ...await cliClient().pauseProfile(profileId) };
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
rethrowAsCliError(error);
|
|
32
|
+
}
|
|
31
33
|
},
|
|
32
34
|
});
|
|
33
35
|
export const resumeLeaf = defineLeaf({
|
|
@@ -43,14 +45,15 @@ export const resumeLeaf = defineLeaf({
|
|
|
43
45
|
{ name: 'paused_at', type: 'null', required: true, constraint: 'Always null after resume; null means active.' },
|
|
44
46
|
],
|
|
45
47
|
outputKind: 'object',
|
|
46
|
-
effects: ['Clears `paused_at
|
|
48
|
+
effects: ['Clears `paused_at`, credits active node deadlines for the paused interval, and lets retained inbox mail, due crons, and interrupted turns resume.'],
|
|
47
49
|
},
|
|
48
50
|
run: async (input) => {
|
|
49
51
|
const { profileId } = resolveProfileOperand(input['profile']);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
try {
|
|
53
|
+
return { ...await cliClient().resumeProfile(profileId) };
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
rethrowAsCliError(error);
|
|
57
|
+
}
|
|
55
58
|
},
|
|
56
59
|
});
|
package/dist/commands/push.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// crtr push update [body] — report progress to subscribers
|
|
4
4
|
// crtr push final [body] — finish: write result, mark node done, close window
|
|
5
|
+
// crtr push result [json] — answer a pending structured-output request
|
|
5
6
|
//
|
|
6
7
|
// "push" is THE verb a node uses to talk to its managers. `update` reports
|
|
7
8
|
// progress; its --tier sets delivery urgency. The caller's node is resolved from
|
|
@@ -39,6 +40,106 @@ const PUSH_WHEN_TO_USE = {
|
|
|
39
40
|
update: 'you have progress worth surfacing to your managers while work remains in flight. Use normal delivery unless it can wait for their next real wake (--tier deferred) or needs immediate attention (--tier urgent); use push final only when the work is actually done.',
|
|
40
41
|
final: 'the work this node was spawned to do is complete and you are ready to hand back the canonical result — this writes that result, marks the node done, and closes its window, so it is the LAST thing you do here, not a progress note. Use push update while work is still in flight, and do not reach for final on a node working directly with the user: it has no manager to report up to and would close mid-conversation (the guard blocks it unless you pass --force after the user confirms).',
|
|
41
42
|
};
|
|
43
|
+
/** `crtr push result` — submit the JSON answer to a pending structured-output
|
|
44
|
+
* request. A pure API client like its siblings: JSON is parsed client-side
|
|
45
|
+
* only to fail fast with a clean error; validation against the schema, the
|
|
46
|
+
* result write, the report push, and the schema clear all happen server-side
|
|
47
|
+
* in `POST /nodes/{id}/result`. */
|
|
48
|
+
function makeResultLeaf() {
|
|
49
|
+
return defineLeaf({
|
|
50
|
+
name: 'result',
|
|
51
|
+
description: 'answer a pending structured-output request with the required JSON result, or decline it',
|
|
52
|
+
whenToUse: 'your system prompt names a pending structured-output request you must answer. The value is validated against the required schema server-side; a mismatch rejects with field errors and records nothing, so fix and rerun. When the schema cannot be honestly satisfied (blocked, unavailable, bad input), pass `--decline "<reason>" --code <token>` (plus `--retryable` when the same request could succeed later) instead of inventing a value; your requester names the code tokens it accepts. For a terminal request either path IS how the node finishes — never call `crtr push final` alongside it.',
|
|
53
|
+
help: {
|
|
54
|
+
name: 'push result',
|
|
55
|
+
summary: 'answer a pending structured-output request',
|
|
56
|
+
params: [
|
|
57
|
+
{ kind: 'stdin', name: 'value', required: false, constraint: "The JSON result. Pipe it from a single-quoted heredoc (`<<'EOF'`) to preserve literal bytes. Required unless `--decline` is passed." },
|
|
58
|
+
{ kind: 'flag', name: 'decline', type: 'string', required: false, constraint: 'Decline instead of answering: one plain sentence on why the schema cannot be honestly satisfied (blocked, unavailable, bad input). Requires `--code`. Records a failure outcome with reason `declined` carrying the reason, code, and retryable flag, and clears the request; for a terminal request this finishes the node.' },
|
|
59
|
+
{ kind: 'flag', name: 'code', type: 'string', required: false, constraint: 'With `--decline`: one whitespace-free token (≤ 64 chars) naming the decline class. crouter carries it verbatim; the requester validates it against the tokens it documented for you.' },
|
|
60
|
+
{ kind: 'flag', name: 'retryable', type: 'bool', required: false, default: false, constraint: 'With `--decline`: the same request could succeed if issued again later (a transient block). Omit when the decline is final for this input.' },
|
|
61
|
+
],
|
|
62
|
+
output: [
|
|
63
|
+
{ name: 'mode', type: 'string', required: true, constraint: 'Which request this answered: `terminal` finished the node; `oneoff` left it working.' },
|
|
64
|
+
{ name: 'result_path', type: 'string', required: true, constraint: 'Absolute path of the recorded artifact: the result JSON, or the declined marker when `--decline` was passed.' },
|
|
65
|
+
{ name: 'declined', type: 'object', required: false, constraint: 'Present only when the request was declined: { reason, code, retryable } as recorded.' },
|
|
66
|
+
{ name: 'report_path', type: 'string', required: true, constraint: 'Absolute path of the report pushed alongside the result.' },
|
|
67
|
+
{ name: 'notified', type: 'string[]', required: true, constraint: 'Subscriber node ids that received an inbox pointer.' },
|
|
68
|
+
{ name: 'transitioned', type: 'object', required: false, constraint: 'Present only for a terminal request that flipped the node — { from, to } lifecycle-status transition applied server-side.' },
|
|
69
|
+
],
|
|
70
|
+
outputKind: 'object',
|
|
71
|
+
effects: [
|
|
72
|
+
'Validates the value against the pending schema server-side; a mismatch records nothing and the request stays pending.',
|
|
73
|
+
'Writes the accepted result to nodes/<nodeId>/context/result.json (or the decline to context/declined.json) and clears the pending request.',
|
|
74
|
+
'A terminal decline records a `failure` outcome with reason `declined` whose `declined` field carries { reason, code, retryable }; the requester sees no value.',
|
|
75
|
+
'Pushes the mode-matched report: final for a terminal request (the node finishes — same effects as `push final`), update for a one-off request (the node keeps working).',
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
run: async (input) => {
|
|
79
|
+
const nodeId = requireCallerNode();
|
|
80
|
+
const decline = typeof input['decline'] === 'string' ? input['decline'].trim() : '';
|
|
81
|
+
const code = typeof input['code'] === 'string' ? input['code'].trim() : '';
|
|
82
|
+
let request;
|
|
83
|
+
if (decline !== '') {
|
|
84
|
+
if (code === '') {
|
|
85
|
+
throw new InputError({ error: 'missing_decline_code', message: 'a decline needs a code', field: 'code', next: 'Pass `--code <token>` — the whitespace-free token your requester documented for this decline class.' });
|
|
86
|
+
}
|
|
87
|
+
request = { decline, code, retryable: input['retryable'] === true };
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
if (code !== '' || input['retryable'] === true) {
|
|
91
|
+
throw new InputError({ error: 'decline_flags_without_decline', message: '--code and --retryable only apply with --decline', next: 'Pass `--decline "<reason>"` alongside them, or drop them and submit the JSON result.' });
|
|
92
|
+
}
|
|
93
|
+
let raw = typeof input['value'] === 'string' ? input['value'].trim() : '';
|
|
94
|
+
if (raw === '')
|
|
95
|
+
raw = (await readStdinRaw()).trim();
|
|
96
|
+
if (raw === '') {
|
|
97
|
+
throw new InputError({ error: 'missing_value', message: 'no result value', field: 'value', next: 'Pass the JSON result as an argument or on stdin, or `--decline "<reason>" --code <token>` when the schema cannot be honestly satisfied.' });
|
|
98
|
+
}
|
|
99
|
+
let value;
|
|
100
|
+
try {
|
|
101
|
+
value = JSON.parse(raw);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
throw new InputError({
|
|
105
|
+
error: 'bad_result_json',
|
|
106
|
+
message: `the result is not valid JSON: ${err.message}`,
|
|
107
|
+
field: 'value',
|
|
108
|
+
next: 'Pass one valid JSON value matching the requested schema.',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
request = { value };
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const result = await cliClient().submitResult(nodeId, request);
|
|
115
|
+
if (result.mode === 'terminal')
|
|
116
|
+
markStopSignal(nodeId, 'pushedFinal');
|
|
117
|
+
return {
|
|
118
|
+
mode: result.mode,
|
|
119
|
+
result_path: result.result_path,
|
|
120
|
+
report_path: result.report_path,
|
|
121
|
+
notified: result.notified,
|
|
122
|
+
...(result.declined === undefined ? {} : { declined: result.declined }),
|
|
123
|
+
...(result.transitioned !== undefined ? { transitioned: result.transitioned } : {}),
|
|
124
|
+
...(result.worktree_auto_dropped === true
|
|
125
|
+
? { worktree_auto_dropped: true, worktree_auto_dropped_path: result.worktree_auto_dropped_path }
|
|
126
|
+
: {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
rethrowAsCliError(err);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
render: (r) => {
|
|
134
|
+
const declined = r['declined'];
|
|
135
|
+
const line = r['mode'] === 'terminal'
|
|
136
|
+
? `${declined ? 'Decline recorded' : 'Result recorded'} — node finished; its engine shuts down on next stop. Nothing more to do here: STOP your turn immediately. Reply with just "Done." and nothing else.`
|
|
137
|
+
: `${declined ? 'One-off request declined' : 'One-off result recorded'} and pushed — the structured-output gate is cleared; continue working normally.`;
|
|
138
|
+
const declinedLine = declined === undefined ? '' : `\n- code: ${declined.code} (${declined.retryable ? 'retryable' : 'not retryable'})`;
|
|
139
|
+
return `${line}\n\n- ${declined ? 'declined' : 'result'}: ${r['result_path']}${declinedLine}\n- report: ${r['report_path']}`;
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
42
143
|
function makePushLeaf(kind) {
|
|
43
144
|
return defineLeaf({
|
|
44
145
|
name: kind,
|
|
@@ -159,6 +260,6 @@ export function registerPush() {
|
|
|
159
260
|
summary: 'push a report to your subscribers',
|
|
160
261
|
model: "A push writes a markdown report to the node's reports/ history and fans a lightweight pointer to every subscriber's inbox. Delivery inlines reports under 1,000 characters with their absolute path; larger reports remain pointers to dereference. Nothing is pushed automatically — the feed contains only what a node pushes explicitly, so push whenever you want a manager to see something. Pipe markdown via a single-quoted heredoc so it reaches the report literally. `push final` is how ANY node finishes: write the canonical result, mark done, close the window.",
|
|
161
262
|
},
|
|
162
|
-
children: [makePushLeaf('update'), makePushLeaf('final')],
|
|
263
|
+
children: [makePushLeaf('update'), makePushLeaf('final'), makeResultLeaf()],
|
|
163
264
|
});
|
|
164
265
|
}
|
|
@@ -14,7 +14,10 @@ test('resolveBundledPiCliPath uses crouter bundled pi instead of a standalone pi
|
|
|
14
14
|
try {
|
|
15
15
|
const cliPath = resolveBundledPiCliPath();
|
|
16
16
|
assert.equal(existsSync(cliPath), true);
|
|
17
|
-
|
|
17
|
+
// The path INSIDE the package is pi's own layout (0.84.4 moved it to
|
|
18
|
+
// dist/bundle/cli.js) and is read from its package.json `bin.pi` — assert
|
|
19
|
+
// only that we resolved into the bundled package.
|
|
20
|
+
assert.match(cliPath, /@earendil-works[\/]pi-coding-agent[\/].*cli\.js$/);
|
|
18
21
|
}
|
|
19
22
|
finally {
|
|
20
23
|
if (prevPath === undefined)
|
|
@@ -69,8 +69,14 @@ export class BrokerLimitsPanel {
|
|
|
69
69
|
this.move(1);
|
|
70
70
|
else if (matches("crtr.setup.list-up", data))
|
|
71
71
|
this.move(-1);
|
|
72
|
+
// DEL (\x7f) is what terminals send for backspace and its code point sits
|
|
73
|
+
// above the control range, so it has to be excluded here or editing an
|
|
74
|
+
// existing value is refused as a non-digit keystroke.
|
|
72
75
|
else if (data.length > 0 &&
|
|
73
|
-
[...data].every((char) =>
|
|
76
|
+
[...data].every((char) => {
|
|
77
|
+
const code = char.codePointAt(0);
|
|
78
|
+
return code >= 32 && code !== 127;
|
|
79
|
+
}))
|
|
74
80
|
this.notice = "Broker limits accept digits only.";
|
|
75
81
|
else {
|
|
76
82
|
this.selected().handleInput(data);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { envProfileId } from '../../../shared/env.js';
|
|
2
|
-
import { ModelSelectorComponent,
|
|
2
|
+
import { ModelSelectorComponent, ThinkingSelectorComponent, } from '@earendil-works/pi-coding-agent';
|
|
3
3
|
import { Input, truncateToWidth } from '@earendil-works/pi-tui';
|
|
4
4
|
import { basename, dirname, join } from 'node:path';
|
|
5
5
|
import { ANTHROPIC_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID, isManagedProvider, readSubscriptionPool, } from '../../../core/subscription-state.js';
|
|
@@ -744,7 +744,7 @@ export class ModelsPanel {
|
|
|
744
744
|
this.notify();
|
|
745
745
|
}
|
|
746
746
|
showModelSelector(scoped, current, onPick) {
|
|
747
|
-
this.modelSelector = new ModelSelectorComponent(this.tui, current,
|
|
747
|
+
this.modelSelector = new ModelSelectorComponent(this.tui, current, this.runtime, scoped.map((model) => ({ model })), onPick, () => this.closePicker());
|
|
748
748
|
this.modelSelector.focused = true;
|
|
749
749
|
this.mode = "model-picker";
|
|
750
750
|
this.notify();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ModelRegistry, ModelRuntime } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import { TUI, type Component, type Focusable } from '@earendil-works/pi-tui';
|
|
3
3
|
import { type BindingId, type BindingResolution } from '../../core/keybindings/index.js';
|
|
4
|
-
export type DestinationId = "providers" | "models" | "keybindings" | "display" | "profiles" | "panes" | "broker-limits" | "updates";
|
|
4
|
+
export type DestinationId = "providers" | "models" | "provider-options" | "keybindings" | "display" | "profiles" | "panes" | "broker-limits" | "updates";
|
|
5
5
|
export declare class SettingsShell implements Component, Focusable {
|
|
6
6
|
private readonly tui;
|
|
7
7
|
private readonly bindings;
|
|
@@ -66,6 +66,12 @@ export class SettingsShell {
|
|
|
66
66
|
label: "Models",
|
|
67
67
|
panel: this.models,
|
|
68
68
|
},
|
|
69
|
+
{
|
|
70
|
+
id: "provider-options",
|
|
71
|
+
group: "Intelligence",
|
|
72
|
+
label: "Provider options",
|
|
73
|
+
panel: prefs("providers", "Provider options", "How a provider serves your turns, once it is authenticated."),
|
|
74
|
+
},
|
|
69
75
|
{
|
|
70
76
|
id: "keybindings",
|
|
71
77
|
group: "Interface",
|
|
@@ -9,11 +9,11 @@ import { BROKER_LIMIT_OVERRIDE_PRECEDENCE } from './setup.js';
|
|
|
9
9
|
/** Rail destinations, in rail order. Kept here rather than imported from the
|
|
10
10
|
* shell so registering the `sys` subtree still costs no pi module graph. */
|
|
11
11
|
const PAGES = [
|
|
12
|
-
'providers', 'models', 'keybindings', 'display', 'profiles', 'panes', 'broker-limits', 'updates',
|
|
12
|
+
'providers', 'models', 'provider-options', 'keybindings', 'display', 'profiles', 'panes', 'broker-limits', 'updates',
|
|
13
13
|
];
|
|
14
14
|
export const sysSettingsLeaf = defineLeaf({
|
|
15
15
|
name: 'settings',
|
|
16
|
-
description: 'open the settings page — providers, models, keybindings, display, profiles, panes, broker limits, updates',
|
|
16
|
+
description: 'open the settings page — providers, models, provider options, keybindings, display, profiles, panes, broker limits, updates',
|
|
17
17
|
whenToUse: 'the user wants to change crouter configuration interactively. Agents should use sys config get/set, which is scriptable; sys setup installs a fresh machine.',
|
|
18
18
|
help: {
|
|
19
19
|
name: 'sys settings',
|
|
@@ -31,7 +31,7 @@ export const sysSettingsLeaf = defineLeaf({
|
|
|
31
31
|
output: [],
|
|
32
32
|
outputKind: 'object',
|
|
33
33
|
effects: [
|
|
34
|
-
'Opens a pi-tui page whose left rail groups ongoing configuration: Intelligence (providers, models), Interface (keybindings, display), and Workspace (profiles, panes, broker limits, updates).',
|
|
34
|
+
'Opens a pi-tui page whose left rail groups ongoing configuration: Intelligence (providers, models, provider options), Interface (keybindings, display), and Workspace (profiles, panes, broker limits, updates).',
|
|
35
35
|
'Profiles edits are immediate: adding or removing a project directory, cycling its memory rung, setting a home, renaming, pausing and pinning a directory default all write the same profile manifests and pin file as the `crtr profile` leaves; deleting a profile runs the same cascade as `crtr profile delete --force`.',
|
|
36
36
|
'Preference, model-ladder and keybinding edits save to the user config as you make them; saved keybinding changes also refresh crouter-owned tmux bindings without overwriting occupied user keys.',
|
|
37
37
|
`Broker warning and automatic-revive hard-cap values save under user config brokerThresholds; crtrd reads them on its next supervision tick. ${BROKER_LIMIT_OVERRIDE_PRECEDENCE}`,
|
|
@@ -171,6 +171,8 @@ declare class FakeSession {
|
|
|
171
171
|
get model(): FakeModel | undefined;
|
|
172
172
|
private currentModel;
|
|
173
173
|
private currentThinkingLevel;
|
|
174
|
+
private bashAbort;
|
|
175
|
+
private bashRun;
|
|
174
176
|
get thinkingLevel(): ThinkingLevel | undefined;
|
|
175
177
|
setThinkingLevel(level: ThinkingLevel): void;
|
|
176
178
|
cycleThinkingLevel(): ThinkingLevel;
|
|
@@ -35,6 +35,7 @@ import { join, dirname } from 'node:path';
|
|
|
35
35
|
import { randomUUID } from 'node:crypto';
|
|
36
36
|
import { DefaultResourceLoader, getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
37
37
|
import { MANAGED_PROVIDER_COOLING_DIAGNOSTIC } from '../../runtime/managed-provider-cooling.js';
|
|
38
|
+
import registerCanvasBashValve, { createValveOperations } from '../../../pi-extensions/canvas-bash-valve.js';
|
|
38
39
|
/** Test seams for prompt-scoped failures before session_start. The one-shot
|
|
39
40
|
* variant records its first failure per node so the next cycling retry can
|
|
40
41
|
* reach boot confirmation. */
|
|
@@ -369,6 +370,8 @@ class FakeSession {
|
|
|
369
370
|
// this getter — store whatever it hands us.
|
|
370
371
|
currentModel;
|
|
371
372
|
currentThinkingLevel;
|
|
373
|
+
bashAbort;
|
|
374
|
+
bashRun;
|
|
372
375
|
get thinkingLevel() {
|
|
373
376
|
return this.currentThinkingLevel;
|
|
374
377
|
}
|
|
@@ -517,10 +520,12 @@ class FakeSession {
|
|
|
517
520
|
}
|
|
518
521
|
abort() {
|
|
519
522
|
const held = this.heldTurn;
|
|
520
|
-
if (held
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
523
|
+
if (held !== undefined) {
|
|
524
|
+
held.release();
|
|
525
|
+
return held.settled;
|
|
526
|
+
}
|
|
527
|
+
this.bashAbort?.abort();
|
|
528
|
+
return this.bashRun ?? Promise.resolve();
|
|
524
529
|
}
|
|
525
530
|
/** In-place tree rewind (broker `navigate_tree`). Mirrors the real SDK's
|
|
526
531
|
* contract: the session file is unchanged, the live history is truncated,
|
|
@@ -975,6 +980,39 @@ class FakeSession {
|
|
|
975
980
|
tool: cmd.tool,
|
|
976
981
|
});
|
|
977
982
|
break;
|
|
983
|
+
case 'bash': {
|
|
984
|
+
const nodeId = process.env['CRTR_NODE_ID'];
|
|
985
|
+
const contextDir = process.env['CRTR_CONTEXT_DIR'];
|
|
986
|
+
if (nodeId === undefined || contextDir === undefined)
|
|
987
|
+
throw new Error('bash control requires canvas node paths');
|
|
988
|
+
this.streaming = true;
|
|
989
|
+
await this.fire('agent_start', {}, ctx);
|
|
990
|
+
this.emit({ type: 'agent_start' });
|
|
991
|
+
// The resource loader imports extensions through jiti while this fixture
|
|
992
|
+
// imports its valve operations through Node. Register the same module
|
|
993
|
+
// instance here so its SIGTERM listener owns this real test job too.
|
|
994
|
+
registerCanvasBashValve({ on: () => { } });
|
|
995
|
+
const controller = new AbortController();
|
|
996
|
+
this.bashAbort = controller;
|
|
997
|
+
const run = createValveOperations(nodeId, contextDir).exec(cmd.command ?? 'true', process.cwd(), {
|
|
998
|
+
onData: () => { },
|
|
999
|
+
signal: controller.signal,
|
|
1000
|
+
}).then(() => { });
|
|
1001
|
+
this.bashRun = run;
|
|
1002
|
+
try {
|
|
1003
|
+
await run;
|
|
1004
|
+
}
|
|
1005
|
+
finally {
|
|
1006
|
+
this.bashAbort = undefined;
|
|
1007
|
+
this.bashRun = undefined;
|
|
1008
|
+
this.streaming = false;
|
|
1009
|
+
}
|
|
1010
|
+
await this.fire('agent_end', { messages: [] }, ctx);
|
|
1011
|
+
this.emit({ type: 'agent_end', messages: [], willRetry: false });
|
|
1012
|
+
await this.fire('agent_settled', { type: 'agent_settled' }, ctx);
|
|
1013
|
+
this.emit({ type: 'agent_settled' });
|
|
1014
|
+
break;
|
|
1015
|
+
}
|
|
978
1016
|
case 'turn':
|
|
979
1017
|
this.streaming = true;
|
|
980
1018
|
await this.fire('agent_start', {}, ctx);
|
|
@@ -514,6 +514,7 @@ export async function createHarness(opts = {}) {
|
|
|
514
514
|
...(o.mode !== undefined ? { mode: o.mode } : {}),
|
|
515
515
|
...(o.lifecycle !== undefined ? { rootLifecycle: o.lifecycle } : {}),
|
|
516
516
|
...(o.model !== undefined ? { model: o.model } : {}),
|
|
517
|
+
...(o.profile !== undefined ? { profile: o.profile } : {}),
|
|
517
518
|
});
|
|
518
519
|
await harness.awaitBoot(result.node.node_id);
|
|
519
520
|
return result.node.node_id;
|
|
@@ -572,6 +573,14 @@ export async function createHarness(opts = {}) {
|
|
|
572
573
|
`pi_pid_identity=${n?.pi_pid_identity ?? 'null'} status=${n?.status ?? 'null'} ` +
|
|
573
574
|
`intent=${n?.intent ?? 'null'} pi_session_id=${n?.pi_session_id ?? 'null'} ` +
|
|
574
575
|
`bootCount=${bootCount(nodeId)} minCount=${minCount}\n`);
|
|
576
|
+
// Diagnostic (2026-09-03 CI investigation): confirm or rule out the
|
|
577
|
+
// backoff-gated respawn path (fleet.ts #respawn's `assess` branch)
|
|
578
|
+
// stranding this row behind a `respawn_not_before` window that never
|
|
579
|
+
// gets a further h.tick() to clear it, since awaitBoot itself never
|
|
580
|
+
// ticks. Remove once the real CI failure is diagnosed.
|
|
581
|
+
process.stderr.write(`[awaitBoot] respawn state: respawn_failures=${n?.respawn_failures ?? 'null'} ` +
|
|
582
|
+
`respawn_not_before=${n?.respawn_not_before ?? 'null'} launched_at=${n?.launched_at ?? 'null'} ` +
|
|
583
|
+
`last_settled_at=${n?.last_settled_at ?? 'null'} now=${new Date().toISOString()}\n`);
|
|
575
584
|
// Identity-aware liveness: what does the current process state say about recorded identity?
|
|
576
585
|
if (n?.pi_pid !== null && n?.pi_pid !== undefined) {
|
|
577
586
|
const liveness = recordedPidLiveness(n.pi_pid, n.pi_pid_identity);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|