Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -188,18 +188,46 @@ jobs:
working-directory: ${{ matrix.package }}
run: ${{ matrix.command }}

owned-session-contract-windows:
name: Caller-owned session contract (Windows named pipe)
needs: trust
if: needs.trust.outputs.allowed == 'true'
runs-on: windows-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.checkout_ref || github.ref }}
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Test named-pipe owned cleanup
working-directory: packages/coding-agent
run: npx tsx ../../node_modules/vitest/dist/cli.js --run test/daemon-supervisor-process.test.ts -t "proves exact owned cleanup over a Windows named pipe"

build-check-test:
name: build-check-test
if: always() && (needs.trust.outputs.allowed == 'true' || inputs.require_trusted)
needs: [trust, build-check, test]
needs: [trust, build-check, test, owned-session-contract-windows]
runs-on: ubuntu-latest
steps:
- name: Verify CI results
env:
TRUST_ALLOWED: ${{ needs.trust.outputs.allowed }}
BUILD_CHECK_RESULT: ${{ needs.build-check.result }}
TEST_RESULT: ${{ needs.test.result }}
WINDOWS_OWNED_SESSION_RESULT: ${{ needs.owned-session-contract-windows.result }}
run: |
test "$TRUST_ALLOWED" = true
test "$BUILD_CHECK_RESULT" = success
test "$TEST_RESULT" = success
test "$WINDOWS_OWNED_SESSION_RESULT" = success
16 changes: 16 additions & 0 deletions .pylon/features.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -351,3 +351,19 @@ decisions:
revisit_when:
- Prime records a spawn-only parent session id in the session header or exposes an equivalent accessor on the extension-visible session manager.
- The proxy-side parent-tree cancellation registry in rynfar/meridian#902 stops needing the parent edge inside the provider payload.

caller-owned-session-environment-cleanup:
area: runtime-reliability
state: shipped
owner: shared
decision: retain
pylon_refs:
- https://github.com/pylon-code/prime-agent/issues/33
- https://github.com/pylon-code/pylon/issues/199
upstream_refs:
- https://github.com/PrimeIntellect-ai/prime-agent/tree/a903d4b6768f484bd6d459b7b0aa7dee38e461e2
fork_change: caller-owned-session-environment-cleanup-v1
upstream_support: Prime upstream does not expose a frozen caller-owned exact-environment contract, secret-free current-attachment proof, or bounded structured cleanup result that survives worker and supervisor replacement without weakening owner isolation.
revisit_when:
- Prime upstream exposes an equivalent public-root token, exact caller environment reuse, generation-scoped post-attach proof, and structured bounded cleanup outcomes.
- Pylon can remove the fork token without weakening native multi-instance isolation or its pre-create ACP fallback.
9 changes: 9 additions & 0 deletions .pylon/upstream-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,12 @@ This ledger records Prime upstream evidence and the decision taken for each over
- Deliberate boundaries: fork, clone, and branch do not carry the linkage, because the result is an independent session with its own provider key rather than a live child of a running parent. Recording the parent id no longer depends on the parent being persisted, which also aligns the inline and `AgentSessionRuntime` hosts with the daemon host's unconditional `newSession` call and its correct child `rlmDepth`.
- Additive only: no daemon command, event, or response shape changes. The parent edge rides inside the `metadata.user_id` envelope extensions already produce.
- Validation: `npm run check` clean. `test/suite/regressions/34-parent-session-identity.test.ts` passes 5/5 and 4 of its 5 cases fail when the inline path stops recording the parent id. Adjacent suites pass: session-manager unit suites, SDK session manager, session flush and git state, migrations, agent traces, context tree, RLM ledger, saved-session catalog, agent-session recursion, child provider identity, subagent runtime host, subagent model selection, subagent terminal messages, side questions, fast-mode children, agent-session runtime, suite compaction, concurrent sessions, daemon agent connection, daemon session id, daemon lazy subagents, daemon session list, ACP RLM subagents, RLM subagent display, clone command, session cwd, and agents-view state — 875 passes across 42 files. `test/extensions-runner.test.ts` fails 21 of 28 identically on unmodified `origin/pylon`, an environment-level extension-loading failure unrelated to this change.

## 2026-08-31 — caller-owned session environment and cleanup contract candidate

- Prime issue [#33](https://github.com/pylon-code/prime-agent/issues/33) freezes `caller_owned_session_environment_cleanup_v1` for the Pylon [#199](https://github.com/pylon-code/pylon/issues/199) native multi-instance gate. The public-root SDK token plus the daemon hello offers for the contract and authoritative cleanup form the preflight that selects native before create. Native attach must then return a current successful proof; a missing post-attach proof triggers bounded native cleanup and fails closed rather than pretending ACP cleaned a partial native create.
- Opted-in client-owned workers use one validated caller snapshot as the exact launch environment across create, attach fallback, worker recovery, and supervisor replacement. Legacy callers keep ambient merge behavior and never advertise the contract. Durable worker state stores only a non-secret contract marker, never the snapshot or an environment identity.
- Public proof is limited to protocol/app/build and supervisor/transport generations. Observable cleanup is single-flight and bounded, with fixed completed, already-completed, replacement-settled, owner-mismatch, uncertain, transport-failure, and unsupported outcomes. Arbitrary owners still cannot attach to or complete another owner's worker.
- The additive wire change keeps protocol version 7 and advances the schema revision from 28 to 29. Mixed-version clients remain usable through the legacy path when opt-in is absent.
- Local validation covers protocol and environment validation, old/new offer and echo compatibility, two-owner isolation, worker recovery, supervisor replacement, proof invalidation/republishing, wrong-owner denial, descriptor/log redaction, and cleanup. The Windows named-pipe case is committed but must run on the hosted `windows-latest` gate before merge.
- Cross-repository order remains Prime #33 and a reproducible package artifact first, then Pylon #199 consuming the exact proof. Revisit when upstream provides an equivalent frozen contract and Pylon can remove the fork token without weakening its ACP fallback.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- Added a negotiated caller-owned session environment proof and observable bounded cleanup outcomes for daemon SDK hosts. ([#33](https://github.com/pylon-code/prime-agent/issues/33))
- Fixed Windows daemon startup so snapshot cache validation does not treat synthesized filesystem mode bits as POSIX permissions. ([#33](https://github.com/pylon-code/prime-agent/issues/33))
18 changes: 18 additions & 0 deletions packages/coding-agent/docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,24 @@ if (!connection.supportsNegotiatedCapability("correlated_prompt_lifecycle_v1"))

`supportsNegotiatedCapability()` is false before attach, while a new attach or reattach is pending, after transport or attachment invalidation, and after disposal. It becomes true only after the same physical transport returns a validated client capability echo and the exact snapshot commit succeeds. `supportsCorrelatedPromptLifecycle()` remains server-offer evidence used to construct the attach request. It is not negotiation proof. Do not substitute a hello offer, method presence, attach success, or package version for the post-attach accessor. Correlated runtime frames are withheld until the attach-side echo commits and are discarded when the echo omits the capability. Pre-proof retention is bounded by both frame count and conservative cumulative structural weight; overflow fails the adapter closed without retaining or reporting attributed payload content. A chunked replacement uses the same count and weight bounds for frames held behind its atomic snapshot fence. New same-connection attachment admission, attachment-epoch change, transport loss, disposal, or matching session close retires that old fence before any later proof can publish; delayed old snapshot frames are ignored until a fresh attachment commits.

### Caller-owned daemon session environments and cleanup

Native multi-instance hosts must gate caller-owned daemon sessions with the exact `caller_owned_session_environment_cleanup_v1` contract. Require all three proofs:

1. `PRIME_AGENT_SDK_FEATURES` from the package root includes the token.
2. The connected daemon hello offers both the same token and `authoritative_owned_session_cleanup_v1`.
3. `connection.getOwnedSessionContractProof()` returns the current post-attach proof.

If either package-root or daemon-offer preflight fails, select ACP fallback before sending the native client-owned `create`. ACP fallback never cleans up a partially created native session. If the later attach does not return the current proof, fail native admission, run bounded native cleanup, and report that failure; do not treat ACP as retroactive cleanup for that partial native create. Do not infer support from a package version, method presence, constructor shape, protocol version, schema revision, or daemon offer alone.

Pass one defensively captured `ownedSessionLaunchEnv` and a fresh `ownedSessionRecoveryConfig` to `DaemonAgentConnection`. Recovery config is required whenever the exact environment option is present; omitting it fails synchronously before attach. Use that same environment snapshot and `launchEnvMode: "replace"` on the preceding client-owned `create` request. The SDK validates and clones the option synchronously. A negotiated daemon uses the snapshot as the worker's whole caller-owned environment, adds only Prime-owned worker bootstrap variables, and reuses it with the recovery config for attach fallback and recovery. Without the exact options and negotiated token, legacy environment merging remains unchanged and the connection does not advertise this contract.

The contract proof contains only the feature/status, protocol identity, schema revision, app/build identity, supervisor generation, and SDK-local transport generation. It is absent before attach, during reattach, after transport or attachment invalidation, after promotion/disposal, and for unproved peers. It never contains the environment, home, path, secret, hash, fingerprint, PID, socket, owner token, or canary.

Use `disposeOwnedSession({ timeoutMs })` when cleanup must be observable. Concurrent calls join one operation, and `timeoutMs` is one strict total deadline for reconnection, authoritative queries, completion, side-question aborts, and unsupported-peer finalization. Its fixed statuses are `completed`, `already_completed`, `replacement_settled`, `owner_mismatch`, `uncertain`, `transport_failure`, and `unsupported`; `uncertain` also reports whether the last authoritative state was `active` or `stopping`. `replacement_settled` means an authenticated supervisor with a different generation answered the read-only cleanup query with `settled` for the connection's previously proved opaque route. Cleanup never sends completion on a replacement or pending route without a current internal attach proof. Each strict cleanup request is transport-bound and is never replayed after reconnect. Public attachment proof remains absent throughout disposal. Results never return raw errors or environment identity. `dispose()` remains the legacy best-effort `Promise<void>` API.

`DaemonClient.request()` keeps legacy reconnect replay by default. Pass `{ recoverAcrossReconnect: false }` only when one request must fail on transport close instead of crossing to a new daemon transport.

`DaemonClient.close()` is terminal owner disposal. `isClosed` becomes true, later `connect()` calls reject, and a live `DaemonAgentConnection` emits one terminal close. Normal and update recovery stop before any later restart, connect, attach, or restored-session query; already-running recovery callbacks are not cancellable but their results are discarded.

`DaemonClientOptions.maxInboundFrameBytes` is the maximum raw bytes before LF in one inbound JSONL frame. It defaults to `DEFAULT_DAEMON_CLIENT_MAX_INBOUND_FRAME_BYTES` (128 MiB) and must be a positive safe integer. LF is excluded. A CR immediately before LF is counted and then stripped.
Expand Down
14 changes: 13 additions & 1 deletion packages/coding-agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,11 @@ export {
type AgentConnectionSlashCommand,
type AgentConnectionState,
DaemonAgentConnection,
type DaemonAgentConnectionOptions,
type DaemonOwnedSessionContractProof,
type DaemonOwnedSessionDaemonIdentity,
type DaemonOwnedSessionDisposeOptions,
type DaemonOwnedSessionDisposeResult,
type ExpiredPromptLifecycle,
InProcessAgentConnection,
type PromptEventAttribution,
Expand All @@ -318,6 +323,7 @@ export {
type DaemonClientId,
type DaemonClientMessageListener,
type DaemonClientOptions,
type DaemonClientRequestOptions,
type DaemonCommand,
type DaemonCommandEnvelope,
type DaemonCommandId,
Expand All @@ -330,6 +336,8 @@ export {
type DaemonOutbound,
type DaemonOwnedSessionCleanupResult,
type DaemonOwnedSessionCleanupStatus,
type DaemonOwnedSessionCompletionResult,
type DaemonOwnedSessionCompletionStatus,
type DaemonProtocolInfo,
type DaemonProtocolName,
type DaemonProtocolVersion,
Expand Down Expand Up @@ -413,7 +421,11 @@ export {
Theme,
type ThemeColor,
} from "./modes/interactive/theme/theme.js";
export { PRIME_AGENT_SDK_FEATURES, type PrimeAgentSdkFeature } from "./sdk-features.js";
export {
CALLER_OWNED_SESSION_ENVIRONMENT_CLEANUP_FEATURE,
PRIME_AGENT_SDK_FEATURES,
type PrimeAgentSdkFeature,
} from "./sdk-features.js";
// Clipboard utilities
export { copyToClipboard } from "./utils/clipboard.js";
export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.js";
Expand Down
24 changes: 21 additions & 3 deletions packages/coding-agent/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ import { printTimings, resetTimings, time } from "./core/timings.js";
import { runMigrations, showDeprecationWarnings } from "./migrations.js";
import { isDaemonCatalogProcess, runDaemonCatalogProcess } from "./modes/daemon/daemon-catalog-process.js";
import { deserializeDaemonError } from "./modes/daemon/daemon-errors.js";
import { collectDaemonClientEnv, collectDaemonLaunchEnv } from "./modes/daemon/daemon-protocol.js";
import {
cloneCallerOwnedSessionLaunchEnv,
collectDaemonClientEnv,
collectDaemonLaunchEnv,
} from "./modes/daemon/daemon-protocol.js";
import {
DAEMON_WORKER_ACTIVE_SESSION_ID_ENV,
isDaemonWorkerProcess,
Expand Down Expand Up @@ -111,6 +115,7 @@ import { ExtensionSelectorComponent } from "./modes/interactive/components/exten
import { shouldRunOnboarding } from "./modes/interactive/onboarding.js";
import { initTheme, preloadCodeHighlighter, stopThemeWatcher } from "./modes/interactive/theme/theme.js";
import { handleConfigCommand } from "./package-manager-cli.js";
import { CALLER_OWNED_SESSION_ENVIRONMENT_CLEANUP_FEATURE } from "./sdk-features.js";
import { isLocalPath } from "./utils/paths.js";

/**
Expand Down Expand Up @@ -956,17 +961,28 @@ async function createDaemonClientConnection(options: {
noSession?: boolean;
supportsExtensionUi?: boolean;
}): Promise<{ connection: DaemonAgentConnection; summary: SessionSummary }> {
// Capture before the first await so attach and every later recovery use one caller-owned snapshot.
const callerOwnedLaunchEnv =
options.clientOwned && options.activeSessionId === undefined
? cloneCallerOwnedSessionLaunchEnv(collectDaemonLaunchEnv())
: undefined;
// Caller must have awaited ensureInteractiveDaemonRunning for this socket.
const client = new DaemonClient(options.socketPath);
await client.connect();

try {
await client.waitForHello();
const supportsCallerOwnedEnvironmentContract =
callerOwnedLaunchEnv !== undefined &&
client.supportsServerCapability(CALLER_OWNED_SESSION_ENVIRONMENT_CLEANUP_FEATURE) &&
client.supportsServerCapability("authoritative_owned_session_cleanup_v1");
const attach = async (summary: SessionSummary) => {
const connection = await DaemonAgentConnection.attach(client, getDaemonSummaryActiveSessionId(summary), {
closeClientOnDispose: true,
sendClientEnv: true,
ownedSession: options.clientOwned,
ownedSessionRecoveryConfig: options.clientOwned ? options.config : undefined,
ownedSessionLaunchEnv: supportsCallerOwnedEnvironmentContract ? callerOwnedLaunchEnv : undefined,
supportsExtensionUi: options.supportsExtensionUi,
recoverDaemon: () => ensureInteractiveDaemonRunning(options.socketPath),
telemetryDisabled: options.config.telemetryDisabled,
Expand All @@ -989,7 +1005,6 @@ async function createDaemonClientConnection(options: {
}
}
if (options.clientOwned) {
await client.waitForHello();
if (!client.supportsServerCapability("client_owned_sessions")) {
throw new DaemonCapabilityUnavailableError("create", "client_owned_sessions");
}
Expand All @@ -1003,7 +1018,10 @@ async function createDaemonClientConnection(options: {
noSession: options.noSession,
env: collectDaemonClientEnv(),
lifecycle: options.clientOwned ? "client_owned" : "resident",
launchEnv: collectDaemonLaunchEnv(),
launchEnv: supportsCallerOwnedEnvironmentContract
? (callerOwnedLaunchEnv as Record<string, string>)
: collectDaemonLaunchEnv(),
launchEnvMode: supportsCallerOwnedEnvironmentContract ? "replace" : undefined,
});
if (!response.success) {
throw deserializeDaemonError(response);
Expand Down
Loading
Loading