diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
index 2d29acac0896..e0d2dd741939 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
@@ -4120,7 +4120,7 @@ public struct SessionCatalogSession: Codable, Sendable {
public let pullrequest: SessionCatalogPullRequestSummary?
public let archived: Bool
public let sessionkey: String?
- public let createdby: [String: AnyCodable]?
+ public let createdactor: [String: AnyCodable]?
public let cancontinue: Bool
public let canarchive: Bool
public let canopenterminal: Bool?
@@ -4141,7 +4141,7 @@ public struct SessionCatalogSession: Codable, Sendable {
pullrequest: SessionCatalogPullRequestSummary? = nil,
archived: Bool,
sessionkey: String? = nil,
- createdby: [String: AnyCodable]? = nil,
+ createdactor: [String: AnyCodable]? = nil,
cancontinue: Bool,
canarchive: Bool,
canopenterminal: Bool? = nil)
@@ -4161,7 +4161,7 @@ public struct SessionCatalogSession: Codable, Sendable {
self.pullrequest = pullrequest
self.archived = archived
self.sessionkey = sessionkey
- self.createdby = createdby
+ self.createdactor = createdactor
self.cancontinue = cancontinue
self.canarchive = canarchive
self.canopenterminal = canopenterminal
@@ -4183,7 +4183,7 @@ public struct SessionCatalogSession: Codable, Sendable {
case pullrequest = "pullRequest"
case archived
case sessionkey = "sessionKey"
- case createdby = "createdBy"
+ case createdactor = "createdActor"
case cancontinue = "canContinue"
case canarchive = "canArchive"
case canopenterminal = "canOpenTerminal"
@@ -6624,13 +6624,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let execask: AnyCodable?
public let execnode: AnyCodable?
public let model: AnyCodable?
- public let spawnedby: AnyCodable?
public let completionownersessionkey: AnyCodable?
- public let spawnedworkspacedir: AnyCodable?
- public let spawnedcwd: AnyCodable?
- public let spawndepth: AnyCodable?
- public let subagentrole: AnyCodable?
- public let subagentcontrolscope: AnyCodable?
public let inheritedtoolpolicyversion: AnyCodable?
public let inheritedtoolallow: AnyCodable?
public let inheritedtooldeny: AnyCodable?
@@ -6661,13 +6655,7 @@ public struct SessionsPatchParams: Codable, Sendable {
execask: AnyCodable? = nil,
execnode: AnyCodable? = nil,
model: AnyCodable? = nil,
- spawnedby: AnyCodable? = nil,
completionownersessionkey: AnyCodable? = nil,
- spawnedworkspacedir: AnyCodable? = nil,
- spawnedcwd: AnyCodable? = nil,
- spawndepth: AnyCodable? = nil,
- subagentrole: AnyCodable? = nil,
- subagentcontrolscope: AnyCodable? = nil,
inheritedtoolpolicyversion: AnyCodable? = nil,
inheritedtoolallow: AnyCodable? = nil,
inheritedtooldeny: AnyCodable? = nil,
@@ -6697,13 +6685,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.execask = execask
self.execnode = execnode
self.model = model
- self.spawnedby = spawnedby
self.completionownersessionkey = completionownersessionkey
- self.spawnedworkspacedir = spawnedworkspacedir
- self.spawnedcwd = spawnedcwd
- self.spawndepth = spawndepth
- self.subagentrole = subagentrole
- self.subagentcontrolscope = subagentcontrolscope
self.inheritedtoolpolicyversion = inheritedtoolpolicyversion
self.inheritedtoolallow = inheritedtoolallow
self.inheritedtooldeny = inheritedtooldeny
@@ -6735,13 +6717,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case execask = "execAsk"
case execnode = "execNode"
case model
- case spawnedby = "spawnedBy"
case completionownersessionkey = "completionOwnerSessionKey"
- case spawnedworkspacedir = "spawnedWorkspaceDir"
- case spawnedcwd = "spawnedCwd"
- case spawndepth = "spawnDepth"
- case subagentrole = "subagentRole"
- case subagentcontrolscope = "subagentControlScope"
case inheritedtoolpolicyversion = "inheritedToolPolicyVersion"
case inheritedtoolallow = "inheritedToolAllow"
case inheritedtooldeny = "inheritedToolDeny"
diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt
index a094ca69909c..bcf3a1d17fdf 100644
--- a/config/max-lines-baseline.txt
+++ b/config/max-lines-baseline.txt
@@ -867,7 +867,6 @@ src/gateway/session-utils.test.ts
src/gateway/session-utils.ts
src/gateway/sessions-history-http.test.ts
src/gateway/sessions-patch.test.ts
-src/gateway/sessions-patch.ts
src/gateway/talk-realtime-relay.test.ts
src/gateway/talk-realtime-relay.ts
src/gateway/test-helpers.server.ts
diff --git a/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 b/docs/.generated/sqlite-session-transcript-schema-baseline.sha256
index d5611b65d7f3..b1b51969807b 100644
--- a/docs/.generated/sqlite-session-transcript-schema-baseline.sha256
+++ b/docs/.generated/sqlite-session-transcript-schema-baseline.sha256
@@ -1 +1 @@
-0852c1b681df33646f60d239afcac6de53fbc498bd5daa58077376331205ccdf sqlite-session-transcript-schema-baseline.sql
+011b9ec0e0fa64b5a4036648fcd61a50674b813a92280e8e3fe8746f6acdb62d sqlite-session-transcript-schema-baseline.sql
diff --git a/docs/concepts/multi-user.md b/docs/concepts/multi-user.md
index 8fa3b0260b42..d1a7e726aaae 100644
--- a/docs/concepts/multi-user.md
+++ b/docs/concepts/multi-user.md
@@ -17,7 +17,9 @@ If people must not access each other's sessions, tools, credentials, or files, g
## Ownership and presence
-New sessions record their creator when the Gateway has a trusted identity available. Trusted-proxy identity takes priority; otherwise OpenClaw uses the paired device's operator label or display name. Older sessions and sessions created without either identity have no owner stamp.
+New sessions record a write-once `createdActor` when the creation path can prove who caused it. Authenticated people use their durable Gateway profile id; requesting agents and system paths use the same actor field. Sessions created without a proven actor remain unattributed.
+
+Human display names are resolved from the current Gateway profile when session rows are returned. OpenClaw does not store labels on session entries, so changing a profile name updates the ownership UI without rewriting session history.
The web app keeps ownership and presence visually distinct:
diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md
index e826e915f0c4..8af01a144a3e 100644
--- a/docs/gateway/protocol.md
+++ b/docs/gateway/protocol.md
@@ -572,13 +572,13 @@ methods. Treat this as feature discovery, not a full enumeration of
- `sessions.preview` returns bounded transcript previews for specific session keys.
- `sessions.describe` returns one gateway session row for an exact session key.
- `sessions.resolve` resolves or canonicalizes a session target.
- - `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created.
+ - `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created. New rows are stamped with write-once creation provenance (`createdVia`, `createdActor`, `createdAt`) from the trusted creation seam; adopting an existing key never restamps it. For human profile actors, `createdActor.label` is resolved from the current user profile when the row is projected and is never stored on the session entry, so profile renames do not drift. Session rows also carry `parentSessionKey` (navigation parent, persisted), `controlOwnerSessionKey` (runtime controller when live), `forkSource` (exact source key + transcript generation for forks), and `previousSessionId` (prior transcript generation under the same key).
- `sessions.dispatch` (`operator.admin`) moves an existing local OpenClaw session with a session-owned managed worktree to a configured cloud-worker profile. Pass `{ key, profileId, agentId? }`. The method is absent when no worker profile is configured, closes local turn admission before draining active work, and returns only after placement reaches `active` worker ownership. Dispatch is one-way; worker-to-local pull-back is not part of this RPC.
- `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` manage the gateway-owned custom session group catalog (names + display order). Membership stays on each session's `category` field; rename and delete update member sessions server-side.
- `sessions.send` sends a message into an existing session.
- `sessions.steer` is the interrupt-and-steer variant for an active session.
- `sessions.abort` aborts active work for a session. Pass `key` plus optional `runId`, or `runId` alone for active runs the gateway can resolve to a session.
- - `sessions.patch` updates session metadata/overrides and reports the resolved canonical model plus effective `agentRuntime`.
+ - `sessions.patch` updates session metadata/overrides and reports the resolved canonical model plus effective `agentRuntime`. Spawn lineage (`spawnedBy`, `spawnedWorkspaceDir`, `spawnedCwd`, `spawnDepth`, `subagentRole`, `subagentControlScope`) is no longer publicly patchable; those facts are written once by trusted creation paths, and requests that still send them are rejected.
- `sessions.reset`, `sessions.delete`, and `sessions.compact` perform session maintenance.
- `sessions.get` returns the full stored session row.
- Chat execution still uses `chat.history`, `chat.send`, `chat.abort`, and `chat.inject`. `chat.history` is display-normalized for UI clients: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (`...`, `...`, `...`, `...`, and truncated tool-call blocks) and leaked ASCII/full-width model control tokens are stripped, pure silent-token assistant rows (exact `NO_REPLY` / `no_reply`) are omitted, and oversized rows can be replaced with placeholders.
diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts
index 5107575304d0..5ccbf5678a48 100644
--- a/packages/gateway-protocol/src/index.ts
+++ b/packages/gateway-protocol/src/index.ts
@@ -26,7 +26,11 @@ export type {
WizardNotFoundErrorDetails,
} from "./schema/error-codes.js";
export * from "./schema/board.js";
-export { SessionCreatorIdentitySchema, type SessionCreatorIdentity } from "./schema/sessions.js";
+export {
+ SessionCreatedActorSchema,
+ type SessionCreatedActor,
+ type SessionRow,
+} from "./schema/sessions-row.js";
export * from "./migration-api.js";
export type * from "./public-session-catalog.js";
import {
@@ -413,6 +417,7 @@ import {
SecretsResolveParamsSchema,
SecretsResolveResultSchema,
SessionBranchSchema,
+ SessionRowSchema,
SessionsAbortParamsSchema,
SessionsCompactParamsSchema,
SessionsCleanupParamsSchema,
@@ -1156,6 +1161,7 @@ export {
SessionsCompactionBranchParamsSchema,
SessionsCompactionRestoreParamsSchema,
SessionBranchSchema,
+ SessionRowSchema,
SessionsBranchesListParamsSchema,
SessionsBranchesListResultSchema,
SessionsBranchesSwitchParamsSchema,
diff --git a/packages/gateway-protocol/src/schema/sessions-catalog.test.ts b/packages/gateway-protocol/src/schema/sessions-catalog.test.ts
index 6b6d67ab6c5f..64716d8b0970 100644
--- a/packages/gateway-protocol/src/schema/sessions-catalog.test.ts
+++ b/packages/gateway-protocol/src/schema/sessions-catalog.test.ts
@@ -31,7 +31,7 @@ describe("SessionsCatalogListResultSchema", () => {
threadId: "thread-1",
status: "idle",
archived: false,
- createdBy: { id: "profile-ada", label: "Ada" },
+ createdActor: { type: "human", id: "profile-ada", label: "Ada" },
canContinue: true,
canArchive: false,
canOpenTerminal: true,
diff --git a/packages/gateway-protocol/src/schema/sessions-catalog.ts b/packages/gateway-protocol/src/schema/sessions-catalog.ts
index 850e6a81066e..d1fce8b6ad17 100644
--- a/packages/gateway-protocol/src/schema/sessions-catalog.ts
+++ b/packages/gateway-protocol/src/schema/sessions-catalog.ts
@@ -3,7 +3,7 @@ import { Type } from "typebox";
import { closedObject } from "./closed-object.js";
import { PluginJsonValueSchema } from "./plugins.js";
import { NonEmptyString } from "./primitives.js";
-import { SessionCreatorIdentitySchema } from "./sessions.js";
+import { SessionCreatedActorSchema } from "./sessions-row.js";
const SessionCatalogErrorSchema = closedObject({ code: NonEmptyString, message: NonEmptyString });
@@ -56,7 +56,7 @@ export const SessionCatalogSessionSchema = closedObject({
pullRequest: Type.Optional(SessionCatalogPullRequestSummarySchema),
archived: Type.Boolean(),
sessionKey: Type.Optional(NonEmptyString),
- createdBy: Type.Optional(SessionCreatorIdentitySchema),
+ createdActor: Type.Optional(SessionCreatedActorSchema),
canContinue: Type.Boolean(),
canArchive: Type.Boolean(),
canOpenTerminal: Type.Optional(Type.Boolean()),
diff --git a/packages/gateway-protocol/src/schema/sessions-row.ts b/packages/gateway-protocol/src/schema/sessions-row.ts
new file mode 100644
index 000000000000..70643fc5243f
--- /dev/null
+++ b/packages/gateway-protocol/src/schema/sessions-row.ts
@@ -0,0 +1,109 @@
+import type { Static } from "typebox";
+import { Type } from "typebox";
+import { closedObject } from "./closed-object.js";
+import { NonEmptyString } from "./primitives.js";
+
+/** Projected actor that caused a session node to be created. */
+export const SessionCreatedActorSchema = closedObject({
+ type: Type.Union([Type.Literal("human"), Type.Literal("agent"), Type.Literal("system")]),
+ id: Type.Optional(NonEmptyString),
+ label: Type.Optional(NonEmptyString),
+});
+
+/** Stable Gateway session row fields; mutation envelopes may add null tombstones. */
+export const SessionRowSchema = Type.Object(
+ {
+ key: Type.String(),
+ sessionId: Type.Optional(Type.String()),
+ kind: Type.Union([
+ Type.Literal("direct"),
+ Type.Literal("group"),
+ Type.Literal("global"),
+ Type.Literal("unknown"),
+ ]),
+ label: Type.Optional(Type.String()),
+ displayName: Type.Optional(Type.String()),
+ derivedTitle: Type.Optional(Type.String()),
+ lastMessagePreview: Type.Optional(Type.String()),
+ channel: Type.Optional(Type.String()),
+ chatType: Type.Optional(
+ Type.Union([Type.Literal("direct"), Type.Literal("group"), Type.Literal("channel")]),
+ ),
+ updatedAt: Type.Optional(Type.Union([Type.Number(), Type.Null()])),
+ archived: Type.Optional(Type.Boolean()),
+ archivedAt: Type.Optional(Type.Number()),
+ pinned: Type.Optional(Type.Boolean()),
+ pinnedAt: Type.Optional(Type.Number()),
+ icon: Type.Optional(Type.String()),
+ unread: Type.Optional(Type.Boolean()),
+ lastReadAt: Type.Optional(Type.Number()),
+ lastActivityAt: Type.Optional(Type.Number()),
+ lastInteractionAt: Type.Optional(Type.Number()),
+ status: Type.Optional(
+ Type.Union([
+ Type.Literal("running"),
+ Type.Literal("done"),
+ Type.Literal("failed"),
+ Type.Literal("killed"),
+ Type.Literal("timeout"),
+ ]),
+ ),
+ lastRunError: Type.Optional(Type.String()),
+ spawnedBy: Type.Optional(Type.String()),
+ parentSessionKey: Type.Optional(Type.String()),
+ controlOwnerSessionKey: Type.Optional(Type.String()),
+ childSessions: Type.Optional(Type.Array(Type.String())),
+ forkedFromParent: Type.Optional(Type.Boolean()),
+ spawnDepth: Type.Optional(Type.Number()),
+ subagentRole: Type.Optional(Type.Union([Type.Literal("orchestrator"), Type.Literal("leaf")])),
+ subagentControlScope: Type.Optional(
+ Type.Union([Type.Literal("children"), Type.Literal("none")]),
+ ),
+ swarmGroupId: Type.Optional(Type.String()),
+ worktree: Type.Optional(
+ Type.Object({
+ id: Type.String(),
+ branch: Type.String(),
+ repoRoot: Type.String(),
+ }),
+ ),
+ execNode: Type.Optional(Type.String()),
+ execCwd: Type.Optional(Type.String()),
+ spawnedWorkspaceDir: Type.Optional(Type.String()),
+ spawnedCwd: Type.Optional(Type.String()),
+ createdVia: Type.Optional(
+ Type.Union([
+ Type.Literal("operator"),
+ Type.Literal("spawn"),
+ Type.Literal("channel"),
+ Type.Literal("cron"),
+ Type.Literal("talk"),
+ Type.Literal("run"),
+ Type.Literal("plugin"),
+ Type.Literal("internal"),
+ ]),
+ ),
+ createdActor: Type.Optional(SessionCreatedActorSchema),
+ createdAt: Type.Optional(Type.Number()),
+ forkSource: Type.Optional(
+ Type.Object({
+ sessionKey: Type.String(),
+ sessionId: Type.String(),
+ entryId: Type.Optional(Type.String()),
+ }),
+ ),
+ previousSessionId: Type.Optional(Type.String()),
+ inputTokens: Type.Optional(Type.Number()),
+ outputTokens: Type.Optional(Type.Number()),
+ totalTokens: Type.Optional(Type.Number()),
+ totalTokensFresh: Type.Optional(Type.Boolean()),
+ contextTokens: Type.Optional(Type.Number()),
+ estimatedCostUsd: Type.Optional(Type.Number()),
+ model: Type.Optional(Type.String()),
+ modelProvider: Type.Optional(Type.String()),
+ },
+ { additionalProperties: true },
+);
+
+export type SessionCreatedActor = Static;
+export type SessionRow = Static;
diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts
index 846322fb549a..5c666fc5ff6d 100644
--- a/packages/gateway-protocol/src/schema/sessions.ts
+++ b/packages/gateway-protocol/src/schema/sessions.ts
@@ -9,6 +9,12 @@ import { NonEmptyString, SessionLabelString } from "./primitives.js";
import { SessionsCreateParamsSchema } from "./sessions-create.js";
export { SessionsCreateParamsSchema };
+export {
+ SessionCreatedActorSchema,
+ SessionRowSchema,
+ type SessionCreatedActor,
+ type SessionRow,
+} from "./sessions-row.js";
export const SESSION_OBSERVER_HEALTH_VALUES = [
"on-track",
@@ -20,13 +26,6 @@ export const SESSION_OBSERVER_HEALTH_VALUES = [
"failed",
] as const;
-/** Stable identity stamped on a session when an operator creates it. */
-export const SessionCreatorIdentitySchema = closedObject({
- id: NonEmptyString,
- label: Type.Optional(NonEmptyString),
-});
-export type SessionCreatorIdentity = Static;
-
/** Trajectory judgment produced for one observed agent session. */
export const SessionObserverHealthSchema = Type.Union([
Type.Literal("on-track"),
@@ -478,17 +477,7 @@ export const SessionsPatchParamsSchema = closedObject({
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
- spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
completionOwnerSessionKey: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
- spawnedWorkspaceDir: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
- spawnedCwd: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
- spawnDepth: Type.Optional(Type.Union([Type.Integer({ minimum: 0 }), Type.Null()])),
- subagentRole: Type.Optional(
- Type.Union([Type.Literal("orchestrator"), Type.Literal("leaf"), Type.Null()]),
- ),
- subagentControlScope: Type.Optional(
- Type.Union([Type.Literal("children"), Type.Literal("none"), Type.Null()]),
- ),
inheritedToolPolicyVersion: Type.Optional(Type.Union([Type.Literal(1), Type.Null()])),
inheritedToolAllow: Type.Optional(Type.Union([Type.Array(NonEmptyString), Type.Null()])),
inheritedToolDeny: Type.Optional(Type.Union([Type.Array(NonEmptyString), Type.Null()])),
diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts
index b176e3a41ddc..01d61dfce197 100644
--- a/src/agents/acp-spawn.test.ts
+++ b/src/agents/acp-spawn.test.ts
@@ -70,6 +70,7 @@ const hoisted = vi.hoisted(() => {
const countActiveRunsForSessionMock = vi.fn();
const getSubagentRunByChildSessionKeyMock = vi.fn();
const listTasksForOwnerKeyMock = vi.fn();
+ const upsertSessionEntryMock = vi.fn();
const createSessionAccessorMock = () => {
const resolveMockStorePath = (scope: {
agentId?: string;
@@ -111,6 +112,8 @@ const hoisted = vi.hoisted(() => {
listSessionEntriesReadOnly: listMockEntries,
loadSessionEntry: loadMockEntry,
loadSessionEntryReadOnly: loadMockEntry,
+ upsertSessionEntry: async (scope: unknown, patch: SessionEntry) =>
+ await upsertSessionEntryMock(scope, patch),
resolveSessionTranscriptRuntimeTarget: async (scope: {
agentId: string;
sessionId: string;
@@ -161,6 +164,7 @@ const hoisted = vi.hoisted(() => {
countActiveRunsForSessionMock,
getSubagentRunByChildSessionKeyMock,
listTasksForOwnerKeyMock,
+ upsertSessionEntryMock,
createSessionAccessorMock,
state,
};
@@ -455,8 +459,8 @@ function expectGatewayMethodNotCalled(method: string): void {
expect(gatewayRequests().some((request) => request.method === method)).toBe(false);
}
-function expectSessionPatchFields(expected: Record): void {
- expectRecordFields(gatewayRequest("sessions.patch").params, expected);
+function expectCreatedSessionFields(expected: Record): void {
+ expectRecordFields(firstMockCall(hoisted.upsertSessionEntryMock, "session create")[1], expected);
}
function expectInitializeSessionFields(expected: Record): Record {
@@ -703,6 +707,13 @@ describe("spawnAcpDirect", () => {
hoisted.countActiveRunsForSessionMock.mockReset().mockReturnValue(0);
hoisted.getSubagentRunByChildSessionKeyMock.mockReset().mockReturnValue(null);
hoisted.listTasksForOwnerKeyMock.mockReset().mockReturnValue([]);
+ hoisted.upsertSessionEntryMock
+ .mockReset()
+ .mockImplementation(async (_scope: unknown, patch: Partial) => ({
+ ...patch,
+ sessionId: patch.sessionId ?? "sess-123",
+ updatedAt: patch.updatedAt ?? Date.now(),
+ }));
hoisted.callGatewayMock.mockReset();
hoisted.callGatewayMock.mockImplementation(async (argsUnknown: unknown) => {
@@ -861,25 +872,25 @@ describe("spawnAcpDirect", () => {
expect(accepted.runId).toBe("run-1");
expect(accepted.mode).toBe("session");
expect(accepted.inlineDelivery).toBe(true);
- expectSessionPatchFields({
- key: accepted.childSessionKey,
+ expectCreatedSessionFields({
spawnedBy: "agent:main:main",
completionOwnerSessionKey: "agent:main:main",
inheritedToolPolicyVersion: 1,
+ parentSessionKey: "agent:main:main",
+ createdVia: "spawn",
+ createdActor: { type: "agent", id: "agent:main:main" },
+ createdAt: expect.any(Number),
});
expectBindingCallFields({
targetKind: "session",
placement: "child",
});
- const patchCallIndex = hoisted.callGatewayMock.mock.calls.findIndex(
- (call: unknown[]) => (call[0] as { method?: string }).method === "sessions.patch",
- );
const agentCallIndex = hoisted.callGatewayMock.mock.calls.findIndex(
(call: unknown[]) => (call[0] as { method?: string }).method === "agent",
);
- const patchCallOrder = expectDefined(
- hoisted.callGatewayMock.mock.invocationCallOrder[patchCallIndex],
- "hoisted.callGatewayMock.mock.invocationCallOrder[patchCallIndex] test invariant",
+ const createCallOrder = expectDefined(
+ hoisted.upsertSessionEntryMock.mock.invocationCallOrder[0],
+ "hoisted.upsertSessionEntryMock.mock.invocationCallOrder[0] test invariant",
);
const initializeCallOrder = expectDefined(
hoisted.initializeSessionMock.mock.invocationCallOrder[0],
@@ -889,10 +900,10 @@ describe("spawnAcpDirect", () => {
hoisted.callGatewayMock.mock.invocationCallOrder[agentCallIndex],
"hoisted.callGatewayMock.mock.invocationCallOrder[agentCallIndex] test invariant",
);
- expect(typeof patchCallOrder).toBe("number");
+ expect(typeof createCallOrder).toBe("number");
expect(typeof initializeCallOrder).toBe("number");
expect(typeof agentCallOrder).toBe("number");
- expect(patchCallOrder < initializeCallOrder).toBe(true);
+ expect(createCallOrder < initializeCallOrder).toBe(true);
expect(initializeCallOrder < agentCallOrder).toBe(true);
expectResolvedIntroTextInBindMetadata();
@@ -1419,9 +1430,8 @@ describe("spawnAcpDirect", () => {
agentSessionKey: "agent:main:subagent:parent",
});
- const accepted = expectAcceptedSpawn(result);
- expectSessionPatchFields({
- key: accepted.childSessionKey,
+ expectAcceptedSpawn(result);
+ expectCreatedSessionFields({
spawnedBy: "agent:main:subagent:parent",
spawnDepth: 2,
subagentRole: "leaf",
diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts
index 88ae45b0c51a..1f57f2543cad 100644
--- a/src/agents/acp-spawn.ts
+++ b/src/agents/acp-spawn.ts
@@ -38,7 +38,9 @@ import {
loadSessionEntry,
loadSessionEntryReadOnly,
resolveSessionTranscriptRuntimeTarget,
+ upsertSessionEntry,
} from "../config/sessions/session-accessor.js";
+import { buildSessionCreationStamp } from "../config/sessions/session-entry-provenance.js";
import type { SessionAcpMeta, SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { callGateway } from "../gateway/call.js";
@@ -59,7 +61,7 @@ import {
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "../routing/session-key.js";
-import { recordSubagentSpawned } from "../sessions/session-state-events.js";
+import { recordSessionCreated, recordSubagentSpawned } from "../sessions/session-state-events.js";
import { listTasksForOwnerKey } from "../tasks/runtime-internal.js";
import { deliveryContextFromSession, normalizeDeliveryContext } from "../utils/delivery-context.js";
import {
@@ -1240,6 +1242,7 @@ export async function spawnAcpDirect(
}
let sessionCreated = false;
+ let childCreationEntry: SessionEntry | undefined;
let initializedRuntime: AcpSpawnRuntimeCloseHandle | undefined;
const childIdem = crypto.randomUUID();
const parentAgentId = parentSessionKey
@@ -1285,20 +1288,37 @@ export async function spawnAcpDirect(
};
const adapter: SpawnBackendAdapter = {
async initialize() {
- await callGateway({
- method: "sessions.patch",
- params: {
- key: sessionKey,
- spawnedBy: requesterInternalKey,
- completionOwnerSessionKey: ownership.completionRequesterSessionKey,
- ...admission.childSessionPatch,
- inheritedToolPolicyVersion: 1,
- ...inheritedToolAllowPatch(ctx.inheritedToolAllowlist),
- ...inheritedToolDenyPatch(ctx.inheritedToolDenylist),
- ...(params.label ? { label: params.label } : {}),
- },
- timeoutMs: 10_000,
+ const creationStamp = buildSessionCreationStamp({
+ via: "spawn",
+ actor: { type: "agent", id: requesterInternalKey },
});
+ const storePath = resolveStorePath(cfg.session?.store, { agentId: targetAgentId });
+ const childSessionPatch = admission.childSessionPatch
+ ? {
+ spawnDepth: admission.childSessionPatch.spawnDepth,
+ ...(admission.childSessionPatch.subagentRole
+ ? { subagentRole: admission.childSessionPatch.subagentRole }
+ : {}),
+ subagentControlScope: admission.childSessionPatch.subagentControlScope,
+ }
+ : {};
+ childCreationEntry =
+ (await upsertSessionEntry(
+ { storePath, sessionKey },
+ {
+ ...creationStamp,
+ spawnedBy: requesterInternalKey,
+ completionOwnerSessionKey: ownership.completionRequesterSessionKey,
+ // Navigation parent is stamped at creation so the durable tree edge
+ // does not depend on the control-lineage field.
+ parentSessionKey: requesterInternalKey,
+ ...childSessionPatch,
+ inheritedToolPolicyVersion: 1,
+ ...inheritedToolAllowPatch(ctx.inheritedToolAllowlist),
+ ...inheritedToolDenyPatch(ctx.inheritedToolDenylist),
+ ...(params.label ? { label: params.label } : {}),
+ },
+ )) ?? undefined;
sessionCreated = true;
const initializedSession = await initializeAcpSpawnRuntime({
cfg,
@@ -1335,6 +1355,13 @@ export async function spawnAcpDirect(
binding: state.binding,
});
// ACP bypasses the native adapter, so seed the same child lineage before dispatch.
+ if (childCreationEntry) {
+ recordSessionCreated({
+ sessionKey,
+ agentId: targetAgentId,
+ entry: childCreationEntry,
+ });
+ }
recordSubagentSpawned({
childSessionKey: sessionKey,
childRunId: childIdem,
diff --git a/src/agents/internal-session-effects.test.ts b/src/agents/internal-session-effects.test.ts
index 55ec79c060fb..89346bbfd2fc 100644
--- a/src/agents/internal-session-effects.test.ts
+++ b/src/agents/internal-session-effects.test.ts
@@ -26,7 +26,12 @@ describe("internal session effects", () => {
expect(target.sessionKey).toMatch(/^agent:main:internal-session-effects:run_with_space-/);
expect(target.sessionId).toMatch(/^internal-session-effects-run_with_space-/);
- expect(loadExactSessionEntry(target)?.entry.sessionId).toBe(target.sessionId);
+ expect(loadExactSessionEntry(target)?.entry).toMatchObject({
+ sessionId: target.sessionId,
+ createdVia: "internal",
+ createdActor: { type: "system" },
+ createdAt: expect.any(Number),
+ });
expect(listSessionEntries({ storePath })).toEqual([]);
await expect(loadTranscriptEvents(target)).resolves.toEqual([
expect.objectContaining({ id: target.sessionId, type: "session" }),
diff --git a/src/agents/internal-session-effects.ts b/src/agents/internal-session-effects.ts
index d9bb0552735d..ecca1bcac46d 100644
--- a/src/agents/internal-session-effects.ts
+++ b/src/agents/internal-session-effects.ts
@@ -7,6 +7,7 @@ import {
replaceTranscriptEvents,
upsertSessionEntry,
} from "../config/sessions/session-accessor.js";
+import { buildSessionCreationStamp } from "../config/sessions/session-entry-provenance.js";
import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
import { createSessionTranscriptHeader } from "../config/sessions/transcript-header.js";
import type { SessionEntry } from "../config/sessions/types.js";
@@ -93,6 +94,7 @@ export async function prepareInternalSessionEffectsSession(params: {
}
const now = Date.now();
const entry = await upsertSessionEntry(scope, {
+ ...buildSessionCreationStamp({ via: "internal", actor: { type: "system" } }),
sessionId: scope.sessionId,
sessionStartedAt: now,
updatedAt: now,
diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts
index c8fd26db8510..3a0ef652d751 100644
--- a/src/agents/openclaw-tools.ts
+++ b/src/agents/openclaw-tools.ts
@@ -645,12 +645,15 @@ export function createOpenClawTools(
config: resolvedConfig,
senderIsOwner: options?.senderIsOwner,
}),
+ // No explicit callGateway: the tool defaults to the same in-process
+ // caller, and an injected override would disable the trusted creation
+ // stamp for materialized agent roots (opts.callGateway === undefined
+ // is the gate in ensureConfiguredAgentMainSession).
createSessionsSendTool({
agentSessionKey: options?.agentSessionKey,
agentChannel: options?.agentChannel,
sandboxed: options?.sandboxed,
config: resolvedConfig,
- callGateway,
}),
]),
...(includeSubagentSpawnTool
diff --git a/src/agents/sessions-spawn-hooks.test.ts b/src/agents/sessions-spawn-hooks.test.ts
index c48f0709e266..666c1bff64fd 100644
--- a/src/agents/sessions-spawn-hooks.test.ts
+++ b/src/agents/sessions-spawn-hooks.test.ts
@@ -591,57 +591,4 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
"delete params",
);
});
-
- it("cleans up the provisional session when lineage patching fails after thread binding", async () => {
- const store: Record> = {};
- hoisted.updateSessionStoreMock.mockImplementation(
- async (_storePath: unknown, mutator: unknown) => {
- if (typeof mutator !== "function") {
- throw new Error("missing session store mutator");
- }
- await mutator(store);
- if (Object.values(store).some((entry) => typeof entry.spawnedBy === "string")) {
- throw new Error("lineage patch failed");
- }
- return store;
- },
- );
- hoisted.callGatewayMock.mockImplementation(async (opts: unknown) => {
- const request = opts as { method?: string; params?: Record };
- if (request.method === "sessions.delete") {
- return { ok: true };
- }
- if (request.method === "agent") {
- return { runId: "run-1", status: "accepted", acceptedAt: 1_001 };
- }
- return {};
- });
-
- const result = await spawn({
- thread: true,
- mode: "session",
- agentAccountId: "work",
- agentTo: "channel:123",
- agentThreadId: "456",
- context: "isolated",
- });
-
- expect(result.status).toBe("error");
- expect(result.error).toContain("lineage patch failed");
- expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled();
- expect(hookRunnerMocks.runSubagentEnded).not.toHaveBeenCalled();
- const methods = getGatewayMethods();
- expect(methods).toContain("sessions.delete");
- expect(methods).not.toContain("agent");
- const deleteCall = findGatewayRequest("sessions.delete");
- expectFields(
- deleteCall?.params,
- {
- key: result.childSessionKey,
- deleteTranscript: true,
- emitLifecycleHooks: true,
- },
- "delete params",
- );
- });
});
diff --git a/src/agents/subagent-spawn.attachments.test.ts b/src/agents/subagent-spawn.attachments.test.ts
index 683972a06d4b..56af28a2acf7 100644
--- a/src/agents/subagent-spawn.attachments.test.ts
+++ b/src/agents/subagent-spawn.attachments.test.ts
@@ -218,57 +218,4 @@ describe("spawnSubagentDirect filename validation", () => {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
-
- it("removes materialized attachments when lineage patching fails", async () => {
- // Attachments are created before the child session lineage patch; failures
- // must delete both the child session and materialized files.
- const calls: Array<{ method?: string; params?: Record }> = [];
- const store: Record> = {};
- updateSessionStoreMock.mockImplementation(async (_storePath: unknown, mutator: unknown) => {
- if (typeof mutator !== "function") {
- throw new Error("missing session store mutator");
- }
- await mutator(store);
- if (Object.values(store).some((entry) => typeof entry.spawnedBy === "string")) {
- throw new Error("lineage patch failed");
- }
- return store;
- });
- callGatewayMock.mockImplementation(async (opts: unknown) => {
- const request = opts as { method?: string; params?: Record };
- calls.push(request);
- if (request.method === "sessions.delete") {
- return { ok: true };
- }
- return {};
- });
-
- const { spawnSubagentDirect } = subagentSpawnModule;
- const result = await spawnSubagentDirect(
- {
- task: "test",
- attachments: [{ name: "file.txt", content: validContent, encoding: "base64" }],
- },
- ctx,
- );
-
- expect(result.status).toBe("error");
- expect(result.error).toContain("lineage patch failed");
- const attachmentsRoot = path.join(workspaceDirOverride, ".openclaw", "attachments");
- const retainedDirs = fs.existsSync(attachmentsRoot)
- ? fs.readdirSync(attachmentsRoot).filter((entry) => !entry.startsWith("."))
- : [];
- expect(retainedDirs).toHaveLength(0);
- const deleteCall = calls.find((entry) => entry.method === "sessions.delete");
- const deleteParams = deleteCall?.params as
- | {
- key?: string;
- deleteTranscript?: boolean;
- emitLifecycleHooks?: boolean;
- }
- | undefined;
- expect(deleteParams?.key).toMatch(/^agent:main:subagent:/);
- expect(deleteParams?.deleteTranscript).toBe(true);
- expect(deleteParams?.emitLifecycleHooks).toBe(false);
- });
});
diff --git a/src/agents/subagent-spawn.model-session.test.ts b/src/agents/subagent-spawn.model-session.test.ts
index b4c9155b9cdd..f23f33c4da45 100644
--- a/src/agents/subagent-spawn.model-session.test.ts
+++ b/src/agents/subagent-spawn.model-session.test.ts
@@ -84,7 +84,7 @@ describe("spawnSubagentDirect runtime model persistence", () => {
expect(result.modelApplied).toBe(true);
expect(result.resolvedModel).toBe("openai/gpt-5.4");
expect(result.resolvedProvider).toBe("openai");
- expect(updateSessionStoreMock).toHaveBeenCalledTimes(3);
+ expect(updateSessionStoreMock).toHaveBeenCalledTimes(2);
expectPersistedRuntimeModel({
persistedStore,
sessionKey: /^agent:main:subagent:/,
diff --git a/src/agents/subagent-spawn.test.ts b/src/agents/subagent-spawn.test.ts
index 9e7cf3bfb3bf..25d55752fcf9 100644
--- a/src/agents/subagent-spawn.test.ts
+++ b/src/agents/subagent-spawn.test.ts
@@ -884,7 +884,15 @@ describe("spawnSubagentDirect seam flow", () => {
expect(result.childSessionKey).toMatch(/^agent:main:subagent:/);
const childSessionKey = result.childSessionKey as string;
- expect(hoisted.updateSessionStoreMock).toHaveBeenCalledTimes(3);
+ expect(hoisted.updateSessionStoreMock).toHaveBeenCalledTimes(2);
+ expect(persistedStore?.[childSessionKey]).toMatchObject({
+ spawnedBy: "agent:main:main",
+ completionOwnerSessionKey: "agent:main:main",
+ parentSessionKey: "agent:main:main",
+ createdVia: "spawn",
+ createdActor: { type: "agent", id: "agent:main:main" },
+ createdAt: expect.any(Number),
+ });
const registerInput = firstRegisteredSubagentRun();
const requesterOrigin = requireRecord(registerInput.requesterOrigin);
expect(registerInput.runId).toBe("run-1");
diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts
index 8beefcba1d01..a8bc6a2398d1 100644
--- a/src/agents/subagent-spawn.ts
+++ b/src/agents/subagent-spawn.ts
@@ -18,6 +18,7 @@ import {
resolveThreadBindingMaxAgeMsForChannel,
resolveThreadBindingSpawnPolicy,
} from "../channels/thread-bindings-policy.js";
+import { buildSessionCreationStamp } from "../config/sessions/session-entry-provenance.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { SubagentSpawnPreparation } from "../context-engine/types.js";
@@ -26,7 +27,7 @@ import { stringifyRouteThreadId } from "../plugin-sdk/channel-route.js";
import { listRegisteredPluginAgentPromptGuidance } from "../plugins/command-registry-state.js";
import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js";
import { isValidAgentId, normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
-import { recordSubagentSpawned } from "../sessions/session-state-events.js";
+import { recordSessionCreated, recordSubagentSpawned } from "../sessions/session-state-events.js";
import type { FastMode } from "../shared/fast-mode.js";
import { resolveUserPath } from "../utils.js";
import type { DeliveryContext } from "../utils/delivery-context.types.js";
@@ -243,15 +244,15 @@ async function callSubagentGateway(
authorization?: SubagentLaunchAuthorization,
): Promise>> {
// Subagent lifecycle requires methods spanning multiple scope tiers
- // (sessions.patch / sessions.delete → admin, agent → write). When each call
+ // (sessions.delete → admin, agent → write). When each call
// independently negotiates least-privilege scopes the first connection pairs
// at a lower tier and every subsequent higher-tier call triggers a
// scope-upgrade handshake that headless gateway-client connections cannot
// complete interactively, causing close(1008) "pairing required" (#59428).
//
// Only admin-requiring calls are pinned to ADMIN_SCOPE; other methods (e.g.
- // "agent" -> write) keep their least-privilege scope. The params-aware
- // resolver keeps spawn-metadata sessions.patch calls on the admin tier.
+ // "agent" -> write) keep their least-privilege scope. Apply the trusted
+ // launch authorization before resolving the request's required scope.
const authorizedParams =
params.params != null && typeof params.params === "object" && !Array.isArray(params.params)
? applySubagentLaunchAuthorization(params.params as Record, authorization)
@@ -390,6 +391,9 @@ function buildDirectChildSessionPatch(patch: Record): Partial,
+ creationStamp?: ReturnType,
): Promise => {
try {
const target = resolveGatewaySessionStoreTarget({
cfg,
key: childSessionKey,
});
- await upsertSessionEntry(
+ const updatedEntry = await upsertSessionEntry(
{
storePath: target.storePath,
sessionKey: target.canonicalKey,
},
- buildDirectChildSessionPatch(patch),
+ { ...buildDirectChildSessionPatch(patch), ...creationStamp },
);
+ childCreationEntry ??= updatedEntry ?? undefined;
return undefined;
} catch (err) {
const message =
@@ -1364,6 +1371,13 @@ export async function spawnSubagentDirect(
};
const initialChildSessionPatch: Record = {
+ spawnedBy: spawnedByKey,
+ completionOwnerSessionKey: ownership.completionRequesterSessionKey,
+ // Navigation and control lineage commit with the creation stamp so a
+ // launch failure cannot leave a durable but parentless child row.
+ parentSessionKey: spawnedByKey,
+ ...(spawnedWorkspaceDir ? { spawnedWorkspaceDir } : {}),
+ ...(spawnedCwd ? { spawnedCwd } : {}),
...admission.childSessionPatch,
inheritedToolPolicyVersion: 1,
...inheritedToolAllowPatch(ctx.inheritedToolAllowlist),
@@ -1374,7 +1388,13 @@ export async function spawnSubagentDirect(
...(params.outputSchema ? { swarmOutputSchema: params.outputSchema } : {}),
};
- const initialPatchError = await patchChildSession(initialChildSessionPatch);
+ const initialPatchError = await patchChildSession(
+ initialChildSessionPatch,
+ buildSessionCreationStamp({
+ via: "spawn",
+ actor: { type: "agent", id: requesterInternalKey },
+ }),
+ );
if (initialPatchError) {
return {
status: "error",
@@ -1530,32 +1550,18 @@ export async function spawnSubagentDirect(
persistentSession: spawnMode === "session",
task,
});
-
const spawnedMetadata = normalizeSpawnedRunMetadata({
spawnedBy: spawnedByKey,
...toolSpawnMetadata,
workspaceDir: spawnedWorkspaceDir,
});
- const spawnLineagePatchError = await patchChildSession({
- spawnedBy: spawnedByKey,
- completionOwnerSessionKey: ownership.completionRequesterSessionKey,
- ...(spawnedMetadata.workspaceDir
- ? { spawnedWorkspaceDir: spawnedMetadata.workspaceDir }
- : {}),
- ...(spawnedCwd ? { spawnedCwd } : {}),
- });
- if (spawnLineagePatchError) {
- await cleanupFailedSpawnBeforeAgentStart({
- childSessionKey,
- attachmentAbsDir,
- emitLifecycleHooks: threadBindingReady,
- deleteTranscript: true,
+
+ if (childCreationEntry) {
+ recordSessionCreated({
+ sessionKey: childSessionKey,
+ agentId: targetAgentId,
+ entry: childCreationEntry,
});
- return {
- status: "error",
- error: spawnLineagePatchError,
- childSessionKey,
- };
}
recordSubagentSpawned({
childSessionKey,
diff --git a/src/agents/tools/in-process-gateway.test.ts b/src/agents/tools/in-process-gateway.test.ts
new file mode 100644
index 000000000000..322bcc492eac
--- /dev/null
+++ b/src/agents/tools/in-process-gateway.test.ts
@@ -0,0 +1,58 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ hasContext: true,
+ dispatch: vi.fn(),
+ callGatewayTool: vi.fn(),
+}));
+
+vi.mock("../../gateway/method-scopes.js", () => ({
+ resolveLeastPrivilegeOperatorScopesForMethod: () => ["operator.write"],
+}));
+
+vi.mock("../../gateway/server-plugins.js", () => ({
+ dispatchGatewayMethodInProcess: mocks.dispatch,
+ getInProcessGatewayRequestContext: vi.fn(),
+ hasInProcessGatewayContext: () => mocks.hasContext,
+}));
+
+vi.mock("./gateway.js", () => ({ callGatewayTool: mocks.callGatewayTool }));
+
+import { callInProcessGatewayToolWithCreation } from "./in-process-gateway.js";
+
+describe("trusted in-process Gateway session creation", () => {
+ beforeEach(() => {
+ mocks.hasContext = true;
+ mocks.dispatch.mockReset().mockResolvedValue({ key: "agent:main:dashboard:child" });
+ mocks.callGatewayTool.mockReset().mockResolvedValue({ key: "agent:main:dashboard:child" });
+ });
+
+ it("surfaces creation provenance only on in-process dispatch", async () => {
+ const creation = {
+ via: "spawn" as const,
+ actor: { type: "agent" as const, id: "agent:main:main" },
+ };
+ await callInProcessGatewayToolWithCreation("sessions.create", { agentId: "main" }, creation);
+
+ expect(mocks.dispatch).toHaveBeenCalledWith(
+ "sessions.create",
+ { agentId: "main" },
+ {
+ forceSyntheticClient: true,
+ sessionCreation: creation,
+ syntheticScopes: ["operator.write"],
+ },
+ );
+ expect(mocks.callGatewayTool).not.toHaveBeenCalled();
+
+ mocks.hasContext = false;
+ await callInProcessGatewayToolWithCreation("sessions.create", { agentId: "main" }, creation);
+
+ expect(mocks.callGatewayTool).toHaveBeenCalledWith(
+ "sessions.create",
+ {},
+ { agentId: "main" },
+ { scopes: ["operator.write"] },
+ );
+ });
+});
diff --git a/src/agents/tools/in-process-gateway.ts b/src/agents/tools/in-process-gateway.ts
index c1fd0a4a2bef..4f9206ae3e31 100644
--- a/src/agents/tools/in-process-gateway.ts
+++ b/src/agents/tools/in-process-gateway.ts
@@ -1,5 +1,6 @@
/** In-process Gateway calls for built-in agent tools. */
import { resolveLeastPrivilegeOperatorScopesForMethod } from "../../gateway/method-scopes.js";
+import type { TrustedSessionCreation } from "../../gateway/server-methods/session-creation-provenance.js";
import type { GatewayRequestContext } from "../../gateway/server-methods/types.js";
import {
dispatchGatewayMethodInProcess,
@@ -34,3 +35,20 @@ export const callInProcessGatewayTool: InProcessGatewayCaller = async (
}
return await callGatewayTool(method, {}, params, { scopes });
};
+
+export async function callInProcessGatewayToolWithCreation>(
+ method: string,
+ params: Record,
+ creation: TrustedSessionCreation,
+): Promise {
+ const scopes = resolveLeastPrivilegeOperatorScopesForMethod(method, params);
+ if (hasInProcessGatewayContext()) {
+ return await dispatchGatewayMethodInProcess(method, params, {
+ forceSyntheticClient: true,
+ sessionCreation: creation,
+ syntheticScopes: scopes,
+ });
+ }
+ // The fallback is a real Gateway request; trusted creation metadata never crosses the wire.
+ return await callGatewayTool(method, {}, params, { scopes });
+}
diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts
index 88aff6f1964c..d799fe2f9720 100644
--- a/src/agents/tools/sessions-send-tool.ts
+++ b/src/agents/tools/sessions-send-tool.ts
@@ -50,6 +50,10 @@ import {
} from "../tool-description-presets.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readNonNegativeIntegerParam, readStringParam } from "./common.js";
+import {
+ callInProcessGatewayToolWithCreation,
+ hasInProcessGatewayToolContext,
+} from "./in-process-gateway.js";
import { runWithScopedSessionAccess } from "./scoped-session-access.js";
import {
createSessionVisibilityGuard,
@@ -189,6 +193,8 @@ async function ensureConfiguredAgentMainSession(params: {
callGateway: GatewayCaller;
sessionKey: string;
mainKey: string;
+ requesterSessionKey?: string;
+ useTrustedInProcessCreation: boolean;
}): Promise<{ ok: true } | { ok: false; error: string }> {
if (
!isConfiguredAgentMainSessionKey({
@@ -209,14 +215,26 @@ async function ensureConfiguredAgentMainSession(params: {
return { ok: true };
} catch {
try {
- await params.callGateway({
- method: "sessions.create",
- params: {
- key: params.sessionKey,
- agentId: resolveAgentIdFromSessionKey(params.sessionKey),
- },
- timeoutMs: 10_000,
- });
+ const createParams = {
+ key: params.sessionKey,
+ agentId: resolveAgentIdFromSessionKey(params.sessionKey),
+ };
+ if (
+ params.useTrustedInProcessCreation &&
+ params.requesterSessionKey &&
+ hasInProcessGatewayToolContext()
+ ) {
+ await callInProcessGatewayToolWithCreation("sessions.create", createParams, {
+ via: "internal",
+ actor: { type: "agent", id: params.requesterSessionKey },
+ });
+ } else {
+ await params.callGateway({
+ method: "sessions.create",
+ params: createParams,
+ timeoutMs: 10_000,
+ });
+ }
return { ok: true };
} catch (err) {
return { ok: false, error: formatErrorMessage(err) };
@@ -621,6 +639,8 @@ export function createSessionsSendTool(opts?: {
callGateway: gatewayCall,
sessionKey: resolvedKey,
mainKey,
+ requesterSessionKey,
+ useTrustedInProcessCreation: opts?.callGateway === undefined,
});
if (!ensuredSession.ok) {
return jsonResult({
diff --git a/src/agents/tools/sessions-spawn-visible.ts b/src/agents/tools/sessions-spawn-visible.ts
index a53d839a841d..af10076e7b84 100644
--- a/src/agents/tools/sessions-spawn-visible.ts
+++ b/src/agents/tools/sessions-spawn-visible.ts
@@ -24,7 +24,11 @@ import { resolveSubagentSpawnOwnership } from "../subagent-spawn-ownership.js";
import { resolveConfiguredSubagentRunTimeoutSeconds } from "../subagent-spawn-plan.js";
import { resolveSubagentTargetPolicy } from "../subagent-target-policy.js";
import { normalizeToolModelOverride, readStringParam, ToolInputError } from "./common.js";
-import { callInProcessGatewayTool, type InProcessGatewayCaller } from "./in-process-gateway.js";
+import {
+ callInProcessGatewayTool,
+ callInProcessGatewayToolWithCreation,
+ type InProcessGatewayCaller,
+} from "./in-process-gateway.js";
import { reserveVisibleChildSlot } from "./sessions-spawn-visible-admission.js";
export const VISIBLE_SESSIONS_SPAWN_SCHEMA = {
@@ -274,7 +278,14 @@ export async function maybeSpawnVisibleSession(params: {
}
try {
const gatewayCall = params.options?.callGateway ?? callInProcessGatewayTool;
- const response = await gatewayCall<{
+ const createGatewayCall: InProcessGatewayCaller =
+ params.options?.callGateway ??
+ ((method, requestParams) =>
+ callInProcessGatewayToolWithCreation(method, requestParams, {
+ via: "spawn",
+ actor: { type: "agent", id: requesterKey },
+ }));
+ const response = await createGatewayCall<{
key?: string;
runStarted?: boolean;
runId?: string;
diff --git a/src/agents/tools/sessions.test.ts b/src/agents/tools/sessions.test.ts
index 7cf4f09562b9..77b442f12e7b 100644
--- a/src/agents/tools/sessions.test.ts
+++ b/src/agents/tools/sessions.test.ts
@@ -10,6 +10,12 @@ import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { extractAssistantText, sanitizeTextContent } from "./chat-history-text.js";
const callGatewayMock = vi.fn();
+const inProcessCreationMock = vi.fn(
+ async (..._args: [unknown, unknown, unknown]): Promise => ({}),
+);
+// Default false mirrors running outside a gateway process; the trusted-creation
+// regression test flips it on and restores it.
+let inProcessGatewayContextAvailable = false;
const facadeRuntimeMock = vi.hoisted(() => ({
sessionKeyResolvers: new Map<
string,
@@ -25,6 +31,11 @@ const facadeRuntimeMock = vi.hoisted(() => ({
vi.mock("../../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
+vi.mock("./in-process-gateway.js", () => ({
+ callInProcessGatewayToolWithCreation: (method: unknown, params: unknown, creation: unknown) =>
+ inProcessCreationMock(method, params, creation),
+ hasInProcessGatewayToolContext: () => inProcessGatewayContextAvailable,
+}));
vi.mock("../../plugin-sdk/facade-runtime.js", async () => {
const actual = await vi.importActual(
"../../plugin-sdk/facade-runtime.js",
@@ -1243,4 +1254,63 @@ describe("sessions_send gating", () => {
expect(waitTimeouts).toEqual([MAX_TIMER_TIMEOUT_MS]);
});
});
+
+describe("sessions_send agent-main materialization provenance", () => {
+ it("uses the trusted in-process creation stamp in the production assembly (no injected caller)", async () => {
+ inProcessGatewayContextAvailable = true;
+ inProcessCreationMock.mockClear();
+ loadConfigMock.mockReturnValue({
+ session: { scope: "per-sender", mainKey: "main" },
+ tools: {
+ agentToAgent: { enabled: false },
+ sessions: { visibility: "all" },
+ },
+ });
+ callGatewayMock.mockImplementation(async (opts: unknown) => {
+ const request = opts as { method?: string };
+ if (request.method === "sessions.resolve") {
+ // Unmaterialized agent main: the probe fails, forcing creation.
+ throw new Error("unknown session: agent:main:main");
+ }
+ if (request.method === "sessions.create") {
+ throw new Error("plain sessions.create must not be used for trusted materialization");
+ }
+ if (request.method === "chat.history") {
+ return { messages: [] };
+ }
+ if (request.method === "agent") {
+ return { runId: "run-ensure-main", acceptedAt: 1 };
+ }
+ return {};
+ });
+ // Mirror production assembly (openclaw-tools.ts): no callGateway override, so
+ // ensureConfiguredAgentMainSession takes the trusted in-process branch.
+ const tool = createSessionsSendTool({
+ agentSessionKey: "agent:main:dashboard:req-provenance",
+ agentChannel: MAIN_AGENT_CHANNEL,
+ });
+
+ try {
+ const result = await tool.execute("call-ensure-main-provenance", {
+ sessionKey: "agent:main:main",
+ message: "wake up",
+ timeoutSeconds: 0,
+ });
+
+ expect(requireDetails(result).status).toBe("accepted");
+ expect(inProcessCreationMock).toHaveBeenCalledTimes(1);
+ expect(inProcessCreationMock).toHaveBeenCalledWith(
+ "sessions.create",
+ { key: "agent:main:main", agentId: "main" },
+ {
+ via: "internal",
+ actor: { type: "agent", id: "agent:main:dashboard:req-provenance" },
+ },
+ );
+ } finally {
+ inProcessGatewayContextAvailable = false;
+ inProcessCreationMock.mockClear();
+ }
+ });
+});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
diff --git a/src/auto-reply/reply/get-reply-fast-path.ts b/src/auto-reply/reply/get-reply-fast-path.ts
index b11e572bd915..ccf60647bf9c 100644
--- a/src/auto-reply/reply/get-reply-fast-path.ts
+++ b/src/auto-reply/reply/get-reply-fast-path.ts
@@ -9,6 +9,7 @@ import { normalizeAnyChannelId } from "../../channels/registry.js";
import { applyMergePatch } from "../../config/merge-patch.js";
import { resolveStorePath } from "../../config/sessions/paths.js";
import { loadSessionEntry, listSessionEntries } from "../../config/sessions/session-accessor.js";
+import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js";
import { resolveSessionKey } from "../../config/sessions/session-key.js";
import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js";
import type { SessionEntry, SessionScope } from "../../config/sessions/types.js";
@@ -214,8 +215,25 @@ export function initFastReplySessionState(params: {
const sessionEntry: SessionEntry = {
...(!resetTriggered ? existingEntry : undefined),
sessionId,
- ...((resetTriggered || !existingEntry) && ctx.SessionCreator
- ? { createdBy: { ...ctx.SessionCreator } }
+ ...(!existingEntry && ctx.SessionCreation
+ ? buildSessionCreationStamp(ctx.SessionCreation)
+ : {}),
+ ...(resetTriggered && existingEntry
+ ? {
+ previousSessionId: existingEntry.sessionId,
+ spawnedBy: existingEntry.spawnedBy,
+ spawnedWorkspaceDir: existingEntry.spawnedWorkspaceDir,
+ spawnedCwd: existingEntry.spawnedCwd,
+ parentSessionKey: existingEntry.parentSessionKey,
+ forkedFromParent: existingEntry.forkedFromParent,
+ forkSource: existingEntry.forkSource,
+ createdVia: existingEntry.createdVia,
+ createdActor: existingEntry.createdActor,
+ createdAt: existingEntry.createdAt,
+ spawnDepth: existingEntry.spawnDepth,
+ subagentRole: existingEntry.subagentRole,
+ subagentControlScope: existingEntry.subagentControlScope,
+ }
: {}),
sessionFile,
updatedAt: now,
diff --git a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts
index a1461df8cb8e..bb337b19720f 100644
--- a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts
+++ b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts
@@ -6,6 +6,7 @@ import {
} from "../../agents/model-selection.js";
import { loadPreparedModelCatalog } from "../../agents/prepared-model-catalog.js";
import type { OpenClawConfig } from "../../config/config.js";
+import { recordSessionCreated } from "../../sessions/session-state-events.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import type { SkillCommandSpec } from "../../skills/types.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
@@ -169,6 +170,13 @@ export async function maybeResolveNativeSlashCommandFastReply(params: {
};
}
const persistedInitialEntry = persistence.entry;
+ if (creatingSession) {
+ recordSessionCreated({
+ sessionKey: sessionState.sessionKey,
+ agentId: params.agentId,
+ entry: persistedInitialEntry,
+ });
+ }
// Commit the synthesized activity/channel touch before commands or directives
// capture their own mutation baseline.
sessionState.sessionEntry = persistedInitialEntry;
diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts
index 159ff224827e..b10ba8bedd61 100644
--- a/src/auto-reply/reply/get-reply.fast-path.test.ts
+++ b/src/auto-reply/reply/get-reply.fast-path.test.ts
@@ -13,6 +13,8 @@ import {
MODEL_SELECTION_LOCKED_RESET_MESSAGE,
ModelSelectionLockedError,
} from "../../sessions/model-overrides.js";
+import { listSessionStateEventsSince } from "../../sessions/session-state-events.js";
+import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import { getReplyPayloadMetadata } from "../reply-payload.js";
import { handleGoalCommand } from "./commands-goal.js";
import { buildFastReplyCommandContext, initFastReplySessionState } from "./get-reply-fast-path.js";
@@ -200,6 +202,7 @@ describe("getReplyFromConfig fast test bootstrap", () => {
});
afterEach(() => {
+ closeOpenClawStateDatabaseForTest();
cliBackendsTesting.resetDepsForTest();
vi.unstubAllEnvs();
});
@@ -604,6 +607,7 @@ describe("getReplyFromConfig fast test bootstrap", () => {
it("handles native slash directives before workspace bootstrap", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-slash-fast-"));
const targetSessionKey = "agent:main:telegram:123";
+ vi.stubEnv("OPENCLAW_STATE_DIR", path.join(home, "state"));
const cfg = markCompleteReplyConfig({
agents: {
defaults: {
@@ -628,6 +632,10 @@ describe("getReplyFromConfig fast test bootstrap", () => {
CommandAuthorized: true,
SessionKey: "telegram:slash:123",
CommandTargetSessionKey: targetSessionKey,
+ SessionCreation: {
+ via: "operator",
+ actor: { type: "human", id: "profile-native-slash" },
+ },
}),
undefined,
cfg,
@@ -644,6 +652,13 @@ describe("getReplyFromConfig fast test bootstrap", () => {
expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled();
expect(mocks.handleCommands).toHaveBeenCalledOnce();
expect(mocks.resolveReplyDirectives).toHaveBeenCalledOnce();
+ expect(listSessionStateEventsSince(targetSessionKey, "main", 0, 20).events).toContainEqual(
+ expect.objectContaining({
+ kind: "created",
+ actorType: "human",
+ actorId: "profile-native-slash",
+ }),
+ );
const directiveParams = requireDirectiveParams();
expect(directiveParams.sessionKey).toBe(targetSessionKey);
expect(directiveParams.workspaceDir).toBe("/tmp/workspace");
@@ -759,6 +774,28 @@ describe("getReplyFromConfig fast test bootstrap", () => {
);
});
+ it("stamps trusted creation provenance during fast bootstrap", () => {
+ const result = initFastReplySessionState({
+ ctx: buildGetReplyCtx({
+ SessionKey: "agent:main:dashboard:created",
+ SessionCreation: {
+ via: "operator",
+ actor: { type: "human", id: "profile-ada" },
+ },
+ }),
+ cfg: { session: { store: "/tmp/sessions.json" } } as OpenClawConfig,
+ agentId: "main",
+ commandAuthorized: true,
+ workspaceDir: "/tmp/workspace",
+ });
+
+ expect(result.sessionEntry).toMatchObject({
+ createdVia: "operator",
+ createdActor: { type: "human", id: "profile-ada" },
+ createdAt: expect.any(Number),
+ });
+ });
+
it("preserves usage footer mode during fast reset bootstrap", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reset-usage-"));
const storePath = path.join(home, "sessions.json");
@@ -788,6 +825,57 @@ describe("getReplyFromConfig fast test bootstrap", () => {
expect(result.sessionEntry.responseUsage).toBe("full");
});
+ it("preserves node provenance and lineage during fast reset bootstrap", async () => {
+ const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reset-lineage-"));
+ const storePath = path.join(home, "sessions.json");
+ const sessionKey = "agent:main:telegram:lineage";
+ await seedFastPathSessionStore(storePath, {
+ [sessionKey]: {
+ sessionId: "existing-fast-reset-lineage",
+ updatedAt: Date.now(),
+ spawnedBy: "agent:main:main",
+ parentSessionKey: "agent:main:dashboard:parent",
+ spawnedWorkspaceDir: "/tmp/workspace",
+ spawnedCwd: "/tmp/repo",
+ forkSource: { sessionKey: "agent:main:main", sessionId: "source-generation" },
+ createdVia: "spawn",
+ createdActor: { type: "agent", id: "agent:main:main" },
+ createdAt: 1_234,
+ spawnDepth: 2,
+ subagentRole: "orchestrator",
+ subagentControlScope: "children",
+ },
+ });
+
+ const result = initFastReplySessionState({
+ ctx: buildGetReplyCtx({
+ Body: "/reset",
+ RawBody: "/reset",
+ CommandBody: "/reset",
+ SessionKey: sessionKey,
+ }),
+ cfg: { session: { store: storePath } } as OpenClawConfig,
+ agentId: "main",
+ commandAuthorized: true,
+ workspaceDir: home,
+ });
+
+ expect(result.sessionEntry).toMatchObject({
+ previousSessionId: "existing-fast-reset-lineage",
+ spawnedBy: "agent:main:main",
+ parentSessionKey: "agent:main:dashboard:parent",
+ spawnedWorkspaceDir: "/tmp/workspace",
+ spawnedCwd: "/tmp/repo",
+ forkSource: { sessionKey: "agent:main:main", sessionId: "source-generation" },
+ createdVia: "spawn",
+ createdActor: { type: "agent", id: "agent:main:main" },
+ createdAt: 1_234,
+ spawnDepth: 2,
+ subagentRole: "orchestrator",
+ subagentControlScope: "children",
+ });
+ });
+
it("rejects a fast reset bootstrap for a model-locked session", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reset-locked-"));
const storePath = path.join(home, "sessions.json");
diff --git a/src/auto-reply/reply/session-creator.test.ts b/src/auto-reply/reply/session-creator.test.ts
deleted file mode 100644
index 3238403d8a65..000000000000
--- a/src/auto-reply/reply/session-creator.test.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import fs from "node:fs/promises";
-import os from "node:os";
-import path from "node:path";
-import { afterEach, expect, it } from "vitest";
-import type { OpenClawConfig } from "../../config/config.js";
-import { upsertSessionEntry } from "../../config/sessions/session-accessor.js";
-import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
-import { initSessionState } from "./session.js";
-
-let tempDir: string | undefined;
-
-afterEach(async () => {
- closeOpenClawAgentDatabasesForTest();
- if (tempDir) {
- await fs.rm(tempDir, { force: true, recursive: true });
- tempDir = undefined;
- }
-});
-
-it("clears the previous creator when an ownerless turn starts a new generation", async () => {
- tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-creator-"));
- const storePath = path.join(tempDir, "sessions.json");
- const sessionKey = "agent:main:telegram:chat:creator";
- await upsertSessionEntry(
- { sessionKey, storePath },
- {
- createdBy: { id: "alice@example.com", label: "Alice" },
- sessionId: "owned-session",
- updatedAt: 1,
- },
- );
-
- const result = await initSessionState({
- ctx: { Body: "/new", CommandBody: "/new", SessionKey: sessionKey },
- cfg: { session: { store: storePath } } as OpenClawConfig,
- commandAuthorized: true,
- });
-
- expect(result.isNewSession).toBe(true);
- expect(result.sessionEntry).not.toHaveProperty("createdBy");
-});
diff --git a/src/auto-reply/reply/session-fork.test.ts b/src/auto-reply/reply/session-fork.test.ts
index 3f22f422334e..e08c8b672ff8 100644
--- a/src/auto-reply/reply/session-fork.test.ts
+++ b/src/auto-reply/reply/session-fork.test.ts
@@ -188,6 +188,10 @@ describe("forkSessionEntryFromParent", () => {
const stored = loadSessionEntry({ agentId: "main", sessionKey, storePath });
expect(stored).toMatchObject({
forkedFromParent: true,
+ forkSource: {
+ sessionKey: parentSessionKey,
+ sessionId: "parent-session",
+ },
label: "forked child",
sessionFile: result.fork.sessionFile,
sessionId: result.fork.sessionId,
@@ -269,6 +273,9 @@ describe("forkSessionEntryFromParent", () => {
sessionId: "",
updatedAt: expect.any(Number),
});
+ expect(
+ loadSessionEntry({ agentId: "main", sessionKey, storePath })?.forkSource,
+ ).toBeUndefined();
});
it("skips stale-token SQLite parents using transcript usage estimates", async () => {
diff --git a/src/auto-reply/reply/session-parent-fork-prepare.ts b/src/auto-reply/reply/session-parent-fork-prepare.ts
index 386bc195a281..4f8cafe25d42 100644
--- a/src/auto-reply/reply/session-parent-fork-prepare.ts
+++ b/src/auto-reply/reply/session-parent-fork-prepare.ts
@@ -60,6 +60,10 @@ export async function prepareReplySessionParentFork(params: {
...buildMainSessionRecoveryClearPatch(params.sessionEntry),
sessionId: fork.sessionId,
sessionFile: fork.sessionFile,
+ forkSource: {
+ sessionKey: params.parentSessionKey,
+ sessionId: parentEntry.sessionId,
+ },
forkedFromParent: true,
totalTokens: undefined,
totalTokensFresh: false,
diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts
index b713b5641247..77251770acae 100644
--- a/src/auto-reply/reply/session.test.ts
+++ b/src/auto-reply/reply/session.test.ts
@@ -36,6 +36,8 @@ import {
isSessionLifecycleMutationActive,
runExclusiveSessionLifecycleMutation,
} from "../../sessions/session-lifecycle-admission.js";
+import { listSessionStateEventsSince } from "../../sessions/session-state-events.js";
+import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import {
createChannelTestPluginBase,
createTestRegistry,
@@ -579,6 +581,7 @@ beforeEach(() => {
});
});
afterEach(async () => {
+ closeOpenClawStateDatabaseForTest();
resetSystemEventsForTest();
await sessionMcpTesting.resetSessionMcpRuntimeManager();
});
@@ -720,6 +723,10 @@ describe("initSessionState thread forking", () => {
expect(result.sessionEntry.totalTokensFresh).toBe(false);
expect(result.sessionEntry.forkedFromParent).toBe(true);
+ expect(result.sessionEntry.forkSource).toEqual({
+ sessionKey: parentSessionKey,
+ sessionId: parentSessionId,
+ });
expect(result.sessionEntry.sessionFile).toBe(
formatSqliteSessionFileMarker({
agentId: "main",
@@ -811,6 +818,10 @@ describe("initSessionState thread forking", () => {
expect(first.sessionEntry.sessionId).not.toBe("preseed-thread-session");
expect(first.sessionEntry.forkedFromParent).toBe(true);
+ expect(first.sessionEntry.forkSource).toEqual({
+ sessionKey: parentSessionKey,
+ sessionId: parentSessionId,
+ });
expect(first.sessionEntry.totalTokens).toBeUndefined();
expect(first.sessionEntry.totalTokensFresh).toBe(false);
expect(first.sessionEntry.abortedLastRun).toBe(false);
@@ -899,6 +910,7 @@ describe("initSessionState thread forking", () => {
// Should be marked as forked (to prevent re-attempts) but NOT actually forked from parent
expect(result.sessionEntry.forkedFromParent).toBe(true);
+ expect(result.sessionEntry.forkSource).toBeUndefined();
// Session ID should NOT match the parent — it should be a fresh UUID
expect(result.sessionEntry.sessionId).not.toBe(parentSessionId);
// Session file should NOT be the parent's file (it was not forked)
@@ -1308,6 +1320,97 @@ describe("initSessionState RawBody", () => {
expect(store[sessionKey]?.modelOverrideSource).toBe("user");
});
+ it("stamps trusted creation provenance when initializing a missing session", async () => {
+ const root = await makeCaseDir("openclaw-session-creation-provenance-");
+ const storePath = path.join(root, "sessions.json");
+ const sessionKey = "agent:main:dashboard:created";
+
+ const result = await withEnvAsync(
+ { OPENCLAW_STATE_DIR: path.join(root, "state") },
+ async () => {
+ const initialized = await initSessionState({
+ ctx: {
+ RawBody: "hello",
+ ChatType: "direct",
+ SessionKey: sessionKey,
+ SessionCreation: {
+ via: "operator",
+ actor: { type: "human", id: "profile-ada" },
+ },
+ },
+ cfg: { session: { store: storePath } } as OpenClawConfig,
+ commandAuthorized: true,
+ });
+ expect(listSessionStateEventsSince(sessionKey, "main", 0, 20).events).toContainEqual(
+ expect.objectContaining({
+ kind: "created",
+ actorType: "human",
+ actorId: "profile-ada",
+ }),
+ );
+ return initialized;
+ },
+ );
+ expect(result.sessionEntry).toMatchObject({
+ createdVia: "operator",
+ createdActor: { type: "human", id: "profile-ada" },
+ createdAt: expect.any(Number),
+ });
+ });
+
+ it("preserves session lineage across an implicit daily stale rollover (#90119)", async () => {
+ const root = await makeCaseDir("openclaw-daily-rollover-lineage-");
+ const storePath = path.join(root, "sessions.json");
+ const sessionKey = "agent:main:subagent:daily-rollover-lineage";
+ const existingSessionId = "session-before-daily-reset-lineage";
+ const staleStartedAt = Date.now() - 48 * 60 * 60 * 1000;
+ const lineage = {
+ spawnedBy: "agent:main:main",
+ spawnedWorkspaceDir: "/tmp/child-workspace",
+ spawnedCwd: "/tmp/task-repo",
+ parentSessionKey: "agent:main:main",
+ forkedFromParent: true,
+ forkSource: {
+ sessionKey: "agent:main:root",
+ sessionId: "root-transcript-generation",
+ },
+ createdVia: "spawn",
+ createdActor: { type: "agent", id: "agent:main:main" },
+ createdAt: staleStartedAt - 1_000,
+ spawnDepth: 1,
+ subagentRole: "leaf",
+ subagentControlScope: "none",
+ } as const;
+
+ await writeSessionStoreFast(storePath, {
+ [sessionKey]: {
+ sessionId: existingSessionId,
+ updatedAt: staleStartedAt,
+ sessionStartedAt: staleStartedAt,
+ lastInteractionAt: staleStartedAt,
+ ...lineage,
+ },
+ });
+
+ const result = await initSessionState({
+ ctx: {
+ RawBody: "continue child work",
+ ChatType: "direct",
+ SessionKey: sessionKey,
+ },
+ cfg: {
+ session: { store: storePath, reset: { mode: "daily", atHour: 4 } },
+ } as OpenClawConfig,
+ commandAuthorized: true,
+ });
+
+ expect(result.isNewSession).toBe(true);
+ expect(result.resetTriggered).toBe(false);
+ expect(result.sessionId).not.toBe(existingSessionId);
+ expect(result.sessionEntry.previousSessionId).toBe(existingSessionId);
+ expectEntryFields(result.sessionEntry, lineage);
+ });
+
it("preserves user-set behavior and pinned state across an implicit daily stale rollover (#92562)", async () => {
// Regression: session-level behavior overrides (/think, /verbose, /reasoning,
// /trace, ttsAuto) survive an explicit /new but were dropped after the
@@ -3979,6 +4082,7 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
const overrides = {
spawnedBy: "agent:main:main",
spawnedWorkspaceDir: "/tmp/child-workspace",
+ spawnedCwd: "/tmp/task-repo",
parentSessionKey: "agent:main:main",
forkedFromParent: true,
spawnDepth: 2,
diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts
index 0117bb6df6cf..a6771b5af53c 100644
--- a/src/auto-reply/reply/session.ts
+++ b/src/auto-reply/reply/session.ts
@@ -34,6 +34,8 @@ import {
commitReplySessionInitialization,
loadReplySessionInitializationSnapshot,
} from "../../config/sessions/session-accessor.js";
+import { sessionEntryForkedFromParent } from "../../config/sessions/session-entry-lineage.js";
+import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js";
import { resolveSessionKey } from "../../config/sessions/session-key.js";
import { resolveMaintenanceConfigFromInput } from "../../config/sessions/store-maintenance.js";
import { runExclusiveSessionStoreWrite } from "../../config/sessions/store-writer.js";
@@ -77,6 +79,7 @@ import {
interruptSessionWorkAdmissions,
runExclusiveSessionLifecycleMutation,
} from "../../sessions/session-lifecycle-admission.js";
+import { recordSessionCreated } from "../../sessions/session-state-events.js";
import {
classifySessionStateActor,
registerMainSessionGroupWatch,
@@ -477,6 +480,10 @@ async function initSessionStateAttemptLocked(
let persistedSpawnedCwd: SessionEntry["spawnedCwd"];
let persistedParentSessionKey: SessionEntry["parentSessionKey"];
let persistedForkedFromParent: SessionEntry["forkedFromParent"];
+ let persistedForkSource: SessionEntry["forkSource"];
+ let persistedCreatedVia: SessionEntry["createdVia"];
+ let persistedCreatedActor: SessionEntry["createdActor"];
+ let persistedCreatedAt: SessionEntry["createdAt"];
let persistedSpawnDepth: SessionEntry["spawnDepth"];
let persistedSubagentRole: SessionEntry["subagentRole"];
let persistedSubagentControlScope: SessionEntry["subagentControlScope"];
@@ -584,6 +591,7 @@ async function initSessionStateAttemptLocked(
ctx,
});
const entry = initializationSnapshot.currentEntry;
+ const createdNewEntry = entry === undefined;
const archivedSessionError = resolveSessionWorkStartError(sessionKey, entry);
if (archivedSessionError) {
throw new Error(archivedSessionError);
@@ -804,17 +812,18 @@ async function initSessionStateAttemptLocked(
persistedResponseUsage = entry.responseUsage;
persistedLabel = entry.label;
persistedDisplayName = entry.displayName;
- }
- // When a reset trigger (/new, /reset) starts a new session, also rotate the
- // underlying CLI conversation and carry forward spawn lineage.
- if (resetTriggered && entry) {
- // Explicit /new and /reset should rotate the underlying CLI conversation too.
- // Keep the model/auth choice, but force the next turn to mint a fresh CLI binding.
+ // Explicit /new and /reset rotate CLI conversation bindings elsewhere.
+ // Lineage/control facts belong to the session node and survive ANY rollover
+ // from an existing entry, following the #90119 carry pattern.
persistedSpawnedBy = entry.spawnedBy;
persistedSpawnedWorkspaceDir = entry.spawnedWorkspaceDir;
persistedSpawnedCwd = entry.spawnedCwd;
persistedParentSessionKey = entry.parentSessionKey;
persistedForkedFromParent = entry.forkedFromParent;
+ persistedForkSource = entry.forkSource;
+ persistedCreatedVia = entry.createdVia;
+ persistedCreatedActor = entry.createdActor;
+ persistedCreatedAt = entry.createdAt;
persistedSpawnDepth = entry.spawnDepth;
persistedSubagentRole = entry.subagentRole;
persistedSubagentControlScope = entry.subagentControlScope;
@@ -902,16 +911,11 @@ async function initSessionStateAttemptLocked(
const lastTo = deliveryFields.lastTo ?? lastToRaw;
const lastAccountId = deliveryFields.lastAccountId ?? lastAccountIdRaw;
const lastThreadId = deliveryFields.lastThreadId ?? lastThreadIdRaw;
+ const creationStamp =
+ !entry && ctx.SessionCreation ? buildSessionCreationStamp(ctx.SessionCreation) : undefined;
sessionEntry = {
...baseEntry,
sessionId,
- ...(isNewSession
- ? ctx.SessionCreator
- ? { createdBy: { ...ctx.SessionCreator } }
- : {}
- : baseEntry?.createdBy
- ? { createdBy: baseEntry.createdBy }
- : {}),
updatedAt: Date.now(),
sessionStartedAt: isNewSession
? now
@@ -930,6 +934,7 @@ async function initSessionStateAttemptLocked(
pinnedAt: entry?.pinnedAt,
usageFamilyKey,
usageFamilySessionIds,
+ previousSessionId: previousSessionEntry?.sessionId ?? baseEntry?.previousSessionId,
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
modelOverrideSource: persistedModelOverrideSource ?? baseEntry?.modelOverrideSource,
@@ -947,6 +952,10 @@ async function initSessionStateAttemptLocked(
spawnedCwd: persistedSpawnedCwd ?? baseEntry?.spawnedCwd,
parentSessionKey: persistedParentSessionKey ?? baseEntry?.parentSessionKey,
forkedFromParent: persistedForkedFromParent ?? baseEntry?.forkedFromParent,
+ forkSource: persistedForkSource ?? baseEntry?.forkSource,
+ createdVia: persistedCreatedVia ?? baseEntry?.createdVia ?? creationStamp?.createdVia,
+ createdActor: persistedCreatedActor ?? baseEntry?.createdActor ?? creationStamp?.createdActor,
+ createdAt: persistedCreatedAt ?? baseEntry?.createdAt ?? creationStamp?.createdAt,
spawnDepth: persistedSpawnDepth ?? baseEntry?.spawnDepth,
subagentRole: persistedSubagentRole ?? baseEntry?.subagentRole,
subagentControlScope: persistedSubagentControlScope ?? baseEntry?.subagentControlScope,
@@ -999,7 +1008,7 @@ async function initSessionStateAttemptLocked(
sessionEntry.displayName = threadLabel;
}
const parentSessionKey = normalizeOptionalString(ctx.ParentSessionKey);
- const alreadyForked = sessionEntry.forkedFromParent === true;
+ const alreadyForked = sessionEntryForkedFromParent(sessionEntry);
if (params.signal?.aborted === true) {
throw new Error("reply session initialization aborted");
}
@@ -1093,6 +1102,9 @@ async function initSessionStateAttemptLocked(
}
sessionEntry = committed.sessionEntry;
sessionId = sessionEntry.sessionId;
+ if (createdNewEntry) {
+ recordSessionCreated({ sessionKey, agentId, entry: sessionEntry });
+ }
if (
!isSystemEvent &&
classifySessionStateActor({ inputProvenance: ctx.InputProvenance }).actorType === "human"
diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts
index 6179cef154db..186858010fa9 100644
--- a/src/auto-reply/templating.ts
+++ b/src/auto-reply/templating.ts
@@ -276,8 +276,11 @@ export type MsgContext = {
OwnerAllowFrom?: Array;
SenderName?: string;
SenderId?: string;
- /** Trusted Gateway operator identity used only when creating a session. */
- SessionCreator?: import("../../packages/gateway-protocol/src/schema/sessions.js").SessionCreatorIdentity;
+ /** Trusted in-process creation provenance; never populated from channel payloads. */
+ SessionCreation?: {
+ via: import("../config/sessions/session-entry-provenance.js").SessionCreatedVia;
+ actor?: import("../config/sessions/session-entry-provenance.js").SessionCreatedActor;
+ };
SenderUsername?: string;
SenderTag?: string;
SenderE164?: string;
diff --git a/src/commands/sessions-table.ts b/src/commands/sessions-table.ts
index 9374eea1a833..f00ef0630b40 100644
--- a/src/commands/sessions-table.ts
+++ b/src/commands/sessions-table.ts
@@ -7,6 +7,7 @@
import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { theme } from "../../packages/terminal-core/src/theme.js";
import type { SessionEntry } from "../config/sessions.js";
+import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
/** Display row derived from a persisted session entry. */
@@ -66,7 +67,7 @@ export function toSessionDisplayRow(key: string, entry: SessionEntry): SessionDi
spawnedWorkspaceDir: entry?.spawnedWorkspaceDir,
spawnedCwd: entry?.spawnedCwd,
parentSessionKey: entry?.parentSessionKey,
- forkedFromParent: entry?.forkedFromParent,
+ forkedFromParent: sessionEntryForkedFromParent(entry) ? true : undefined,
spawnDepth: entry?.spawnDepth,
subagentRole: entry?.subagentRole,
subagentControlScope: entry?.subagentControlScope,
diff --git a/src/config/sessions/session-accessor.conformance.test.ts b/src/config/sessions/session-accessor.conformance.test.ts
index 01c5fd9eef5a..f7a88069de51 100644
--- a/src/config/sessions/session-accessor.conformance.test.ts
+++ b/src/config/sessions/session-accessor.conformance.test.ts
@@ -1258,7 +1258,11 @@ describe("sqlite session normalization", () => {
await upsertSqliteSessionEntry(scope, {
channel: "telegram",
chatType: "group",
+ createdVia: "channel",
+ createdActor: { type: "human", id: "telegram-sender" },
+ createdAt: 1_782_973_390_000,
displayName: "telegram:g-bucephalus-+-topics",
+ forkSource: { sessionKey: "agent:main:main", sessionId: "root-session" },
sessionId: oldSessionId,
status: "killed",
updatedAt: 1_782_973_392_492,
@@ -1298,6 +1302,11 @@ describe("sqlite session normalization", () => {
expect(loadSqliteSessionEntry(scope)).toEqual(
expect.objectContaining({
sessionId: newSessionId,
+ createdVia: "channel",
+ createdActor: { type: "human", id: "telegram-sender" },
+ createdAt: 1_782_973_390_000,
+ forkSource: { sessionKey: "agent:main:main", sessionId: "root-session" },
+ previousSessionId: oldSessionId,
usageFamilyKey: sessionKey,
usageFamilySessionIds: [oldSessionId, newSessionId],
}),
diff --git a/src/config/sessions/session-accessor.sqlite-entry-store.ts b/src/config/sessions/session-accessor.sqlite-entry-store.ts
index 112924395719..91dabf0b9846 100644
--- a/src/config/sessions/session-accessor.sqlite-entry-store.ts
+++ b/src/config/sessions/session-accessor.sqlite-entry-store.ts
@@ -25,7 +25,6 @@ import {
import {
normalizeSqliteStatus,
parseSqliteSessionEntryJson as parseSessionEntryRow,
- serializeSqliteSessionCreatorIdentity,
} from "./session-accessor.sqlite-status.js";
import {
readTranscriptMutationStateInTransaction,
@@ -488,7 +487,6 @@ export function writeSessionEntry(
entry_json: JSON.stringify(normalizedEntry),
updated_at: updatedAt,
status: normalizeSqliteStatus(normalizedEntry.status),
- created_by_json: serializeSqliteSessionCreatorIdentity(normalizedEntry.createdBy),
})
.onConflict((conflict) =>
conflict.column("session_key").doUpdateSet({
@@ -496,7 +494,6 @@ export function writeSessionEntry(
entry_json: JSON.stringify(normalizedEntry),
updated_at: updatedAt,
status: normalizeSqliteStatus(normalizedEntry.status),
- created_by_json: serializeSqliteSessionCreatorIdentity(normalizedEntry.createdBy),
}),
),
);
diff --git a/src/config/sessions/session-accessor.sqlite-entry.ts b/src/config/sessions/session-accessor.sqlite-entry.ts
index e4380cdb5a57..0343e6dfce55 100644
--- a/src/config/sessions/session-accessor.sqlite-entry.ts
+++ b/src/config/sessions/session-accessor.sqlite-entry.ts
@@ -64,6 +64,7 @@ import {
readSqliteSessionEntriesByStatus,
} from "./session-accessor.sqlite-status.js";
import { preserveSqliteSameKeySessionRolloverLineage } from "./session-entry-lineage.js";
+import { buildSessionCreationStamp } from "./session-entry-provenance.js";
import { kickSessionHistoryDiskBudgetMaintenance } from "./session-history-eviction.js";
import { resolveSessionStorePathForScope } from "./session-store-path.js";
import type { GroupKeyResolution, SessionEntry } from "./types.js";
@@ -419,13 +420,27 @@ export async function recordSqliteInboundSessionMeta(params: {
const createIfMissing = params.createIfMissing ?? true;
return await patchSqliteSessionEntry(
{ sessionKey: params.sessionKey, storePath: params.storePath },
- (_entry, context) =>
- deriveSessionMetaPatch({
+ (_entry, context) => {
+ const metadataPatch = deriveSessionMetaPatch({
ctx: params.ctx,
sessionKey: params.sessionKey,
existing: context.existingEntry,
groupResolution: params.groupResolution,
- }),
+ });
+ if (context.existingEntry) {
+ return metadataPatch;
+ }
+ const senderId = params.ctx.From?.trim();
+ return {
+ ...buildSessionCreationStamp(
+ params.ctx.SessionCreation ?? {
+ via: "channel",
+ actor: { type: "human", ...(senderId ? { id: senderId } : {}) },
+ },
+ ),
+ ...metadataPatch,
+ };
+ },
{
// Inbound metadata must not refresh activity timestamps; idle reset
// evaluation relies on updatedAt from actual session turns.
@@ -452,8 +467,8 @@ export async function updateSqliteSessionLastRoute(params: {
const createIfMissing = params.createIfMissing ?? true;
return await patchSqliteSessionEntry(
{ sessionKey: params.sessionKey, storePath: params.storePath },
- (_entry, context) =>
- deriveLastRoutePatch({
+ (_entry, context) => {
+ const routePatch = deriveLastRoutePatch({
channel: params.channel,
to: params.to,
accountId: params.accountId,
@@ -464,7 +479,23 @@ export async function updateSqliteSessionLastRoute(params: {
groupResolution: params.groupResolution,
existing: context.existingEntry,
sessionKey: params.sessionKey,
- }),
+ });
+ if (context.existingEntry) {
+ return routePatch;
+ }
+ const senderId = params.ctx?.From?.trim();
+ return {
+ ...buildSessionCreationStamp(
+ params.ctx?.SessionCreation ?? {
+ via: "channel",
+ ...(params.ctx
+ ? { actor: { type: "human" as const, ...(senderId ? { id: senderId } : {}) } }
+ : {}),
+ },
+ ),
+ ...routePatch,
+ };
+ },
{
// Route updates must not refresh activity timestamps (#49515).
preserveActivity: true,
diff --git a/src/config/sessions/session-accessor.sqlite-message-cut.test.ts b/src/config/sessions/session-accessor.sqlite-message-cut.test.ts
index 5af4dfec76d1..5684fbc6e0ac 100644
--- a/src/config/sessions/session-accessor.sqlite-message-cut.test.ts
+++ b/src/config/sessions/session-accessor.sqlite-message-cut.test.ts
@@ -37,7 +37,11 @@ async function createSession(options: { activeLeafTarget?: string } = {}) {
cliSessionIds: { "claude-cli": "claude-conversation" },
compactionCount: 2,
contextTokens: 100_000,
+ createdVia: "operator",
+ createdActor: { type: "human", id: "profile-1" },
+ createdAt: 1_000,
deliveryContext: { channel: "telegram", to: "chat-123" },
+ forkSource: { sessionKey: "agent:main:root", sessionId: "root-session" },
lastChannel: "telegram",
lastTo: "chat-123",
lifecycleRevision: "source-lifecycle-revision",
@@ -210,6 +214,11 @@ describe("SQLite session message cuts", () => {
cliSessionIds: undefined,
compactionCount: undefined,
contextTokens: undefined,
+ createdVia: "operator",
+ createdActor: { type: "human", id: "profile-1" },
+ createdAt: 1_000,
+ forkSource: { sessionKey: "agent:main:root", sessionId: "root-session" },
+ previousSessionId: "message-cut-source",
});
expect(result.entry.deliveryContext).toEqual({ channel: "telegram", to: "chat-123" });
});
@@ -274,6 +283,16 @@ describe("SQLite session message cuts", () => {
expect(result.entry.lastChannel).toBeUndefined();
expect(result.entry.lastTo).toBeUndefined();
expect(result.entry.parentSessionKey).toBe(canonicalSourceKey);
+ expect(result.entry.previousSessionId).toBeUndefined();
+ expect(result.entry.forkedFromParent).toBeUndefined();
+ expect(result.entry.createdVia).toBeUndefined();
+ expect(result.entry.createdActor).toBeUndefined();
+ expect(result.entry.createdAt).toBeUndefined();
+ expect(result.entry.forkSource).toEqual({
+ sessionKey: canonicalSourceKey,
+ sessionId: "message-cut-source",
+ entryId: "user-2",
+ });
expect(result.entry).toMatchObject({
modelOverride: "gpt-5",
modelOverrideSource: "user",
diff --git a/src/config/sessions/session-accessor.sqlite-message-cut.ts b/src/config/sessions/session-accessor.sqlite-message-cut.ts
index 50221d321bc1..0c450679d2b7 100644
--- a/src/config/sessions/session-accessor.sqlite-message-cut.ts
+++ b/src/config/sessions/session-accessor.sqlite-message-cut.ts
@@ -33,6 +33,7 @@ import type {
SessionMessageCutMutationParams,
SessionMessageCutMutationResult,
} from "./session-accessor.types.js";
+import { buildSessionCreationStamp } from "./session-entry-provenance.js";
import { inheritSessionSelection } from "./session-entry-selection.js";
import { reconcileSessionTranscriptIndexInTransaction } from "./session-transcript-index.js";
import { parseSqliteSessionFileMarker } from "./sqlite-marker.js";
@@ -142,6 +143,7 @@ async function mutateSqliteSessionAtMessage(
result = mutateSqliteSessionAtMessageInTransaction(database, resolved, {
entryId: params.entryId,
canonicalSourceKey,
+ creation: params.creation,
mode,
sourceKey,
targetKey,
@@ -158,6 +160,7 @@ function mutateSqliteSessionAtMessageInTransaction(
resolved: ResolvedSqliteScope,
params: {
canonicalSourceKey: string;
+ creation?: SessionMessageCutMutationParams["creation"];
entryId: string;
mode: SessionTranscriptMutationMode;
sourceKey: string;
@@ -215,13 +218,25 @@ function mutateSqliteSessionAtMessageInTransaction(
// Rotating transcript identity fences stale live managers: later snapshot-replace writes
// target the old session and cannot erase this leaf repoint from the active session.
- const nextEntry = cloneMessageCutSessionEntry({
- currentEntry,
- forked: params.mode === "fork",
- nextSessionFile,
- nextSessionId,
- parentSessionKey: params.mode === "fork" ? params.canonicalSourceKey : undefined,
- });
+ const nextEntry = {
+ ...cloneMessageCutSessionEntry({
+ currentEntry,
+ forked: params.mode === "fork",
+ forkSource:
+ params.mode === "fork"
+ ? {
+ sessionKey: params.canonicalSourceKey,
+ sessionId: currentEntry.sessionId,
+ entryId: params.entryId,
+ }
+ : undefined,
+ nextSessionFile,
+ nextSessionId,
+ }),
+ ...(params.mode === "fork" && params.creation
+ ? buildSessionCreationStamp(params.creation)
+ : {}),
+ };
writeSessionEntry(database, params.targetKey, nextEntry);
return {
status: "created",
@@ -356,9 +371,9 @@ function resolveMessageCut(
function cloneMessageCutSessionEntry(params: {
currentEntry: SessionEntry;
forked: boolean;
+ forkSource?: NonNullable;
nextSessionFile: string;
nextSessionId: string;
- parentSessionKey?: string;
}): SessionEntry {
const baseEntry = params.forked
? inheritSessionSelection(params.currentEntry)
@@ -407,7 +422,10 @@ function cloneMessageCutSessionEntry(params: {
abortCutoffTimestamp: undefined,
usageFamilyKey: params.forked ? undefined : params.currentEntry.usageFamilyKey,
usageFamilySessionIds: params.forked ? undefined : params.currentEntry.usageFamilySessionIds,
- ...(params.parentSessionKey ? { parentSessionKey: params.parentSessionKey } : {}),
+ previousSessionId: params.forked ? undefined : params.currentEntry.sessionId,
+ ...(params.forkSource
+ ? { forkSource: params.forkSource, parentSessionKey: params.forkSource.sessionKey }
+ : {}),
};
}
diff --git a/src/config/sessions/session-accessor.sqlite-parent-session.ts b/src/config/sessions/session-accessor.sqlite-parent-session.ts
index d0e0518a680d..55fc923fb45f 100644
--- a/src/config/sessions/session-accessor.sqlite-parent-session.ts
+++ b/src/config/sessions/session-accessor.sqlite-parent-session.ts
@@ -218,6 +218,10 @@ export async function forkSqliteSessionEntryFromParentTarget(
});
const next = mergeSessionEntry(freshBase, {
...patch,
+ forkSource: {
+ sessionKey: parentTarget.canonicalKey,
+ sessionId: freshParent.sessionId,
+ },
forkedFromParent: true,
sessionFile: fork.transcript.sessionFile,
sessionId: fork.transcript.sessionId,
diff --git a/src/config/sessions/session-accessor.sqlite-status.ts b/src/config/sessions/session-accessor.sqlite-status.ts
index d125210aa008..d73ccc88b5cb 100644
--- a/src/config/sessions/session-accessor.sqlite-status.ts
+++ b/src/config/sessions/session-accessor.sqlite-status.ts
@@ -19,42 +19,12 @@ export function normalizeSqliteStatus(value: unknown): SessionEntryStatus | null
: null;
}
-function normalizeSessionCreatorIdentity(value: unknown): SessionEntry["createdBy"] {
- if (!value || typeof value !== "object" || Array.isArray(value)) {
- return undefined;
- }
- const candidate = value as { id?: unknown; label?: unknown };
- const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
- if (!id) {
- return undefined;
- }
- const label = typeof candidate.label === "string" ? candidate.label.trim() : "";
- return { id, ...(label ? { label } : {}) };
-}
-
-export function serializeSqliteSessionCreatorIdentity(
- createdBy: SessionEntry["createdBy"],
-): string | null {
- const normalized = normalizeSessionCreatorIdentity(createdBy);
- return normalized ? JSON.stringify(normalized) : null;
-}
-
export function parseSqliteSessionEntryJson(row: { entry_json: string }): SessionEntry | null {
try {
const parsed = JSON.parse(row.entry_json) as unknown;
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
- return null;
- }
- const entry = parsed as SessionEntry;
- // entry_json stays authoritative across downgrade/upgrade cycles: an older
- // binary can rewrite it without knowing about the additive projection column.
- const createdBy = normalizeSessionCreatorIdentity(entry.createdBy);
- if (createdBy) {
- entry.createdBy = createdBy;
- } else {
- delete entry.createdBy;
- }
- return entry;
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
+ ? (parsed as SessionEntry)
+ : null;
} catch {
return null;
}
diff --git a/src/config/sessions/session-accessor.test.ts b/src/config/sessions/session-accessor.test.ts
index 233d7cac1a4f..19f225ff2279 100644
--- a/src/config/sessions/session-accessor.test.ts
+++ b/src/config/sessions/session-accessor.test.ts
@@ -105,7 +105,6 @@ describe("session accessor seam", () => {
};
await upsertSessionEntry(scope, {
- createdBy: { id: "profile-ada", label: "Ada Lovelace" },
model: "gpt-5.5",
sessionId: "session-1",
updatedAt: 10,
@@ -113,20 +112,10 @@ describe("session accessor seam", () => {
expect(loadSessionEntry(scope)).toMatchObject({
model: "gpt-5.5",
- createdBy: { id: "profile-ada", label: "Ada Lovelace" },
sessionId: "session-1",
updatedAt: expect.any(Number),
});
expect(readSessionUpdatedAt(scope)).toEqual(expect.any(Number));
- const databasePath = resolveSqliteTargetFromSessionStorePath(storePath, {
- agentId: "main",
- }).path;
- const database = openOpenClawAgentDatabase({ agentId: "main", path: databasePath });
- expect(
- database.db
- .prepare("SELECT created_by_json FROM session_entries WHERE session_key = ?")
- .get(scope.sessionKey),
- ).toEqual({ created_by_json: '{"id":"profile-ada","label":"Ada Lovelace"}' });
expect(listSessionEntries({ storePath })).toEqual([
{
sessionKey: "agent:main:main",
@@ -138,17 +127,6 @@ describe("session accessor seam", () => {
},
]);
- // A downgraded writer knows only entry_json and can leave the additive
- // projection untouched. Re-upgrade must not resurrect that stale creator.
- database.db
- .prepare("UPDATE session_entries SET entry_json = ?, updated_at = ? WHERE session_key = ?")
- .run(
- JSON.stringify({ model: "legacy-reset", sessionId: "session-1", updatedAt: 15 }),
- 15,
- scope.sessionKey,
- );
- expect(loadSessionEntry(scope)).not.toHaveProperty("createdBy");
-
await upsertSessionEntry(scope, { model: "sonnet-4.6", updatedAt: 20 });
expect(loadSessionEntry(scope)).toMatchObject({
@@ -156,7 +134,6 @@ describe("session accessor seam", () => {
sessionId: "session-1",
updatedAt: expect.any(Number),
});
- expect(loadSessionEntry(scope)).not.toHaveProperty("createdBy");
});
it("lists retained transcript instances across same-key session rotation", async () => {
@@ -496,12 +473,48 @@ describe("session accessor seam", () => {
const recorded = await recordInboundSessionMeta({ storePath, sessionKey, ctx });
expect(recorded?.origin?.provider).toBe("webchat");
+ expect(recorded).toMatchObject({
+ createdVia: "channel",
+ createdActor: { type: "human", id: "webchat:user-1" },
+ createdAt: expect.any(Number),
+ });
+ const creationStamp = {
+ createdVia: recorded?.createdVia,
+ createdActor: recorded?.createdActor,
+ createdAt: recorded?.createdAt,
+ };
+
+ await recordInboundSessionMeta({
+ storePath,
+ sessionKey,
+ ctx: { ...ctx, From: "webchat:different-sender" },
+ });
+ expect(loadSessionEntry({ sessionKey, storePath })).toMatchObject(creationStamp);
// Detached result: caller mutations must never leak into cached store state.
if (recorded) {
recorded.origin = { provider: "mutated" };
}
expect(loadSessionEntry({ sessionKey, storePath })?.origin?.provider).toBe("webchat");
+
+ const operatorKey = "agent:main:dashboard:operator-created";
+ const operator = await recordInboundSessionMeta({
+ storePath,
+ sessionKey: operatorKey,
+ ctx: {
+ ...ctx,
+ SessionKey: operatorKey,
+ SessionCreation: {
+ via: "operator",
+ actor: { type: "human", id: "profile-ada" },
+ },
+ },
+ });
+ expect(operator).toMatchObject({
+ createdVia: "operator",
+ createdActor: { type: "human", id: "profile-ada" },
+ createdAt: expect.any(Number),
+ });
});
it("does not create sessions when inbound meta recording opts out of upsert", async () => {
diff --git a/src/config/sessions/session-accessor.types.ts b/src/config/sessions/session-accessor.types.ts
index 6394fbb36684..0843606bd1c4 100644
--- a/src/config/sessions/session-accessor.types.ts
+++ b/src/config/sessions/session-accessor.types.ts
@@ -665,6 +665,10 @@ export type SessionMessageCutMutationResult =
export type SessionMessageCutMutationParams = {
agentId?: string;
+ creation?: {
+ via: import("./session-entry-provenance.js").SessionCreatedVia;
+ actor?: import("./session-entry-provenance.js").SessionCreatedActor;
+ };
entryId: string;
env?: NodeJS.ProcessEnv;
sessionKey: string;
diff --git a/src/config/sessions/session-entry-lineage.ts b/src/config/sessions/session-entry-lineage.ts
index 97e9a095b73c..70ad3974c192 100644
--- a/src/config/sessions/session-entry-lineage.ts
+++ b/src/config/sessions/session-entry-lineage.ts
@@ -1,6 +1,13 @@
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import type { SessionEntry } from "./types.js";
+/** True when this entry's transcript began as a copy of a parent (actual forkSource ancestry or the legacy/thread-settled marker). */
+export function sessionEntryForkedFromParent(
+ entry: Pick | undefined,
+): boolean {
+ return entry?.forkSource !== undefined || entry?.forkedFromParent === true;
+}
+
export function preserveSqliteSameKeySessionRolloverLineage(params: {
next: SessionEntry;
previous: SessionEntry;
@@ -13,6 +20,7 @@ export function preserveSqliteSameKeySessionRolloverLineage(params: {
}
return {
...params.next,
+ previousSessionId,
usageFamilyKey:
params.next.usageFamilyKey ?? params.previous.usageFamilyKey ?? params.sessionKey,
usageFamilySessionIds: uniqueStrings([
diff --git a/src/config/sessions/session-entry-provenance.ts b/src/config/sessions/session-entry-provenance.ts
index 79f4a5255160..804f05f08509 100644
--- a/src/config/sessions/session-entry-provenance.ts
+++ b/src/config/sessions/session-entry-provenance.ts
@@ -1,5 +1,31 @@
import type { HookExternalContentSource } from "../../security/external-content.js";
+/** Kept aligned with SessionStateActorType (src/sessions/session-state-event-kinds.ts); not imported to avoid layering config/sessions onto src/sessions. */
+export type SessionCreatedActor = { type: "human" | "agent" | "system"; id?: string };
+export type SessionCreatedVia =
+ | "operator" // gateway sessions.create (Control UI / operator clients)
+ | "spawn" // sessions_spawn native or ACP subagent spawn
+ | "channel" // inbound channel conversation materialization
+ | "cron"
+ | "talk"
+ | "run" // create-on-run materialization (agent-session-persist)
+ | "plugin" // trusted plugin runtime creation
+ | "internal"; // internal/hidden sessions (internal-session-effects, voice bare rows)
+
+// Return shape mirrors the SessionEntry creation fields as a leaf contract;
+// types.ts imports from here, never the reverse (madge cycle guard).
+export function buildSessionCreationStamp(params: {
+ via: SessionCreatedVia;
+ actor?: SessionCreatedActor;
+ now?: number;
+}): { createdVia: SessionCreatedVia; createdActor?: SessionCreatedActor; createdAt: number } {
+ return {
+ createdVia: params.via,
+ ...(params.actor ? { createdActor: params.actor } : {}),
+ createdAt: params.now ?? Date.now(),
+ };
+}
+
export type SessionEntryProvenance = {
/** Plugin id that owns this session through a trusted runtime creation seam. */
pluginOwnerId?: string;
diff --git a/src/config/sessions/session-transcript-search.test.ts b/src/config/sessions/session-transcript-search.test.ts
index 0264021ef07f..14f161410eee 100644
--- a/src/config/sessions/session-transcript-search.test.ts
+++ b/src/config/sessions/session-transcript-search.test.ts
@@ -74,7 +74,8 @@ function search(query: string, options: { limit?: number; sessionKeys?: string[]
async function waitForSearchReconcile(query: string): Promise {
await vi.waitFor(() => expect(search(query).indexing).toBe(false), {
interval: 10,
- timeout: 5_000,
+ // Compact CI shards can delay the asynchronous index worker beyond five seconds.
+ timeout: 15_000,
});
}
diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts
index 6547d58059f5..a2d705dcb72a 100644
--- a/src/config/sessions/sessions.test.ts
+++ b/src/config/sessions/sessions.test.ts
@@ -19,6 +19,7 @@ import {
import { evaluateSessionFreshness, resolveSessionResetPolicy } from "./reset.js";
import { mergeRestartRecoveryTerminalRunIds } from "./restart-recovery-state.js";
import { loadSessionEntry } from "./session-accessor.js";
+import { buildSessionCreationStamp } from "./session-entry-provenance.js";
import { resolveAndPersistSessionFile } from "./session-file.js";
import { formatSqliteSessionFileMarker } from "./sqlite-marker.js";
import { readSessionStoreCache, writeSessionStoreCache } from "./store-cache.js";
@@ -453,6 +454,28 @@ describe("session store writer queue", () => {
expect(store["agent:main:array"]).toBeUndefined();
});
+ it("round-trips durable session creation and lineage fields", async () => {
+ const key = "agent:main:lineage-roundtrip";
+ const entry: SessionEntry = {
+ sessionId: "lineage-roundtrip-session",
+ updatedAt: 200,
+ createdVia: "operator",
+ createdActor: { type: "human", id: "profile-1" },
+ createdAt: 100,
+ forkSource: {
+ sessionKey: "agent:main:source",
+ sessionId: "source-session",
+ entryId: "source-entry",
+ },
+ previousSessionId: "previous-generation",
+ };
+ const { storePath } = await makeTmpStore();
+
+ await saveSessionStore(storePath, { [key]: entry }, { skipMaintenance: true });
+
+ expect(loadSessionStore(storePath, { skipCache: true })[key]).toEqual(entry);
+ });
+
it("strips malformed pending final-delivery fields on load", async () => {
const { storePath } = await makeTmpStore({
"agent:main:bad-pending": {
@@ -1117,6 +1140,64 @@ describe("session store writer queue", () => {
expect(merged.modelProvider).toBeUndefined();
});
+ it("builds a deterministic session creation stamp", () => {
+ expect(
+ buildSessionCreationStamp({
+ via: "spawn",
+ actor: { type: "agent", id: "agent:main:requester" },
+ now: 123,
+ }),
+ ).toEqual({
+ createdVia: "spawn",
+ createdActor: { type: "agent", id: "agent:main:requester" },
+ createdAt: 123,
+ });
+ });
+
+ it("keeps session creation and fork ancestry fields write-once", () => {
+ const existing: SessionEntry = {
+ sessionId: "session-write-once",
+ updatedAt: 100,
+ createdVia: "channel",
+ createdActor: { type: "human", id: "sender-1" },
+ createdAt: 50,
+ forkSource: { sessionKey: "agent:main:parent", sessionId: "parent-session" },
+ };
+
+ expect(
+ mergeSessionEntry(existing, {
+ createdVia: undefined,
+ createdActor: { type: "system", id: "replacement" },
+ createdAt: 200,
+ forkSource: undefined,
+ }),
+ ).toMatchObject({
+ createdVia: "channel",
+ createdActor: { type: "human", id: "sender-1" },
+ createdAt: 50,
+ forkSource: { sessionKey: "agent:main:parent", sessionId: "parent-session" },
+ });
+ });
+
+ it("fills absent session creation and fork ancestry fields", () => {
+ expect(
+ mergeSessionEntry(
+ { sessionId: "session-fill", updatedAt: 100 },
+ {
+ createdVia: "internal",
+ createdActor: { type: "system" },
+ createdAt: 75,
+ forkSource: { sessionKey: "agent:main:source", sessionId: "source-session" },
+ },
+ ),
+ ).toMatchObject({
+ createdVia: "internal",
+ createdActor: { type: "system" },
+ createdAt: 75,
+ forkSource: { sessionKey: "agent:main:source", sessionId: "source-session" },
+ });
+ });
+
it("rewrites generated sessionFile paths when session id changes", () => {
const previousSessionId = "11111111-1111-4111-8111-111111111111";
const nextSessionId = "22222222-2222-4222-8222-222222222222";
diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts
index 17f8323f4288..c6a9f3953254 100644
--- a/src/config/sessions/types.ts
+++ b/src/config/sessions/types.ts
@@ -7,7 +7,6 @@ import type {
} from "@openclaw/acp-core/types";
import { normalizeOptionalString, type FastMode } from "@openclaw/normalization-core/string-coerce";
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
-import type { SessionCreatorIdentity } from "../../../packages/gateway-protocol/src/schema/sessions.js";
import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-icon.js";
import type { ChatType } from "../../channels/chat-type.js";
import type { ChannelId } from "../../channels/plugins/channel-id.types.js";
@@ -17,7 +16,11 @@ import type { DeliveryContext } from "../../utils/delivery-context.types.js";
import type { TtsAutoMode } from "../types.tts.js";
import type { MainRestartRecoveryState } from "./main-session-recovery.types.js";
import type { SessionRestartRecoveryState } from "./restart-recovery-types.js";
-import type { SessionEntryProvenance } from "./session-entry-provenance.js";
+import type {
+ SessionCreatedActor,
+ SessionCreatedVia,
+ SessionEntryProvenance,
+} from "./session-entry-provenance.js";
import { rewriteSessionFileForNewSessionId } from "./session-file-rotation.js";
import type { AgentPatchedSessionModelFallback } from "./session-model-fallback.js";
@@ -252,8 +255,6 @@ export type SessionEntry = SessionRestartRecoveryState &
/** Durable one-shot prompt additions drained before the next agent turn. */
pluginNextTurnInjections?: Record;
sessionId: string;
- /** Operator identity captured once for this session generation. */
- createdBy?: SessionCreatorIdentity;
updatedAt: number;
/** Opaque owner revision used to reject stale lifecycle mutations. */
lifecycleRevision?: string;
@@ -294,7 +295,17 @@ export type SessionEntry = SessionRestartRecoveryState &
worktree?: { id: string; branch: string; repoRoot: string };
/** Explicit parent session linkage for dashboard-created child sessions. */
parentSessionKey?: string;
- /** True after a thread/topic session has been forked from its parent transcript once. */
+ /** How this session node came to exist; written once and retained across sessionId rotations. */
+ createdVia?: SessionCreatedVia;
+ /** Actor that caused node creation, with an optional profile, session, or sender id; written once. */
+ createdActor?: SessionCreatedActor;
+ /** Node creation time (ms); unlike sessionStartedAt, survives sessionId rotations. */
+ createdAt?: number;
+ /** Exact source generation and optional cut entry for an actual transcript-copy fork. */
+ forkSource?: { sessionKey: string; sessionId: string; entryId?: string };
+ /** Session id of the prior transcript generation under this same session key. */
+ previousSessionId?: string;
+ /** Thread parent-seeding settled marker; also set when seeding is deliberately skipped. */
forkedFromParent?: boolean;
/** Subagent spawn depth (0 = main, 1 = sub-agent, 2 = sub-sub-agent). */
spawnDepth?: number;
@@ -671,6 +682,20 @@ function mergeSessionEntryWithPolicy(
(existing.sessionId === sessionId ? existing.sessionStartedAt : updatedAt),
};
+ // Node creation and exact fork ancestry are write-once; patches may only fill absent values.
+ if (existing.createdVia !== undefined) {
+ next.createdVia = existing.createdVia;
+ }
+ if (existing.createdActor !== undefined) {
+ next.createdActor = existing.createdActor;
+ }
+ if (existing.createdAt !== undefined) {
+ next.createdAt = existing.createdAt;
+ }
+ if (existing.forkSource !== undefined) {
+ next.forkSource = existing.forkSource;
+ }
+
if (existing.sessionId !== sessionId) {
// Session id rotations should move transcript paths when they match known reset/fork shapes.
const patchHasSessionFile = Object.hasOwn(patch, "sessionFile");
diff --git a/src/cron/isolated-agent/run-session-state.test.ts b/src/cron/isolated-agent/run-session-state.test.ts
index 0efbd9c57429..b236412651ca 100644
--- a/src/cron/isolated-agent/run-session-state.test.ts
+++ b/src/cron/isolated-agent/run-session-state.test.ts
@@ -65,6 +65,9 @@ describe("createPersistCronSessionEntry", () => {
lifecycleRevision,
modelProvider: "claude-cli",
model: "claude-opus-4-8",
+ // Node-local lineage on the base row must not leak onto the :run: node.
+ previousSessionId: "base-prior-generation",
+ forkSource: { sessionKey: "agent:main:other", sessionId: "other-generation" },
}),
),
lifecycleRevision,
@@ -87,7 +90,12 @@ describe("createPersistCronSessionEntry", () => {
storePath: cronSession.storePath,
update: expect.any(Function),
});
+ expect(store[runSessionKey]?.previousSessionId).toBeUndefined();
+ expect(store[runSessionKey]?.forkSource).toBeUndefined();
expect(store[runSessionKey]).toMatchObject({
+ createdVia: "cron",
+ createdActor: { type: "system" },
+ createdAt: expect.any(Number),
sessionId: "run-session-id",
modelProvider: "claude-cli",
model: "claude-opus-4-8",
@@ -165,7 +173,8 @@ describe("createPersistCronSessionEntry", () => {
},
}),
);
- const persistSessionEntry = vi.fn(async () => {});
+ const persistedStore: Record = {};
+ const persistSessionEntry = makeGuardedPersistSessionEntry(persistedStore);
const persist = createPersistCronSessionEntry({
cronSession,
@@ -175,12 +184,21 @@ describe("createPersistCronSessionEntry", () => {
await persist();
- expect(cronSession.store["agent:main:cron:job"]).toBe(cronSession.sessionEntry);
+ expect(cronSession.store["agent:main:cron:job"]).toMatchObject({
+ createdVia: "cron",
+ createdActor: { type: "system" },
+ createdAt: expect.any(Number),
+ });
+ expect(persistedStore["agent:main:cron:job"]).toMatchObject({
+ createdVia: "cron",
+ createdActor: { type: "system" },
+ createdAt: expect.any(Number),
+ });
expect(cronSession.store["agent:main:cron:job:run:run-session-id"]).toBeUndefined();
expect(persistSessionEntry).toHaveBeenCalledWith({
storePath: "/tmp/sessions.json",
sessionKey: "agent:main:cron:job",
- fallbackEntry: cronSession.sessionEntry,
+ fallbackEntry: expect.objectContaining({ sessionId: "run-session-id" }),
update: expect.any(Function),
});
});
diff --git a/src/cron/isolated-agent/run-session-state.ts b/src/cron/isolated-agent/run-session-state.ts
index 75543845cff2..79887a610eb1 100644
--- a/src/cron/isolated-agent/run-session-state.ts
+++ b/src/cron/isolated-agent/run-session-state.ts
@@ -3,6 +3,7 @@ import fs from "node:fs";
import { isDeepStrictEqual } from "node:util";
import type { LiveSessionModelSelection } from "../../agents/live-model-switch.js";
import type { SessionEntry } from "../../config/sessions.js";
+import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js";
import { mergeSessionSnapshotChanges } from "../../config/sessions/session-snapshot-merge.js";
import { parseSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js";
import { isCronSessionKey } from "../../sessions/session-key-utils.js";
@@ -112,6 +113,14 @@ export function createPersistCronSessionEntry(params: {
sessionKey: params.agentSessionKey,
fallbackEntry: persistedEntry,
update: (currentEntry) => {
+ if (!currentEntry) {
+ const creationStamp = buildSessionCreationStamp({
+ via: "cron",
+ actor: { type: "system" },
+ });
+ committedEntry = { ...persistedEntry, ...creationStamp };
+ mergedLiveEntry = { ...liveEntry, ...creationStamp };
+ }
const ownsCurrentRevision =
currentEntry?.lifecycleRevision === params.cronSession.lifecycleRevision;
const currentRevisionActive = Boolean(
@@ -189,6 +198,13 @@ export function createCronRunContinuationSession(params: {
entry?.cronRunContinuation?.lifecycleRevision === continuation.lifecycleRevision;
const persist = async (create: boolean, phase: "running" | "ready", basePersisted = false) => {
const source = structuredClone(params.cronSession.sessionEntry);
+ delete source.createdVia;
+ delete source.createdActor;
+ delete source.createdAt;
+ // Node-local lineage must not leak across keys: the base row's generation
+ // chain and fork ancestry describe the cron root, not this :run: node.
+ delete source.previousSessionId;
+ delete source.forkSource;
let persisted = false;
let alreadySealed = false;
await params.persistSessionEntry({
@@ -212,6 +228,9 @@ export function createCronRunContinuationSession(params: {
return {
...current,
...source,
+ ...(!current
+ ? buildSessionCreationStamp({ via: "cron", actor: { type: "system" } })
+ : {}),
...(params.thinkingLevel ? { thinkingLevel: params.thinkingLevel } : {}),
cronRunContinuation: {
...continuation,
diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts
index a71199638228..b599f8e2230a 100644
--- a/src/gateway/method-scopes.test.ts
+++ b/src/gateway/method-scopes.test.ts
@@ -423,7 +423,10 @@ describe("method scope resolution", () => {
["sendPolicy", { key: "agent:main:ios-1", sendPolicy: "deny" }],
["inheritedToolAllow", { key: "agent:main:ios-1", inheritedToolAllow: ["exec"] }],
["inheritedToolPolicyVersion", { key: "agent:main:ios-1", inheritedToolPolicyVersion: 1 }],
- ["spawnedBy", { key: "agent:main:ios-1", spawnedBy: "agent:main:main" }],
+ [
+ "completionOwnerSessionKey",
+ { key: "agent:main:ios-1", completionOwnerSessionKey: "agent:main:main" },
+ ],
["mixed with safe fields", { key: "agent:main:ios-1", label: "x", execHost: "node-1" }],
["unknown fields", { key: "agent:main:ios-1", futureField: true }],
])("keeps sessions.patch admin-only when params include %s", (_name, params) => {
diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts
index 88c64ba4d90f..ddc98ae22a1c 100644
--- a/src/gateway/server-chat.ts
+++ b/src/gateway/server-chat.ts
@@ -15,6 +15,7 @@ import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "../auto-re
import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
import { normalizeAgentPlanSteps } from "../channels/streaming.js";
import { getRuntimeConfig } from "../config/io.js";
+import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js";
import {
type AgentEventPayload,
type AgentEventRuntimePayload,
@@ -550,7 +551,7 @@ export function createAgentEventHandler({
spawnedBy: row?.spawnedBy,
spawnedWorkspaceDir: row?.spawnedWorkspaceDir,
spawnedCwd: row?.spawnedCwd,
- forkedFromParent: row?.forkedFromParent,
+ forkedFromParent: sessionEntryForkedFromParent(row ?? undefined) ? true : undefined,
spawnDepth: row?.spawnDepth,
subagentRole: row?.subagentRole,
subagentControlScope: row?.subagentControlScope,
diff --git a/src/gateway/server-methods/agent-reset-phase.ts b/src/gateway/server-methods/agent-reset-phase.ts
index b5c03a782acc..c708ba46b284 100644
--- a/src/gateway/server-methods/agent-reset-phase.ts
+++ b/src/gateway/server-methods/agent-reset-phase.ts
@@ -21,8 +21,8 @@ import {
resolveBareSessionResetResult,
runSessionResetFromAgent,
} from "./agent-session-reset.js";
-import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
import { emitSessionsChanged } from "./session-change-event.js";
+import { resolveAgentRunSessionCreation } from "./session-creation-provenance.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
export type CommittedResetCompletion = {
@@ -97,7 +97,7 @@ export async function runAgentResetPhase(params: {
? { agentId: params.agentId }
: {}),
reason: resetReason,
- createdBy: gatewayClientSessionCreator(params.client),
+ creation: resolveAgentRunSessionCreation(params.client),
assertCurrent: () => assertAgentRunLifecycleGenerationCurrent(params.lifecycleGeneration),
onCommitted: (commit) => {
params.setCommittedResetCompletion({
diff --git a/src/gateway/server-methods/agent-run-handler.ts b/src/gateway/server-methods/agent-run-handler.ts
index f0a97892ff4a..1ec6de269b77 100644
--- a/src/gateway/server-methods/agent-run-handler.ts
+++ b/src/gateway/server-methods/agent-run-handler.ts
@@ -23,7 +23,7 @@ import { startAgentRunExecution } from "./agent-run-execution-phase.js";
import { buildAgentSessionPatch } from "./agent-session-patch.js";
import { persistAgentSessionPhase } from "./agent-session-persist.js";
import { prepareAgentSession } from "./agent-session-prepare.js";
-import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
+import { resolveAgentRunSessionCreation } from "./session-creation-provenance.js";
import type { GatewayRequestHandlers } from "./types.js";
export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({
@@ -298,7 +298,6 @@ export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({
freshEntry === undefined
? normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId)
: undefined,
- createdBy: gatewayClientSessionCreator(client),
expectedExistingSessionId,
hasRestoredCronContinuation: restoredCronContinuationIdentity !== undefined,
resetPolicy,
@@ -338,6 +337,7 @@ export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({
canonicalSessionKey,
sessionAgentId,
mainSessionKey,
+ creation: resolveAgentRunSessionCreation(client),
lifecycleGeneration,
isRestartRecoveryResumeRun,
runId,
diff --git a/src/gateway/server-methods/agent-session-patch.test.ts b/src/gateway/server-methods/agent-session-patch.test.ts
index a1ed06abc0c2..1c8c2a0d5d44 100644
--- a/src/gateway/server-methods/agent-session-patch.test.ts
+++ b/src/gateway/server-methods/agent-session-patch.test.ts
@@ -31,59 +31,6 @@ function buildPatch(touchInteraction: boolean) {
}
describe("agent session patch", () => {
- it("stamps a creator only when minting a new session", () => {
- const patch = buildAgentSessionPatch({
- freshEntry: undefined,
- initialEntry: undefined,
- cfg: {},
- sessionAgentId: "main",
- canonicalSessionKey: "agent:main:new",
- storePath: "/tmp/openclaw-agent-creator-test.json",
- normalizedSpawned: {},
- requestDeliveryHint: undefined,
- createdBy: { id: "profile-ada", label: "Ada" },
- hasRestoredCronContinuation: false,
- resetPolicy: resolveSessionResetPolicy({ resetType: "direct" }),
- now: 1_000,
- isSystemGatewayRun: false,
- visibleRequest: true,
- fallbackSessionId: "new-session",
- touchInteraction: true,
- failedSessionTranscriptMissing: () => false,
- }).patch;
-
- expect(patch.createdBy).toEqual({ id: "profile-ada", label: "Ada" });
- });
-
- it("clears a previous creator on an ownerless implicit rotation", () => {
- const entry: SessionEntry = {
- createdBy: { id: "profile-ada", label: "Ada" },
- sessionId: "old-session",
- updatedAt: 1,
- };
- const patch = buildAgentSessionPatch({
- freshEntry: entry,
- initialEntry: entry,
- cfg: {},
- sessionAgentId: "main",
- canonicalSessionKey: "agent:main:main",
- storePath: "/tmp/openclaw-agent-creator-rotation.json",
- normalizedSpawned: {},
- requestDeliveryHint: undefined,
- hasRestoredCronContinuation: false,
- resetPolicy: resolveSessionResetPolicy({ resetType: "direct" }),
- now: 2,
- isSystemGatewayRun: false,
- visibleRequest: true,
- fallbackSessionId: "new-session",
- touchInteraction: true,
- failedSessionTranscriptMissing: () => true,
- }).patch;
-
- expect(Object.hasOwn(patch, "createdBy")).toBe(true);
- expect(patch.createdBy).toBeUndefined();
- });
-
it("clears agent status at the next human interaction boundary", () => {
const patch = buildPatch(true);
expect(Object.hasOwn(patch, "agentStatus")).toBe(true);
diff --git a/src/gateway/server-methods/agent-session-patch.ts b/src/gateway/server-methods/agent-session-patch.ts
index 7ba3de5c0fd5..f828dd80d6bb 100644
--- a/src/gateway/server-methods/agent-session-patch.ts
+++ b/src/gateway/server-methods/agent-session-patch.ts
@@ -50,7 +50,6 @@ export function buildAgentSessionPatch(params: {
requestLabel?: string;
recipientChannel?: string;
pluginOwnerId?: string;
- createdBy?: SessionEntry["createdBy"];
expectedExistingSessionId?: string;
hasRestoredCronContinuation: boolean;
resetPolicy: ReturnType;
@@ -210,9 +209,6 @@ export function buildAgentSessionPatch(params: {
sessionId: patchSessionId,
updatedAt: params.now,
...(freshIsNewSession && !freshSessionRotatedSinceLoad ? { sessionStartedAt: params.now } : {}),
- ...(freshIsNewSession && !freshSessionRotatedSinceLoad
- ? { createdBy: params.createdBy ? { ...params.createdBy } : undefined }
- : {}),
...(params.touchInteraction
? {
lastInteractionAt: params.now,
diff --git a/src/gateway/server-methods/agent-session-persist.ts b/src/gateway/server-methods/agent-session-persist.ts
index e7d1a64a3722..9e53ff7646fd 100644
--- a/src/gateway/server-methods/agent-session-persist.ts
+++ b/src/gateway/server-methods/agent-session-persist.ts
@@ -18,9 +18,11 @@ import {
patchSessionEntryTarget,
type SessionEntryPatchOptions,
} from "../../config/sessions/session-accessor.js";
+import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { assertAgentRunLifecycleGenerationCurrent } from "../../infra/agent-events.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
+import { recordSessionCreated } from "../../sessions/session-state-events.js";
import { getGeneratedMediaTaskIdsForSessionKey } from "../../tasks/task-status-access.js";
import { formatForLog } from "../ws-log.js";
import {
@@ -35,6 +37,7 @@ import {
} from "./agent-handler-helpers.js";
import type { AgentRunRequest } from "./agent-request-types.js";
import type { AgentSessionPatchBuild } from "./agent-session-patch.js";
+import type { TrustedSessionCreation } from "./session-creation-provenance.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
export type CronContinuationClaim = {
@@ -75,6 +78,7 @@ export async function persistAgentSessionPhase(params: {
canonicalSessionKey: string;
sessionAgentId: string;
mainSessionKey: string;
+ creation: TrustedSessionCreation;
lifecycleGeneration: string;
isRestartRecoveryResumeRun: boolean;
runId: string;
@@ -117,6 +121,7 @@ export async function persistAgentSessionPhase(params: {
let restoredCronContinuation: RestoredCronContinuation | undefined;
let mainRestartRecoveryOwnerLease: MainSessionRecoveryOwnerLease | undefined;
let skipAgentInitialSessionTouch = false;
+ let createdNewEntry = false;
const recoveredSessionStartedAt =
!patchBuild.isNewSession &&
params.entry !== undefined &&
@@ -246,12 +251,25 @@ export async function persistAgentSessionPhase(params: {
});
}
patchBuild = params.buildSessionPatch(entryForPatch);
- const effectivePatch =
+ const lifecyclePatch =
recoveredSessionStartedAt !== undefined &&
entryForPatch?.sessionStartedAt === undefined &&
entryForPatch?.sessionId === params.entry?.sessionId
? { ...patchBuild.patch, sessionStartedAt: recoveredSessionStartedAt }
: patchBuild.patch;
+ const previousSessionId = normalizeOptionalString(freshEntry?.sessionId);
+ const nextSessionId = normalizeOptionalString(lifecyclePatch.sessionId);
+ const rotationLineage =
+ previousSessionId && nextSessionId && previousSessionId !== nextSessionId
+ ? { previousSessionId }
+ : {};
+ const effectivePatch = freshEntry
+ ? { ...lifecyclePatch, ...rotationLineage }
+ : {
+ ...lifecyclePatch,
+ ...buildSessionCreationStamp(params.creation),
+ };
+ createdNewEntry = freshEntry === undefined;
const merged = withSqliteSessionFileMarker({
agentId: params.sessionAgentId,
entry: mergeSessionEntry(entryForPatch, effectivePatch),
@@ -416,6 +434,13 @@ export async function persistAgentSessionPhase(params: {
const rotatedSessionId = patchBuild.rotatedSessionId;
const usableRequestedSessionId = patchBuild.usableRequestedSessionId;
const freshness = patchBuild.freshness;
+ if (createdNewEntry && sessionEntry) {
+ recordSessionCreated({
+ sessionKey: params.canonicalSessionKey,
+ agentId: params.sessionAgentId,
+ entry: sessionEntry,
+ });
+ }
if (isNewSession && params.entry?.sessionId && resolvedSessionId !== params.entry.sessionId) {
supersededSessionId = params.entry.sessionId;
}
diff --git a/src/gateway/server-methods/agent-session-reset.ts b/src/gateway/server-methods/agent-session-reset.ts
index 1c8a67694b48..9577ff0e22e1 100644
--- a/src/gateway/server-methods/agent-session-reset.ts
+++ b/src/gateway/server-methods/agent-session-reset.ts
@@ -1,5 +1,4 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
-import type { SessionCreatorIdentity } from "../../../packages/gateway-protocol/src/index.js";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import type { AgentCommandOpts } from "../../agents/command/types.js";
import { agentCommandFromIngress } from "../../commands/agent.js";
@@ -14,13 +13,14 @@ import { defaultRuntime } from "../../runtime.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { performGatewaySessionReset } from "../session-reset-service.js";
import { loadSessionEntry } from "../session-utils.js";
+import type { TrustedSessionCreation } from "./session-creation-provenance.js";
import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js";
export async function runSessionResetFromAgent(params: {
key: string;
agentId?: string;
reason: "new" | "reset";
- createdBy?: SessionCreatorIdentity;
+ creation: TrustedSessionCreation;
assertCurrent?: () => void;
onCommitted?: (commit: { key: string; sessionId: string }) => void;
}) {
@@ -29,7 +29,7 @@ export async function runSessionResetFromAgent(params: {
...(params.agentId ? { agentId: params.agentId } : {}),
reason: params.reason,
commandSource: "gateway:agent",
- createdBy: params.createdBy,
+ creation: params.creation,
assertCurrent: params.assertCurrent,
onCommitted: params.onCommitted,
});
diff --git a/src/gateway/server-methods/chat-send-user-turn.test.ts b/src/gateway/server-methods/chat-send-user-turn.test.ts
index 1ee695781e7b..39e5d2b7c2b0 100644
--- a/src/gateway/server-methods/chat-send-user-turn.test.ts
+++ b/src/gateway/server-methods/chat-send-user-turn.test.ts
@@ -163,6 +163,12 @@ describe("prepareChatSendUserTurn", () => {
}),
client: {
connId: "conn-1",
+ authenticatedUserProfile: {
+ profileId: "profile-ada",
+ displayName: "Ada",
+ hasAvatar: false,
+ updatedAt: 1,
+ },
connect: {
device: { id: "device-1" },
scopes: ["operator.admin"],
@@ -190,6 +196,10 @@ describe("prepareChatSendUserTurn", () => {
MediaStaged: true,
GatewayClientScopes: ["operator.admin"],
GatewayClientCaps: ["tool-events"],
+ SessionCreation: {
+ via: "operator",
+ actor: { type: "human", id: "profile-ada" },
+ },
});
expect(prepared.ctx).not.toHaveProperty("SenderId");
expect(prepared.queuedFollowupOwnerKey).toBe("device:device-1");
diff --git a/src/gateway/server-methods/chat-send-user-turn.ts b/src/gateway/server-methods/chat-send-user-turn.ts
index af26276e4d4c..74d421e037f2 100644
--- a/src/gateway/server-methods/chat-send-user-turn.ts
+++ b/src/gateway/server-methods/chat-send-user-turn.ts
@@ -17,7 +17,7 @@ import type { prepareChatSendAttachments } from "./chat-send-attachments.js";
import type { NormalizedChatSendRequest } from "./chat-send-request.js";
import type { PreparedChatSendSession } from "./chat-send-session.js";
import { normalizeOptionalChatText } from "./chat-text-normalization.js";
-import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
+import { resolveOperatorSessionCreation } from "./session-creation-provenance.js";
import type { GatewayRequestContext, GatewayRequestHandlerOptions } from "./types.js";
type PreparedChatSendAttachments = Extract<
@@ -180,9 +180,7 @@ function buildChatSendMessageContext(params: {
body: commandBody,
},
MessageSid: params.clientRunId,
- ...(gatewayClientSessionCreator(params.client)
- ? { SessionCreator: gatewayClientSessionCreator(params.client) }
- : {}),
+ SessionCreation: resolveOperatorSessionCreation(params.client),
ApprovalReviewerDeviceId: queuedFollowupOwnerDeviceId,
...(!isOperatorUiClient(params.clientInfo)
? {
diff --git a/src/gateway/server-methods/gateway-client-identity.ts b/src/gateway/server-methods/gateway-client-identity.ts
index 35747ef5241c..85f7028a683a 100644
--- a/src/gateway/server-methods/gateway-client-identity.ts
+++ b/src/gateway/server-methods/gateway-client-identity.ts
@@ -17,8 +17,3 @@ export function gatewayClientSenderFields(client: GatewayClient | null): {
}
return client?.authenticatedUserId ? { sender: { id: client.authenticatedUserId } } : {};
}
-
-/** Returns the trusted creator identity captured during connection admission. */
-export function gatewayClientSessionCreator(client: GatewayClient | null) {
- return client?.operatorIdentity ? { ...client.operatorIdentity } : undefined;
-}
diff --git a/src/gateway/server-methods/session-catalog.test.ts b/src/gateway/server-methods/session-catalog.test.ts
index 91f5234cc7ca..c871b1203023 100644
--- a/src/gateway/server-methods/session-catalog.test.ts
+++ b/src/gateway/server-methods/session-catalog.test.ts
@@ -4,15 +4,15 @@ import { gatewaySubagentState } from "../../plugins/runtime/gateway-bindings.js"
import { createPluginRuntime } from "../../plugins/runtime/index.js";
import type { SessionCatalogProvider } from "../../plugins/session-catalog.js";
-type CatalogSessionEntryLoader = (
+type CatalogSessionRowLoader = (
sessionKey: string,
options?: { agentId?: string; clone?: boolean },
-) => { entry: { createdBy?: { id: string; label?: string } } | undefined };
+) => { createdActor?: { type: "human" | "agent" | "system"; id?: string; label?: string } } | null;
const hoisted = vi.hoisted(() => ({
activeRegistry: { sessionCatalogs: [] as unknown[] },
pinnedSessionExtensionRegistry: undefined as { sessionCatalogs: unknown[] } | undefined,
- loadSessionEntryReadOnly: vi.fn(() => ({ entry: undefined })),
+ loadGatewaySessionRow: vi.fn(() => null),
recordSessionStateEvent: vi.fn(),
upsertSessionUpstreamLink: vi.fn(),
}));
@@ -41,7 +41,7 @@ vi.mock("../../plugins/session-conversation-binding.js", () => ({
}));
vi.mock("../session-utils.js", async (importOriginal) => {
const actual = await importOriginal();
- return { ...actual, loadSessionEntryReadOnly: hoisted.loadSessionEntryReadOnly };
+ return { ...actual, loadGatewaySessionRow: hoisted.loadGatewaySessionRow };
});
const { resolveSessionCatalogCreateTarget, sessionCatalogHandlers } =
@@ -81,8 +81,8 @@ describe("session catalog Gateway methods", () => {
beforeEach(() => {
hoisted.activeRegistry.sessionCatalogs = [];
hoisted.pinnedSessionExtensionRegistry = undefined;
- hoisted.loadSessionEntryReadOnly.mockReset();
- hoisted.loadSessionEntryReadOnly.mockReturnValue({ entry: undefined });
+ hoisted.loadGatewaySessionRow.mockReset();
+ hoisted.loadGatewaySessionRow.mockReturnValue(null);
hoisted.recordSessionStateEvent.mockClear();
hoisted.upsertSessionUpstreamLink.mockClear();
conversationBindingMocks.bindPluginSessionConversation.mockClear();
@@ -169,7 +169,7 @@ describe("session catalog Gateway methods", () => {
status: "stored",
archived: false,
sessionKey: "agent:main:owned",
- createdBy: { id: "provider-spoof" },
+ createdActor: { type: "human" as const, id: "provider-spoof" },
canContinue: true,
canArchive: false,
},
@@ -178,7 +178,7 @@ describe("session catalog Gateway methods", () => {
status: "stored",
archived: false,
sessionKey: "agent:main:missing",
- createdBy: { id: "provider-spoof" },
+ createdActor: { type: "human" as const, id: "provider-spoof" },
canContinue: true,
canArchive: false,
},
@@ -186,18 +186,17 @@ describe("session catalog Gateway methods", () => {
threadId: "external-thread",
status: "stored",
archived: false,
- createdBy: { id: "provider-spoof" },
+ createdActor: { type: "human" as const, id: "provider-spoof" },
canContinue: true,
canArchive: false,
},
],
};
- hoisted.loadSessionEntryReadOnly.mockImplementation((sessionKey: string) => ({
- entry:
- sessionKey === "agent:main:owned"
- ? { createdBy: { id: "profile-ada", label: "Ada" } }
- : undefined,
- }));
+ hoisted.loadGatewaySessionRow.mockImplementation((sessionKey: string) =>
+ sessionKey === "agent:main:owned"
+ ? { createdActor: { type: "human", id: "profile-ada", label: "Ada" } }
+ : null,
+ );
hoisted.activeRegistry.sessionCatalogs = [
{
provider: provider("claude", {
@@ -219,10 +218,10 @@ describe("session catalog Gateway methods", () => {
const projectedSessions = [
expect.objectContaining({
threadId: "owned-thread",
- createdBy: { id: "profile-ada", label: "Ada" },
+ createdActor: { type: "human", id: "profile-ada", label: "Ada" },
}),
- expect.not.objectContaining({ createdBy: expect.anything() }),
- expect.not.objectContaining({ createdBy: expect.anything() }),
+ expect.not.objectContaining({ createdActor: expect.anything() }),
+ expect.not.objectContaining({ createdActor: expect.anything() }),
];
expect(broadcastToConnIds).toHaveBeenCalledWith(
@@ -242,10 +241,10 @@ describe("session catalog Gateway methods", () => {
}),
],
});
- expect(hoisted.loadSessionEntryReadOnly).toHaveBeenCalledWith("agent:main:owned", {
+ expect(hoisted.loadGatewaySessionRow).toHaveBeenCalledWith("agent:main:owned", {
agentId: "main",
});
- expect(hoisted.loadSessionEntryReadOnly).toHaveBeenCalledTimes(2);
+ expect(hoisted.loadGatewaySessionRow).toHaveBeenCalledTimes(2);
});
it("uses the pinned Gateway catalog runtime after active registry churn", async () => {
diff --git a/src/gateway/server-methods/session-catalog.ts b/src/gateway/server-methods/session-catalog.ts
index bd2f50f03451..763be985dfff 100644
--- a/src/gateway/server-methods/session-catalog.ts
+++ b/src/gateway/server-methods/session-catalog.ts
@@ -24,7 +24,7 @@ import { bindPluginSessionConversation } from "../../plugins/session-conversatio
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { recordSessionStateEvent } from "../../sessions/session-state-events.js";
import { upsertSessionUpstreamLink } from "../../sessions/session-upstream-links.js";
-import { loadSessionEntryReadOnly } from "../session-utils.js";
+import { loadGatewaySessionRow } from "../session-utils.js";
import { resolveAgentIdOrRespondError } from "./agent-id-shared.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
@@ -158,28 +158,28 @@ function catalogResult(
return result;
}
-function projectCatalogHostCreators(
+function projectCatalogHostCreatedActors(
host: SessionCatalogHost,
agentId: string,
- creatorBySessionKey: Map,
+ actorBySessionKey: Map,
): SessionCatalogHost {
return {
...host,
- sessions: host.sessions.map(({ createdBy: _providerCreatedBy, ...session }) => {
+ sessions: host.sessions.map(({ createdActor: _providerCreatedActor, ...session }) => {
// Catalog providers do not own creator identity; the persisted session entry does.
const sessionKey = session.sessionKey;
- let createdBy: SessionCatalogSession["createdBy"];
- if (sessionKey && creatorBySessionKey.has(sessionKey)) {
- createdBy = creatorBySessionKey.get(sessionKey);
+ let createdActor: SessionCatalogSession["createdActor"];
+ if (sessionKey && actorBySessionKey.has(sessionKey)) {
+ createdActor = actorBySessionKey.get(sessionKey);
} else {
- createdBy = sessionKey
- ? loadSessionEntryReadOnly(sessionKey, { agentId }).entry?.createdBy
+ createdActor = sessionKey
+ ? loadGatewaySessionRow(sessionKey, { agentId })?.createdActor
: undefined;
if (sessionKey) {
- creatorBySessionKey.set(sessionKey, createdBy);
+ actorBySessionKey.set(sessionKey, createdActor);
}
}
- return createdBy ? { ...session, createdBy: { ...createdBy } } : session;
+ return createdActor ? { ...session, createdActor: { ...createdActor } } : session;
}),
};
}
@@ -228,7 +228,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
const search = normalizeSessionCatalogSearch(request.search);
const progressId = request.progressId;
const progressConnId = progressId && client?.connId ? client.connId : undefined;
- const creatorBySessionKey = new Map();
+ const actorBySessionKey = new Map();
const catalogList = await Promise.all(
selected.map(async (provider): Promise => {
const createTarget = resolveProviderCreateTarget(provider, resolvedAgent.agentId);
@@ -244,7 +244,13 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
agentId: resolvedAgent.agentId,
catalog: catalogResult(
provider,
- [projectCatalogHostCreators(host, resolvedAgent.agentId, creatorBySessionKey)],
+ [
+ projectCatalogHostCreatedActors(
+ host,
+ resolvedAgent.agentId,
+ actorBySessionKey,
+ ),
+ ],
undefined,
createSession,
),
@@ -265,7 +271,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
return catalogResult(
provider,
hosts.map((host) =>
- projectCatalogHostCreators(host, resolvedAgent.agentId, creatorBySessionKey),
+ projectCatalogHostCreatedActors(host, resolvedAgent.agentId, actorBySessionKey),
),
undefined,
createSession,
diff --git a/src/gateway/server-methods/session-creation-provenance.test.ts b/src/gateway/server-methods/session-creation-provenance.test.ts
new file mode 100644
index 000000000000..cc8dbf777563
--- /dev/null
+++ b/src/gateway/server-methods/session-creation-provenance.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vitest";
+import { resolveAgentRunSessionCreation } from "./session-creation-provenance.js";
+
+describe("agent run session creation provenance", () => {
+ it("uses a proven Gateway profile id", () => {
+ expect(
+ resolveAgentRunSessionCreation({
+ authenticatedUserProfile: { profileId: "profile-ada" },
+ }),
+ ).toEqual({ via: "run", actor: { type: "human", id: "profile-ada" } });
+ });
+
+ it("does not infer an actor for a profile-less wire client", () => {
+ expect(resolveAgentRunSessionCreation({})).toEqual({ via: "run" });
+ });
+});
diff --git a/src/gateway/server-methods/session-creation-provenance.ts b/src/gateway/server-methods/session-creation-provenance.ts
new file mode 100644
index 000000000000..a58066bc5748
--- /dev/null
+++ b/src/gateway/server-methods/session-creation-provenance.ts
@@ -0,0 +1,42 @@
+import type {
+ SessionCreatedActor,
+ SessionCreatedVia,
+} from "../../config/sessions/session-entry-provenance.js";
+
+export type TrustedSessionCreation = {
+ via: SessionCreatedVia;
+ actor?: SessionCreatedActor;
+};
+
+/**
+ * Structural subset of GatewayClient; a leaf contract so shared-types.ts can
+ * import TrustedSessionCreation without a type cycle back through this module.
+ */
+type SessionCreationClient = {
+ authenticatedUserProfile?: { profileId?: string } | null;
+ internal?: { syntheticClient?: true; sessionCreation?: TrustedSessionCreation };
+};
+
+export function resolveOperatorSessionCreation(
+ client: SessionCreationClient | null | undefined,
+ options: { allowTrustedHint?: boolean } = {},
+): TrustedSessionCreation {
+ if (options.allowTrustedHint && client?.internal?.sessionCreation) {
+ return client.internal.sessionCreation;
+ }
+ const profileId = client?.authenticatedUserProfile?.profileId;
+ // Actor only when proven: a profile-less wire connection may be an agent-tool
+ // client on a remote topology, so claiming a human actor would misattribute
+ // agent-caused creations. Absent actor means unknown, never inferred.
+ return {
+ via: "operator",
+ ...(profileId ? { actor: { type: "human" as const, id: profileId } } : {}),
+ };
+}
+
+export function resolveAgentRunSessionCreation(
+ client: SessionCreationClient | null | undefined,
+): TrustedSessionCreation {
+ const actor = resolveOperatorSessionCreation(client).actor;
+ return { via: "run", ...(actor ? { actor } : {}) };
+}
diff --git a/src/gateway/server-methods/sessions-create.ts b/src/gateway/server-methods/sessions-create.ts
index cec4042ab5a7..1b57ae8961cc 100644
--- a/src/gateway/server-methods/sessions-create.ts
+++ b/src/gateway/server-methods/sessions-create.ts
@@ -14,6 +14,7 @@ import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status
import { insideGitCheckout } from "../../agents/worktrees/git.js";
import { managedWorktrees } from "../../agents/worktrees/service.js";
import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js";
+import { sessionEntryForkedFromParent } from "../../config/sessions/session-entry-lineage.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { isPathInside } from "../../infra/path-guards.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
@@ -28,13 +29,13 @@ import { resolveSessionStoreAgentId } from "../session-store-key.js";
import { readSessionMessageCountAsync } from "../session-transcript-readers.js";
import { loadSessionEntryReadOnly, resolveGatewaySessionStoreTarget } from "../session-utils.js";
import { chatHandlers } from "./chat.js";
-import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
import { resolveSessionCatalogCreateTarget } from "./session-catalog.js";
import { emitSessionsChanged } from "./session-change-event.js";
import {
resolveSessionCreateInitialTurn,
shouldAttachPendingMessageSeq,
} from "./session-create-initial-turn.js";
+import { resolveOperatorSessionCreation } from "./session-creation-provenance.js";
import { sessionLog } from "./sessions-shared.js";
import type { GatewayRequestHandlers } from "./types.js";
import { assertValidParams } from "./validation.js";
@@ -329,7 +330,6 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
).allowed;
const created = await createGatewaySession({
cfg,
- createdBy: gatewayClientSessionCreator(client),
key: sessionKey,
agentId: sessionAgentId,
label: p.label,
@@ -356,6 +356,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
emitCommandHooks: p.emitCommandHooks,
resetMainWhenUnspecified: !hasInitialTurn,
commandSource: "webchat",
+ creation: resolveOperatorSessionCreation(client, { allowTrustedHint: true }),
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
afterCreate: hasInitialTurn
? async ({ key, agentId, entry, storePath }) => {
@@ -432,6 +433,9 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
branch: sessionWorktree.branch,
}
: undefined;
+ const responseEntry = sessionEntryForkedFromParent(created.entry)
+ ? { ...created.entry, forkedFromParent: true as const }
+ : created.entry;
if (created.resetExisting) {
respond(
true,
@@ -439,7 +443,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
ok: true,
key: created.key,
sessionId: created.entry.sessionId,
- entry: created.entry,
+ entry: responseEntry,
resolved: created.resolved,
runStarted: false,
...(createdWorktree ? { worktree: createdWorktree } : {}),
@@ -467,7 +471,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
ok: true,
key: created.key,
sessionId: created.entry.sessionId,
- entry: created.entry,
+ entry: responseEntry,
runStarted,
...(runPayload ? runPayload : {}),
...(runStarted && typeof messageSeq === "number" ? { messageSeq } : {}),
diff --git a/src/gateway/server-methods/sessions-mutations.ts b/src/gateway/server-methods/sessions-mutations.ts
index 139fca10d60d..3571a7c80321 100644
--- a/src/gateway/server-methods/sessions-mutations.ts
+++ b/src/gateway/server-methods/sessions-mutations.ts
@@ -34,9 +34,9 @@ import {
type SessionsPatchResult,
} from "../session-utils.js";
import { projectSessionsPatchEntry } from "../sessions-patch.js";
-import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
import { hasVisibleActiveSessionRun } from "./session-active-runs.js";
import { emitSessionsChanged } from "./session-change-event.js";
+import { resolveOperatorSessionCreation } from "./session-creation-provenance.js";
import {
isAgentMainSessionKey,
loadSessionsRuntimeModule,
@@ -400,7 +400,7 @@ export const sessionMutationHandlers: GatewayRequestHandlers = {
...(p.agentId ? { agentId: p.agentId } : {}),
reason,
commandSource: "gateway:sessions.reset",
- createdBy: gatewayClientSessionCreator(client),
+ creation: resolveOperatorSessionCreation(client),
});
if (!result.ok) {
respond(false, undefined, result.error);
diff --git a/src/gateway/server-methods/sessions-rewind.test.ts b/src/gateway/server-methods/sessions-rewind.test.ts
index 376bcbb893d7..25605279d055 100644
--- a/src/gateway/server-methods/sessions-rewind.test.ts
+++ b/src/gateway/server-methods/sessions-rewind.test.ts
@@ -58,9 +58,12 @@ import {
appendTranscriptEvent,
appendTranscriptMessage,
listSessionEntries,
+ loadSessionEntry,
upsertSessionEntry,
} from "../../config/sessions/session-accessor.js";
+import { listSessionStateEventsSince } from "../../sessions/session-state-events.js";
import { sessionsHandlers } from "./sessions.js";
+import type { GatewayClient } from "./types.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const sessionKey = "agent:main:rewind-handler";
@@ -140,8 +143,12 @@ type MessageCutMethod =
| "sessions.fork"
| "sessions.rewind";
-async function invoke(method: MessageCutMethod, entryId?: string) {
- const respond = vi.fn() as unknown as RespondFn;
+async function invoke(
+ method: MessageCutMethod,
+ entryId?: string,
+ client: GatewayClient | null = null,
+) {
+ const respond = vi.fn();
await expectDefined(
sessionsHandlers[method],
`${method} handler`,
@@ -155,9 +162,9 @@ async function invoke(method: MessageCutMethod, entryId?: string) {
? {}
: { entryId }),
},
- respond,
+ respond: respond as unknown as RespondFn,
context: context(),
- client: null,
+ client,
isWebchatConnect: () => false,
});
return respond;
@@ -209,12 +216,36 @@ describe("session message-cut methods", () => {
});
it("returns editor text for rewind and a new key for fork", async () => {
- const fork = await invoke("sessions.fork", "user-entry");
+ const profileId = "profile-fork-creator";
+ const fork = await invoke("sessions.fork", "user-entry", {
+ connect: { scopes: ["operator.write"] },
+ authenticatedUserProfile: {
+ profileId,
+ displayName: "Fork Operator",
+ hasAvatar: false,
+ updatedAt: 1,
+ },
+ } as GatewayClient);
expect(fork).toHaveBeenCalledWith(
true,
expect.objectContaining({ editorText: "edit me", sessionKey: expect.any(String) }),
undefined,
);
+ const forkKey = (fork.mock.calls[0]?.[1] as { sessionKey?: string } | undefined)?.sessionKey;
+ expect(forkKey).toBeTruthy();
+ const forkEntry = loadSessionEntry({ agentId: "main", sessionKey: forkKey ?? "" });
+ expect(forkEntry).toMatchObject({
+ createdVia: "operator",
+ createdActor: { type: "human", id: profileId },
+ createdAt: expect.any(Number),
+ });
+ expect(listSessionStateEventsSince(forkKey ?? "", "main", 0, 20).events).toContainEqual(
+ expect.objectContaining({
+ kind: "created",
+ actorType: "human",
+ actorId: profileId,
+ }),
+ );
const rewind = await invoke("sessions.rewind", "user-entry");
expect(rewind).toHaveBeenCalledWith(true, { editorText: "edit me" }, undefined);
diff --git a/src/gateway/server-methods/sessions-rewind.ts b/src/gateway/server-methods/sessions-rewind.ts
index 643ebd0cb650..d8b8d37eec48 100644
--- a/src/gateway/server-methods/sessions-rewind.ts
+++ b/src/gateway/server-methods/sessions-rewind.ts
@@ -22,6 +22,7 @@ import {
isCompetingSessionWorkAdmissionActive,
runExclusiveSessionLifecycleMutation,
} from "../../sessions/session-lifecycle-admission.js";
+import { recordSessionCreated } from "../../sessions/session-state-events.js";
import {
readSessionUpstreamLink,
type SessionUpstreamLink,
@@ -33,6 +34,7 @@ import {
import { asWorkerInferenceControl } from "../worker-environments/inference-control.js";
import { hasVisibleActiveSessionRun } from "./session-active-runs.js";
import { emitSessionsChanged } from "./session-change-event.js";
+import { resolveOperatorSessionCreation } from "./session-creation-provenance.js";
import {
loadAccessorSessionEntryForGatewayTarget,
resolveSessionWorkerPlacementMutationError,
@@ -155,7 +157,7 @@ async function mutateSessionAtMessage(
options: GatewayRequestHandlerOptions,
action: MessageCutAction,
): Promise {
- const { params, respond, context } = options;
+ const { params, respond, context, client } = options;
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
const entryId =
action === "switch"
@@ -385,6 +387,7 @@ async function mutateSessionAtMessage(
sessionStoreKey: current.sessionStoreKey,
storePath: current.storePath,
targetKey,
+ creation: resolveOperatorSessionCreation(client),
})
: action === "rewind"
? rewindSessionToMessage({
@@ -415,6 +418,12 @@ async function mutateSessionAtMessage(
}
if (action !== "fork") {
clearSessionQueues(lifecycleIdentities);
+ } else {
+ recordSessionCreated({
+ sessionKey: result.key,
+ agentId: current.target.agentId,
+ entry: result.entry,
+ });
}
respond(
true,
diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts
index 5cc7245267c1..af8275173128 100644
--- a/src/gateway/server-methods/shared-types.ts
+++ b/src/gateway/server-methods/shared-types.ts
@@ -1,5 +1,4 @@
import type {
- SessionCreatorIdentity,
SessionApprovalReplay,
SystemAgentChatQuestion,
} from "../../../packages/gateway-protocol/src/index.js";
@@ -58,6 +57,7 @@ import type {
WorkerEnvironmentServiceContract,
WorkerPlacementDispatchContract,
} from "../worker-environments/service-contract.js";
+import type { TrustedSessionCreation } from "./session-creation-provenance.js";
/**
* Shared gateway request types used by every server-method module.
@@ -78,8 +78,6 @@ export type GatewayClient = {
hasAvatar: boolean;
updatedAt: number;
};
- /** Trusted operator identity resolved once during connection admission. */
- operatorIdentity?: SessionCreatorIdentity;
pluginSurfaceUrls?: Record;
pluginNodeCapabilitySurfaces?: Record;
pluginNodeCapabilities?: Record;
@@ -89,6 +87,10 @@ export type GatewayClient = {
/** Signed shared-auth session admitted only to approve its own upgrade pairing. */
isControlUiDeviceAuthMigration?: boolean;
internal?: {
+ /** Marks the server-constructed client used by trusted in-process dispatch. */
+ syntheticClient?: true;
+ /** Trusted session creation provenance; never accepted from Gateway wire params. */
+ sessionCreation?: TrustedSessionCreation;
allowModelOverride?: boolean;
approvalRuntime?: boolean;
cronRunContinuation?: boolean;
diff --git a/src/gateway/server-plugin-runtime-client.ts b/src/gateway/server-plugin-runtime-client.ts
index a2b2b2619291..75242b19b6b5 100644
--- a/src/gateway/server-plugin-runtime-client.ts
+++ b/src/gateway/server-plugin-runtime-client.ts
@@ -10,6 +10,7 @@ import { normalizeToolName } from "../agents/tool-policy.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import type { RuntimePluginToolGrant } from "../plugins/runtime/tool-grant.js";
import { APPROVALS_SCOPE, WRITE_SCOPE } from "./method-scopes.js";
+import type { TrustedSessionCreation } from "./server-methods/session-creation-provenance.js";
import type { GatewayRequestOptions } from "./server-methods/types.js";
export function createSyntheticPluginRuntimeClient(params?: {
@@ -21,6 +22,7 @@ export function createSyntheticPluginRuntimeClient(params?: {
pluginRuntimeOwnerId?: string;
runtimePluginToolGrant?: RuntimePluginToolGrant;
delegatedToolPolicyHandoff?: boolean;
+ sessionCreation?: TrustedSessionCreation;
scopes?: string[];
}): NonNullable {
const pluginRuntimeOwnerId =
@@ -41,6 +43,8 @@ export function createSyntheticPluginRuntimeClient(params?: {
scopes: params?.scopes ?? [WRITE_SCOPE],
},
internal: {
+ syntheticClient: true,
+ ...(params?.sessionCreation ? { sessionCreation: params.sessionCreation } : {}),
allowModelOverride: params?.allowModelOverride === true,
...(params?.agentRunTracking ? { agentRunTracking: params.agentRunTracking } : {}),
...(params?.cronRunContinuation === true ? { cronRunContinuation: true } : {}),
diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts
index bb9a3320c895..50d89d94f0a6 100644
--- a/src/gateway/server-plugins.ts
+++ b/src/gateway/server-plugins.ts
@@ -28,6 +28,7 @@ import {
type GatewayMethodDispatchResponse,
unwrapGatewayMethodDispatchResponse,
} from "./server-in-process-dispatch.js";
+import type { TrustedSessionCreation } from "./server-methods/session-creation-provenance.js";
import type {
GatewayRequestContext,
GatewayRequestHandler,
@@ -247,6 +248,7 @@ type DispatchGatewayMethodInProcessOptions = {
pluginRuntimeOwnerId?: string;
runtimePluginToolGrant?: RuntimePluginToolGrant;
delegatedToolPolicyHandoff?: boolean;
+ sessionCreation?: TrustedSessionCreation;
requireScopedClient?: boolean;
syntheticScopes?: string[];
timeoutMs?: number;
@@ -288,6 +290,7 @@ export async function dispatchGatewayMethodInProcessRaw(
? { runtimePluginToolGrant: options.runtimePluginToolGrant }
: {}),
delegatedToolPolicyHandoff: options?.delegatedToolPolicyHandoff === true,
+ ...(options?.sessionCreation ? { sessionCreation: options.sessionCreation } : {}),
scopes: options?.syntheticScopes,
});
const scopedClient = mergePluginRuntimeClientInternal(
diff --git a/src/gateway/server-session-events.ts b/src/gateway/server-session-events.ts
index 74dba1cdede5..ccbfa48a10f3 100644
--- a/src/gateway/server-session-events.ts
+++ b/src/gateway/server-session-events.ts
@@ -93,7 +93,7 @@ function buildGatewaySessionSnapshot(params: {
const session = params.includeSession
? {
...buildGatewaySessionEventRow(sessionRow),
- createdBy: sessionRow.createdBy ?? null,
+ createdActor: sessionRow.createdActor ?? null,
thinkingLevel: sessionRow.thinkingLevel ?? null,
}
: undefined;
diff --git a/src/gateway/server.agent.gateway-server-agent-a.test.ts b/src/gateway/server.agent.gateway-server-agent-a.test.ts
index 9cd121baf496..da59bc7881d8 100644
--- a/src/gateway/server.agent.gateway-server-agent-a.test.ts
+++ b/src/gateway/server.agent.gateway-server-agent-a.test.ts
@@ -367,6 +367,53 @@ describe("gateway server agent", () => {
expect(persisted?.spawnedBy).toBe("agent:main:main");
});
+ test("agent stamps create-on-run session rows without guessing an actor", async () => {
+ await setTestSessionStore({ entries: {} });
+ const sessionKey = "agent:main:dashboard:create-on-run";
+ const res = await rpcReq(gatewaySuite.ws, "agent", {
+ message: "hi",
+ sessionKey,
+ idempotencyKey: "idem-agent-create-on-run",
+ });
+ expect(res.ok).toBe(true);
+ await waitForAgentCommandCall("idem-agent-create-on-run");
+
+ expect(
+ loadSessionEntry({ sessionKey, storePath: gatewaySuite.sessionStorePath }),
+ ).toMatchObject({
+ createdVia: "run",
+ createdAt: expect.any(Number),
+ });
+ expect(
+ loadSessionEntry({ sessionKey, storePath: gatewaySuite.sessionStorePath })?.createdActor,
+ ).toBeUndefined();
+ });
+
+ test("agent links the previous generation when a missing transcript rotates the session", async () => {
+ const sessionKey = "agent:main:dashboard:rotate-missing-transcript";
+ await setTestSessionStore({
+ entries: {
+ [sessionKey]: {
+ sessionId: "missing-transcript-generation",
+ status: "failed",
+ updatedAt: Date.now(),
+ },
+ },
+ });
+
+ const res = await rpcReq(gatewaySuite.ws, "agent", {
+ message: "continue",
+ sessionKey,
+ idempotencyKey: "idem-agent-rotate-missing-transcript",
+ });
+ expect(res.ok).toBe(true);
+ await waitForAgentCommandCall("idem-agent-rotate-missing-transcript");
+
+ const persisted = loadSessionEntry({ sessionKey, storePath: gatewaySuite.sessionStorePath });
+ expect(persisted?.sessionId).not.toBe("missing-transcript-generation");
+ expect(persisted?.previousSessionId).toBe("missing-transcript-generation");
+ });
+
test("agent derives sessionKey from agentId", async () => {
testState.agentsConfig = { list: [{ id: "ops" }] };
await setTestSessionStore({
diff --git a/src/gateway/server.agent.subagent-delivery-context.test.ts b/src/gateway/server.agent.subagent-delivery-context.test.ts
index 45d32fe7a5f2..ab3a82ca2d5f 100644
--- a/src/gateway/server.agent.subagent-delivery-context.test.ts
+++ b/src/gateway/server.agent.subagent-delivery-context.test.ts
@@ -224,10 +224,10 @@ describe("subagent session deliveryContext from spawn request params", () => {
});
});
- test("pre-patched subagent session (via sessions.patch) inherits deliveryContext from agent request", async () => {
- // Simulates the real subagent spawn flow: spawnSubagentDirect calls sessions.patch
- // first (to set spawnDepth, spawnedBy, etc.), then calls callSubagentGateway({method: "agent"}).
- // The sessions.patch creates a partial entry without deliveryContext.
+ test("pre-created subagent session inherits deliveryContext from agent request", async () => {
+ // Simulates the real subagent spawn flow: the trusted session accessor persists lineage
+ // before callSubagentGateway({method: "agent"}) seeds the delivery context.
+ // The direct lineage write creates a partial entry without deliveryContext.
// The agent handler must seed deliveryContext from the request params.
await prepareSessionStore({
"agent:main:subagent:pre-patched": {
diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts
index 50565079c71c..bfca611219b4 100644
--- a/src/gateway/server.sessions.create.test.ts
+++ b/src/gateway/server.sessions.create.test.ts
@@ -15,6 +15,7 @@ import { managedWorktrees } from "../agents/worktrees/service.js";
import { loadSessionEntry, loadTranscriptEvents } from "../config/sessions/session-accessor.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
+import { listSessionStateEventsSince } from "../sessions/session-state-events.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import {
agentCommand,
@@ -73,51 +74,6 @@ function requireNonEmptyString(value: string | undefined, label: string): string
return value;
}
-test("sessions.create stamps the trusted creator and preserves it until reset", async () => {
- await createSessionStoreDir();
- const adaClient = {
- operatorIdentity: { id: "profile-ada", label: "Ada Lovelace" },
- connect: { scopes: ["operator.admin"] },
- } as never;
- const bobClient = {
- operatorIdentity: { id: "profile-bob", label: "Bob Hopper" },
- connect: { scopes: ["operator.admin"] },
- } as never;
-
- const created = await directSessionReq<{
- key: string;
- entry: { createdBy?: { id: string; label?: string } };
- }>("sessions.create", { agentId: "main" }, { client: adaClient });
- expect(created.ok).toBe(true);
- expect(created.payload?.entry.createdBy).toEqual({
- id: "profile-ada",
- label: "Ada Lovelace",
- });
- const key = requireNonEmptyString(created.payload?.key, "created session key");
-
- const reused = await directSessionReq<{ entry: { createdBy?: { id: string } } }>(
- "sessions.create",
- { agentId: "main", key },
- { client: bobClient },
- );
- expect(reused.payload?.entry.createdBy?.id).toBe("profile-ada");
-
- const listed = await directSessionReq<{
- sessions: Array<{ key: string; createdBy?: { id: string; label?: string } }>;
- }>("sessions.list", { agentId: "main" });
- expect(listed.payload?.sessions.find((row) => row.key === key)?.createdBy).toEqual({
- id: "profile-ada",
- label: "Ada Lovelace",
- });
-
- const reset = await directSessionReq<{ entry: { createdBy?: { id: string; label?: string } } }>(
- "sessions.reset",
- { agentId: "main", key },
- { client: bobClient },
- );
- expect(reset.payload?.entry.createdBy).toEqual({ id: "profile-bob", label: "Bob Hopper" });
-});
-
test("sessions.create provisions and reuses a session worktree for later runs", async () => {
const root = await fs.mkdtemp(
path.join(await fs.realpath(os.tmpdir()), "openclaw-session-worktree-"),
@@ -1488,6 +1444,190 @@ test("sessions.create preserves write-scoped fresh keyed model selection but gat
});
});
+test("sessions.create stamps trusted operator provenance and records created", async () => {
+ await createSessionStoreDir();
+ const profileId = "profile-session-creator";
+ const created = await directSessionReq<{
+ key?: string;
+ entry?: {
+ createdVia?: string;
+ createdActor?: { type: string; id?: string };
+ createdAt?: number;
+ };
+ }>(
+ "sessions.create",
+ { agentId: "main" },
+ {
+ client: {
+ connect: { scopes: ["operator.write"] },
+ authenticatedUserProfile: {
+ profileId,
+ displayName: "Test Operator",
+ hasAvatar: false,
+ updatedAt: 1,
+ },
+ } as never,
+ },
+ );
+
+ expect(created.ok).toBe(true);
+ expect(created.payload?.entry).toMatchObject({
+ createdVia: "operator",
+ createdActor: { type: "human", id: profileId },
+ createdAt: expect.any(Number),
+ });
+ const key = requireNonEmptyString(created.payload?.key, "created session key");
+ expect(listSessionStateEventsSince(key, "main", 0, 20).events).toContainEqual(
+ expect.objectContaining({
+ kind: "created",
+ actorType: "human",
+ actorId: profileId,
+ summary: "session created",
+ }),
+ );
+
+ const synthetic = await directSessionReq<{
+ entry?: { createdVia?: string; createdActor?: unknown; createdAt?: number };
+ }>(
+ "sessions.create",
+ { agentId: "main" },
+ {
+ client: {
+ connect: { scopes: ["operator.write"] },
+ internal: { syntheticClient: true },
+ } as never,
+ },
+ );
+ expect(synthetic.payload?.entry).toMatchObject({
+ createdVia: "operator",
+ createdAt: expect.any(Number),
+ });
+ expect(synthetic.payload?.entry?.createdActor).toBeUndefined();
+
+ const hinted = await directSessionReq<{
+ entry?: { createdVia?: string; createdActor?: unknown };
+ }>(
+ "sessions.create",
+ { agentId: "main" },
+ {
+ client: {
+ connect: { scopes: ["operator.write"] },
+ internal: {
+ syntheticClient: true,
+ sessionCreation: {
+ via: "spawn",
+ actor: { type: "agent", id: "agent:main:main" },
+ },
+ },
+ } as never,
+ },
+ );
+ expect(hinted.payload?.entry).toMatchObject({
+ createdVia: "spawn",
+ createdActor: { type: "agent", id: "agent:main:main" },
+ });
+});
+
+test("sessions.create reset-in-place preserves the node creation stamp", async () => {
+ testState.sessionConfig = { dmScope: "main" };
+ const { storePath } = await createSessionStoreDir();
+ await writeSessionStore({
+ entries: {
+ main: sessionStoreEntry("existing-main", {
+ createdVia: "channel",
+ createdActor: { type: "human", id: "telegram:42" },
+ createdAt: 1234,
+ }),
+ },
+ });
+
+ const reset = await directSessionReq<{ entry?: Record }>(
+ "sessions.create",
+ { agentId: "main", parentSessionKey: "main", emitCommandHooks: true },
+ {
+ client: {
+ connect: { scopes: ["operator.write"] },
+ authenticatedUserProfile: {
+ profileId: "profile-resetter",
+ displayName: null,
+ hasAvatar: false,
+ updatedAt: 1,
+ },
+ } as never,
+ },
+ );
+
+ expect(reset.ok).toBe(true);
+ expect(reset.payload?.entry).toMatchObject({
+ createdVia: "channel",
+ createdActor: { type: "human", id: "telegram:42" },
+ createdAt: 1234,
+ });
+ expect(loadSessionEntry({ sessionKey: "agent:main:main", storePath })).toMatchObject({
+ createdVia: "channel",
+ createdActor: { type: "human", id: "telegram:42" },
+ createdAt: 1234,
+ });
+});
+
+test("sessions.create adopting an existing key does not restamp node provenance", async () => {
+ const { storePath } = await createSessionStoreDir();
+ await writeSessionStore({
+ entries: {
+ "agent:main:dashboard:adopted": sessionStoreEntry("existing-adopted", {
+ createdVia: "spawn",
+ createdActor: { type: "agent", id: "agent:main:main" },
+ createdAt: 4321,
+ }),
+ },
+ });
+ const { chatHandlers } = await import("./server-methods/chat.js");
+ const chatSend = vi.spyOn(chatHandlers, "chat.send").mockImplementation(async ({ respond }) => {
+ respond(true, { runId: "adopted-run", status: "started" });
+ });
+
+ try {
+ const adopted = await directSessionReq<{
+ entry?: Record;
+ runStarted?: boolean;
+ }>(
+ "sessions.create",
+ { key: "agent:main:dashboard:adopted", agentId: "main", message: "adopted follow-up" },
+ {
+ client: {
+ connect: { scopes: ["operator.write"] },
+ authenticatedUserProfile: {
+ profileId: "profile-adopter",
+ displayName: null,
+ hasAvatar: false,
+ updatedAt: 1,
+ },
+ } as never,
+ },
+ );
+
+ expect(adopted.ok).toBe(true);
+ // Post-create work (the nested initial chat.send) still runs on adoption.
+ expect(adopted.payload?.runStarted).toBe(true);
+ expect(chatSend).toHaveBeenCalledTimes(1);
+ expect(
+ loadSessionEntry({ sessionKey: "agent:main:dashboard:adopted", storePath }),
+ ).toMatchObject({
+ createdVia: "spawn",
+ createdActor: { type: "agent", id: "agent:main:main" },
+ createdAt: 4321,
+ });
+ // Adoption is not a node creation: no `created` event may enter the journal.
+ expect(
+ listSessionStateEventsSince("agent:main:dashboard:adopted", "main", 0, 20).events.filter(
+ (event) => event.kind === "created",
+ ),
+ ).toEqual([]);
+ } finally {
+ chatSend.mockRestore();
+ }
+});
+
test("sessions.create scopes the main alias to the requested agent", async () => {
const { storePath } = await createSessionStoreDir();
@@ -1875,6 +2015,7 @@ test("sessions.create forks the parent transcript into the new session", async (
entry?: {
sessionFile?: string;
parentSessionKey?: string;
+ forkSource?: { sessionKey: string; sessionId: string };
forkedFromParent?: boolean;
totalTokens?: number;
totalTokensFresh?: boolean;
@@ -1887,6 +2028,10 @@ test("sessions.create forks the parent transcript into the new session", async (
expect(created.ok, JSON.stringify(created.error)).toBe(true);
expect(created.payload?.entry?.parentSessionKey).toBe("agent:main:main");
+ expect(created.payload?.entry?.forkSource).toEqual({
+ sessionKey: "agent:main:main",
+ sessionId: parent.sessionId,
+ });
expect(created.payload?.entry?.forkedFromParent).toBe(true);
expect(created.payload?.entry?.totalTokens).toBeUndefined();
expect(created.payload?.entry?.totalTokensFresh).toBe(false);
@@ -1932,8 +2077,16 @@ test("sessions.create forks the parent transcript into the new session", async (
expect(loadSessionEntry({ sessionKey: key, storePath })).toMatchObject({
sessionId: created.payload?.sessionId,
sessionFile: forkedSessionFile,
- forkedFromParent: true,
+ forkSource: {
+ sessionKey: "agent:main:main",
+ sessionId: parent.sessionId,
+ },
});
+ expect(loadSessionEntry({ sessionKey: key, storePath })).not.toHaveProperty("forkedFromParent");
+ const listed = await directSessionReq<{
+ sessions?: Array<{ key: string; forkedFromParent?: boolean }>;
+ }>("sessions.list", {});
+ expect(listed.payload?.sessions?.find((row) => row.key === key)?.forkedFromParent).toBe(true);
testState.sessionConfig = undefined;
});
@@ -2159,6 +2312,7 @@ test("sessions.create resolves an agent-qualified fork from the parent store", a
entry?: {
parentSessionKey?: string;
sessionFile?: string;
+ forkSource?: { sessionKey: string; sessionId: string };
forkedFromParent?: boolean;
};
}>("sessions.create", {
@@ -2169,6 +2323,10 @@ test("sessions.create resolves an agent-qualified fork from the parent store", a
expect(created.ok, JSON.stringify(created.error)).toBe(true);
expect(created.payload?.key).toMatch(/^agent:main:dashboard:/);
expect(created.payload?.entry?.parentSessionKey).toBe("agent:work:main");
+ expect(created.payload?.entry?.forkSource).toEqual({
+ sessionKey: "agent:work:main",
+ sessionId: parent.sessionId,
+ });
expect(created.payload?.entry?.forkedFromParent).toBe(true);
const forkedSessionFile = requireNonEmptyString(
created.payload?.entry?.sessionFile,
diff --git a/src/gateway/server.sessions.list-changed.test.ts b/src/gateway/server.sessions.list-changed.test.ts
index 0274a551587d..ef5b726c52f0 100644
--- a/src/gateway/server.sessions.list-changed.test.ts
+++ b/src/gateway/server.sessions.list-changed.test.ts
@@ -1191,12 +1191,22 @@ test("sessions.changed mutation events include subagent ownership metadata", asy
entries: {
"subagent:child": sessionStoreEntry("sess-child", {
spawnedBy: "agent:main:main",
+ parentSessionKey: "agent:main:dashboard:navigation-parent",
spawnedWorkspaceDir: "/tmp/subagent-workspace",
spawnedCwd: "/tmp/task-repo",
forkedFromParent: true,
spawnDepth: 2,
subagentRole: "orchestrator",
subagentControlScope: "children",
+ createdVia: "spawn",
+ createdActor: { type: "agent", id: "agent:main:main" },
+ createdAt: 1_000,
+ forkSource: {
+ sessionKey: "agent:main:main",
+ sessionId: "sess-source",
+ entryId: "entry-source",
+ },
+ previousSessionId: "sess-previous",
}),
},
});
@@ -1211,12 +1221,23 @@ test("sessions.changed mutation events include subagent ownership metadata", asy
sessionKey: "agent:main:subagent:child",
reason: "patch",
spawnedBy: "agent:main:main",
+ controlOwnerSessionKey: "agent:main:main",
+ parentSessionKey: "agent:main:dashboard:navigation-parent",
spawnedWorkspaceDir: "/tmp/subagent-workspace",
spawnedCwd: "/tmp/task-repo",
forkedFromParent: true,
spawnDepth: 2,
subagentRole: "orchestrator",
subagentControlScope: "children",
+ createdVia: "spawn",
+ createdActor: { type: "agent", id: "agent:main:main" },
+ createdAt: 1_000,
+ forkSource: {
+ sessionKey: "agent:main:main",
+ sessionId: "sess-source",
+ entryId: "entry-source",
+ },
+ previousSessionId: "sess-previous",
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
diff --git a/src/gateway/server.sessions.reset-models.test.ts b/src/gateway/server.sessions.reset-models.test.ts
index 72aaaff43473..09970096a54c 100644
--- a/src/gateway/server.sessions.reset-models.test.ts
+++ b/src/gateway/server.sessions.reset-models.test.ts
@@ -6,6 +6,7 @@ import path from "node:path";
import { expect, test } from "vitest";
import { loadSessionEntry } from "../config/sessions/session-accessor.js";
import { MODEL_SELECTION_LOCKED_RESET_MESSAGE } from "../sessions/model-overrides.js";
+import { listSessionStateEventsSince } from "../sessions/session-state-events.js";
import { testState, writeSessionStore } from "./test-helpers.js";
import {
setupGatewaySessionsTestHarness,
@@ -28,6 +29,11 @@ type ResetSessionEntry = {
spawnedWorkspaceDir?: string;
spawnedCwd?: string;
parentSessionKey?: string;
+ createdVia?: string;
+ createdActor?: { type: string; id?: string };
+ createdAt?: number;
+ forkSource?: { sessionKey: string; sessionId: string; entryId?: string };
+ previousSessionId?: string;
forkedFromParent?: boolean;
spawnDepth?: number;
subagentRole?: string;
@@ -84,6 +90,35 @@ type ModelResetEntry = Pick<
type ResolvedSessionModel = { modelProvider: string; model: string };
type SessionEntryOverrides = NonNullable[1]>;
+test("sessions.reset stamps provenance when it materializes a missing row", async () => {
+ await createSessionStoreDir();
+ const reset = await directSessionReq<{ entry: ResetSessionEntry }>(
+ "sessions.reset",
+ { key: "agent:main:subagent:missing" },
+ {
+ client: {
+ authenticatedUserProfile: { profileId: "profile-reset-creator" },
+ } as never,
+ },
+ );
+
+ expect(reset.ok).toBe(true);
+ expect(reset.payload?.entry).toMatchObject({
+ createdVia: "operator",
+ createdActor: { type: "human", id: "profile-reset-creator" },
+ createdAt: expect.any(Number),
+ });
+ expect(
+ listSessionStateEventsSince("agent:main:subagent:missing", "main", 0, 20).events,
+ ).toContainEqual(
+ expect.objectContaining({
+ kind: "created",
+ actorType: "human",
+ actorId: "profile-reset-creator",
+ }),
+ );
+});
+
const ownedChildMetadata = {
chatType: "group",
channel: "discord",
@@ -523,6 +558,15 @@ test("sessions.reset preserves spawned session ownership metadata", async () =>
"subagent:child": sessionStoreEntry("sess-owned-child", {
sessionFile: customSessionFile,
...ownedChildMetadata,
+ forkedFromParent: undefined,
+ createdVia: "spawn",
+ createdActor: { type: "agent", id: "agent:main:main" },
+ createdAt: 1_000,
+ forkSource: {
+ sessionKey: "agent:main:root",
+ sessionId: "root-session",
+ entryId: "root-entry",
+ },
}),
},
});
@@ -535,9 +579,31 @@ test("sessions.reset preserves spawned session ownership metadata", async () =>
expect(reset.ok).toBe(true);
expectOwnedChildMetadata(reset.payload?.entry);
+ expect(reset.payload?.entry).toMatchObject({
+ createdVia: "spawn",
+ createdActor: { type: "agent", id: "agent:main:main" },
+ createdAt: 1_000,
+ forkSource: {
+ sessionKey: "agent:main:root",
+ sessionId: "root-session",
+ entryId: "root-entry",
+ },
+ previousSessionId: "sess-owned-child",
+ });
const stored = loadSessionEntry({ sessionKey: "agent:main:subagent:child", storePath }) as
| ResetSessionEntry
| undefined;
expectOwnedChildMetadata(stored);
+ expect(stored).toMatchObject({
+ createdVia: "spawn",
+ createdActor: { type: "agent", id: "agent:main:main" },
+ createdAt: 1_000,
+ forkSource: {
+ sessionKey: "agent:main:root",
+ sessionId: "root-session",
+ entryId: "root-entry",
+ },
+ previousSessionId: "sess-owned-child",
+ });
});
diff --git a/src/gateway/server.sessions.store-rpc.test.ts b/src/gateway/server.sessions.store-rpc.test.ts
index 30e86705f105..9ae8c919537d 100644
--- a/src/gateway/server.sessions.store-rpc.test.ts
+++ b/src/gateway/server.sessions.store-rpc.test.ts
@@ -439,33 +439,24 @@ test("lists and patches session store via sessions.* RPC", async () => {
expect(spawnedOnly.ok).toBe(true);
expect(spawnedOnly.payload?.sessions.map((s) => s.key)).toEqual(["agent:main:subagent:one"]);
- const spawnedPatched = await directSessionReq<{
- ok: true;
- entry: { spawnedBy?: string };
- }>("sessions.patch", {
- key: "agent:main:subagent:two",
- spawnedBy: "agent:main:main",
- });
- expect(spawnedPatched.ok).toBe(true);
- expect(spawnedPatched.payload?.entry.spawnedBy).toBe("agent:main:main");
-
- const acpPatched = await directSessionReq<{
- ok: true;
- entry: { spawnedBy?: string; spawnDepth?: number };
- }>("sessions.patch", {
- key: "agent:main:acp:child",
+ for (const [field, value] of Object.entries({
spawnedBy: "agent:main:main",
+ spawnedWorkspaceDir: "/tmp/subagent-workspace",
+ spawnedCwd: "/tmp/task-repo",
spawnDepth: 1,
- });
- expect(acpPatched.ok).toBe(true);
- expect(acpPatched.payload?.entry.spawnedBy).toBe("agent:main:main");
- expect(acpPatched.payload?.entry.spawnDepth).toBe(1);
-
- const spawnedPatchedInvalidKey = await directSessionReq("sessions.patch", {
- key: "agent:main:main",
- spawnedBy: "agent:main:main",
- });
- expect(spawnedPatchedInvalidKey.ok).toBe(false);
+ subagentRole: "leaf",
+ subagentControlScope: "none",
+ })) {
+ const rejected = await directSessionReq("sessions.patch", {
+ key: "agent:main:subagent:two",
+ [field]: value,
+ });
+ expect(rejected.ok, field).toBe(false);
+ expect(rejected.error, field).toMatchObject({
+ code: "INVALID_REQUEST",
+ message: expect.stringContaining(`unexpected property '${field}'`),
+ });
+ }
const cleaned = await directSessionReq<{
applied: true;
diff --git a/src/gateway/server/ws-connection/connect-session.ts b/src/gateway/server/ws-connection/connect-session.ts
index f81ec1b91ae2..b983231ada70 100644
--- a/src/gateway/server/ws-connection/connect-session.ts
+++ b/src/gateway/server/ws-connection/connect-session.ts
@@ -9,7 +9,6 @@ import {
import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js";
import { ErrorCodes, PROTOCOL_VERSION } from "../../../../packages/gateway-protocol/src/index.js";
import { getRuntimeConfig } from "../../../config/io.js";
-import { getPairedDevice } from "../../../infra/device-pairing.js";
import {
captureAuthenticatedNodePairingState,
type NodePairingGeneration,
@@ -214,33 +213,6 @@ export async function attachAuthenticatedGatewayConnect(
);
}
}
- let pairedDeviceLabel: string | undefined;
- if (device?.id) {
- try {
- const pairedDevice = await getPairedDevice(device.id);
- pairedDeviceLabel =
- normalizeOptionalString(pairedDevice?.operatorLabel) ??
- normalizeOptionalString(pairedDevice?.displayName);
- } catch (error) {
- // Pairing metadata is attribution-only and must not turn into a login dependency.
- logWsControl.warn(
- `paired device label resolution failed conn=${connId}: ${formatForLog(error)}`,
- );
- }
- }
- // SSO identity wins over device labeling so one person keeps the same creator
- // across browsers; paired-device labels cover gateways without trusted proxy auth.
- const operatorIdentity = authenticatedUserProfile
- ? {
- id: authenticatedUserId,
- label: authenticatedUserProfile.displayName ?? authenticatedUserId,
- }
- : authenticatedUserId
- ? { id: authenticatedUserId, label: authenticatedUserId }
- : device?.id && pairedDeviceLabel
- ? { id: device.id, label: pairedDeviceLabel }
- : undefined;
-
const pluginSurfaceUrls: Record = {};
const pluginNodeCapabilitySurfaces = indexPluginNodeCapabilitySurfaces(pluginNodeCapabilities);
const pendingPluginNodeCapabilities: Array<{
@@ -342,7 +314,6 @@ export async function attachAuthenticatedGatewayConnect(
presenceKey,
...(authenticatedUserId ? { authenticatedUserId } : {}),
...(authenticatedUserProfile ? { authenticatedUserProfile } : {}),
- ...(operatorIdentity ? { operatorIdentity } : {}),
clientIp: reportedClientIp,
...(internal ? { internal } : {}),
...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}),
diff --git a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts
index a79491a40bb6..eee08f93c826 100644
--- a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts
+++ b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts
@@ -628,7 +628,6 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
displayName: "alice",
hasAvatar: false,
},
- operatorIdentity: { id: "alice@example.com", label: "alice" },
});
expect(setAvatar(profileId!, new Uint8Array([1, 2, 3]), "image/png").ok).toBe(true);
@@ -665,10 +664,7 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
}),
);
});
- expect(harness.client).toMatchObject({
- authenticatedUserId: "alice@example.com",
- operatorIdentity: { id: "alice@example.com", label: "alice@example.com" },
- });
+ expect(harness.client).toMatchObject({ authenticatedUserId: "alice@example.com" });
expect(harness.client).not.toMatchObject({ authenticatedUserProfile: expect.anything() });
expect(harness.logWsControl.warn).toHaveBeenCalledTimes(1);
expect(harness.logWsControl.warn).toHaveBeenCalledWith(
diff --git a/src/gateway/session-create-fork-entry.test.ts b/src/gateway/session-create-fork-entry.test.ts
new file mode 100644
index 000000000000..d9f648a53970
--- /dev/null
+++ b/src/gateway/session-create-fork-entry.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import type { SessionEntry } from "../config/sessions.js";
+import { buildForkedGatewaySessionEntry } from "./session-create-fork-entry.js";
+
+describe("buildForkedGatewaySessionEntry", () => {
+ it("preserves adopted node ancestry and links the replaced generation", () => {
+ const previous: SessionEntry = {
+ sessionId: "adopted-generation",
+ updatedAt: 1,
+ forkSource: { sessionKey: "agent:main:original", sessionId: "original-generation" },
+ };
+
+ const forked = buildForkedGatewaySessionEntry(
+ previous,
+ { sessionId: "next-generation", sessionFile: "/tmp/next-generation.jsonl" },
+ { sessionKey: "agent:main:new-parent", sessionId: "new-parent-generation" },
+ previous,
+ );
+
+ expect(forked).toMatchObject({
+ sessionId: "next-generation",
+ previousSessionId: "adopted-generation",
+ forkSource: { sessionKey: "agent:main:original", sessionId: "original-generation" },
+ });
+ });
+
+ it("uses the requested ancestry for a genuinely new node", () => {
+ const entry: SessionEntry = { sessionId: "provisional", updatedAt: 1 };
+ const forked = buildForkedGatewaySessionEntry(
+ entry,
+ { sessionId: "forked", sessionFile: "/tmp/forked.jsonl" },
+ { sessionKey: "agent:main:parent", sessionId: "parent-generation" },
+ );
+
+ expect(forked.forkSource).toEqual({
+ sessionKey: "agent:main:parent",
+ sessionId: "parent-generation",
+ });
+ expect(forked.previousSessionId).toBeUndefined();
+ });
+});
diff --git a/src/gateway/session-create-fork-entry.ts b/src/gateway/session-create-fork-entry.ts
index f1ae66c4b674..c6800bbde5da 100644
--- a/src/gateway/session-create-fork-entry.ts
+++ b/src/gateway/session-create-fork-entry.ts
@@ -4,6 +4,8 @@ import type { SessionEntry } from "../config/sessions.js";
export function buildForkedGatewaySessionEntry(
entry: SessionEntry,
fork: { sessionId: string; sessionFile: string },
+ forkSource: NonNullable,
+ previousEntry?: SessionEntry,
): SessionEntry {
// Replacing the transcript identity also replaces the recovery episode owned by the old row.
return {
@@ -11,7 +13,10 @@ export function buildForkedGatewaySessionEntry(
...buildMainSessionRecoveryClearPatch(entry),
sessionId: fork.sessionId,
sessionFile: fork.sessionFile,
- forkedFromParent: true,
+ forkSource: previousEntry?.forkSource ?? forkSource,
+ ...(previousEntry?.sessionId && previousEntry.sessionId !== fork.sessionId
+ ? { previousSessionId: previousEntry.sessionId }
+ : {}),
totalTokens: undefined,
totalTokensFresh: false,
};
diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts
index 3057259b267a..5ae1129d0962 100644
--- a/src/gateway/session-create-service.ts
+++ b/src/gateway/session-create-service.ts
@@ -6,7 +6,6 @@ import {
import {
ErrorCodes,
type ErrorShape,
- type SessionCreatorIdentity,
errorShape,
missingScopeErrorShape,
} from "../../packages/gateway-protocol/src/index.js";
@@ -34,6 +33,11 @@ import {
createSessionEntryWithTranscript,
resolveSessionEntryAccessTarget,
} from "../config/sessions/session-accessor.js";
+import {
+ buildSessionCreationStamp,
+ type SessionCreatedActor,
+ type SessionCreatedVia,
+} from "../config/sessions/session-entry-provenance.js";
import { inheritSessionSelection } from "../config/sessions/session-entry-selection.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
@@ -58,6 +62,7 @@ import {
isSessionWorkAdmissionActive,
runExclusiveSessionLifecycleMutation,
} from "../sessions/session-lifecycle-admission.js";
+import { recordSessionCreated } from "../sessions/session-state-events.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import { ADMIN_SCOPE } from "./operator-scopes.js";
import { buildForkedGatewaySessionEntry } from "./session-create-fork-entry.js";
@@ -261,7 +266,6 @@ export async function createGatewaySession(params: {
thinkingLevel?: string;
/** Trusted catalog-owned model/runtime pair, persisted and locked together. */
catalogTarget?: TrustedCatalogSessionTarget;
- createdBy?: SessionCreatorIdentity;
parentSessionKey?: string;
/**
* Spawn-lineage depth declared by spawn-owned creations (visible subagent
@@ -293,6 +297,8 @@ export async function createGatewaySession(params: {
initialEntry?: TrustedInitialSessionEntry;
/** Public callers need admin before reconfiguring an adopted keyed session. */
allowExistingModelSelection?: boolean;
+ /** Trusted in-process creation provenance; never populated from public Gateway params. */
+ creation?: { via: SessionCreatedVia; actor?: SessionCreatedActor };
/** Exact harness namespace authorized by the scoped plugin runtime. */
authorizedAgentHarnessId?: string;
/** Exact plugin namespace authorized by the scoped plugin runtime. */
@@ -537,7 +543,7 @@ export async function createGatewaySession(params: {
: {}),
reason: "new",
commandSource: params.commandSource,
- createdBy: params.createdBy,
+ ...(params.creation ? { creation: params.creation } : {}),
...(spawnedCwd ? { spawnedCwd } : {}),
...(params.worktree ? { worktree: params.worktree } : {}),
...(params.execNode ? { execNode: params.execNode } : {}),
@@ -560,6 +566,7 @@ export async function createGatewaySession(params: {
}
let createdContext: CreatedGatewaySession | undefined;
+ let createdNewEntry = false;
const createChildSession = async (): Promise => {
let currentParentSessionEntry = parentSessionEntry;
if (
@@ -687,6 +694,9 @@ export async function createGatewaySession(params: {
),
};
}
+ // Adoption of an existing key must not stamp provenance or emit a
+ // `created` event; only a genuinely new row is a node creation.
+ createdNewEntry = existingEntry === undefined;
const requestedModel = normalizeOptionalString(params.model);
const requestedThinkingLevel = normalizeOptionalString(params.thinkingLevel);
if (existingEntry?.sessionId && params.allowExistingModelSelection !== true) {
@@ -771,9 +781,10 @@ export async function createGatewaySession(params: {
: undefined;
const initializedEntry: SessionEntry = {
...patched.entry,
- ...(existingEntry === undefined && params.createdBy
- ? { createdBy: { ...params.createdBy } }
- : {}),
+ // Stamp provenance only for genuinely new rows: adopting an existing key
+ // must not restamp write-once node facts (this direct store write bypasses
+ // the merge-level write-once guard), and legacy rows stay "unknown".
+ ...(params.creation && createdNewEntry ? buildSessionCreationStamp(params.creation) : {}),
...(catalogResolvedModel && catalogAgentRuntime
? {
providerOverride: catalogResolvedModel.provider,
@@ -880,7 +891,15 @@ export async function createGatewaySession(params: {
}
return {
...initialized,
- entry: buildForkedGatewaySessionEntry(entry, fork),
+ entry: buildForkedGatewaySessionEntry(
+ entry,
+ fork,
+ {
+ sessionKey: forkParentSessionKey,
+ sessionId: currentParentSessionEntry.sessionId,
+ },
+ existingEntry,
+ ),
};
},
params.initialEntry
@@ -967,12 +986,28 @@ export async function createGatewaySession(params: {
run: createChildSession,
});
if (result.ok && !result.resetExisting && createdContext) {
+ // Adoption still runs post-create work (initial chat.send, plugin hooks);
+ // only the created journal event is reserved for genuinely new rows.
+ if (createdNewEntry) {
+ recordSessionCreated({
+ sessionKey: createdContext.key,
+ agentId: createdContext.agentId,
+ entry: createdContext.entry,
+ });
+ }
await params.afterCreate?.(createdContext);
}
return result;
}
const result = await createChildSession();
if (result.ok && !result.resetExisting && createdContext) {
+ if (createdNewEntry) {
+ recordSessionCreated({
+ sessionKey: createdContext.key,
+ agentId: createdContext.agentId,
+ entry: createdContext.entry,
+ });
+ }
await params.afterCreate?.(createdContext);
}
return result;
diff --git a/src/gateway/session-event-payload.test.ts b/src/gateway/session-event-payload.test.ts
index 10179b23d1b8..86507186fd44 100644
--- a/src/gateway/session-event-payload.test.ts
+++ b/src/gateway/session-event-payload.test.ts
@@ -1,21 +1,21 @@
import { expect, it } from "vitest";
import { buildGatewaySessionEventFields } from "./session-event-payload.js";
-it("projects creator identity and explicitly clears it for ownerless generations", () => {
+it("projects the created actor and explicitly clears it for actorless sessions", () => {
expect(
buildGatewaySessionEventFields({
sessionRow: {
key: "agent:main:owned",
kind: "direct",
updatedAt: 1,
- createdBy: { id: "profile-ada", label: "Ada" },
+ createdActor: { type: "human", id: "profile-ada", label: "Ada" },
},
- }).createdBy,
- ).toEqual({ id: "profile-ada", label: "Ada" });
+ }).createdActor,
+ ).toEqual({ type: "human", id: "profile-ada", label: "Ada" });
expect(
buildGatewaySessionEventFields({
sessionRow: { key: "agent:main:ownerless", kind: "direct", updatedAt: 2 },
- }).createdBy,
+ }).createdActor,
).toBeNull();
});
diff --git a/src/gateway/session-event-payload.ts b/src/gateway/session-event-payload.ts
index 3db640fe5d14..60332094cdc3 100644
--- a/src/gateway/session-event-payload.ts
+++ b/src/gateway/session-event-payload.ts
@@ -1,3 +1,4 @@
+import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js";
import type { GatewaySessionRow } from "./session-utils.js";
/**
@@ -27,7 +28,7 @@ export function buildGatewaySessionEventFields(params: {
return {
updatedAt: sessionRow.updatedAt ?? undefined,
sessionId: sessionRow.sessionId,
- createdBy: sessionRow.createdBy ?? null,
+ createdActor: sessionRow.createdActor ?? null,
kind: sessionRow.kind,
channel: sessionRow.channel,
subject: sessionRow.subject,
@@ -46,13 +47,18 @@ export function buildGatewaySessionEventFields(params: {
observerDigest: sessionRow.observerDigest ?? null,
lastActivityAt: sessionRow.lastActivityAt,
spawnedBy: sessionRow.spawnedBy,
+ controlOwnerSessionKey: sessionRow.controlOwnerSessionKey ?? null,
swarmGroupId: sessionRow.swarmGroupId,
spawnedWorkspaceDir: sessionRow.spawnedWorkspaceDir,
spawnedCwd: sessionRow.spawnedCwd,
- forkedFromParent: sessionRow.forkedFromParent,
+ forkedFromParent: sessionEntryForkedFromParent(sessionRow) ? true : undefined,
spawnDepth: sessionRow.spawnDepth,
subagentRole: sessionRow.subagentRole,
subagentControlScope: sessionRow.subagentControlScope,
+ createdVia: sessionRow.createdVia,
+ createdAt: sessionRow.createdAt,
+ forkSource: sessionRow.forkSource,
+ previousSessionId: sessionRow.previousSessionId,
label: params.label ?? sessionRow.label ?? null,
// Explicit null so subscribed clients drop a cleared category during merge-reconcile.
category: sessionRow.category ?? null,
diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts
index c92d072c8f40..76f4bcf7df24 100644
--- a/src/gateway/session-reset-service.ts
+++ b/src/gateway/session-reset-service.ts
@@ -1,11 +1,7 @@
// Gateway session reset/delete service.
// Rotates transcripts and coordinates lifecycle cleanup across runtimes/hooks.
import { randomUUID } from "node:crypto";
-import {
- ErrorCodes,
- errorShape,
- type SessionCreatorIdentity,
-} from "../../packages/gateway-protocol/src/index.js";
+import { ErrorCodes, errorShape } from "../../packages/gateway-protocol/src/index.js";
import { getAcpSessionManager } from "../acp/control-plane/manager.js";
import { getAcpRuntimeBackend } from "../acp/runtime/registry.js";
import {
@@ -39,6 +35,12 @@ import {
} from "../config/sessions.js";
import { rebindCliSessionReseedReceiptsForReset } from "../config/sessions/cli-session-binding.js";
import { resolveResetPreservedSelection } from "../config/sessions/reset-preserved-selection.js";
+import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js";
+import {
+ buildSessionCreationStamp,
+ type SessionCreatedActor,
+ type SessionCreatedVia,
+} from "../config/sessions/session-entry-provenance.js";
import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
import type { SessionAcpMeta } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -66,7 +68,10 @@ import {
runExclusiveSessionLifecycleMutation,
SESSION_WORK_ADMISSION_DRAIN_TIMEOUT_MS,
} from "../sessions/session-lifecycle-admission.js";
-import { handleSessionStateSessionReset } from "../sessions/session-state-events.js";
+import {
+ handleSessionStateSessionReset,
+ recordSessionCreated,
+} from "../sessions/session-state-events.js";
import {
forgetActiveSessionForShutdown,
listActiveSessionsForShutdown,
@@ -912,7 +917,8 @@ export async function performGatewaySessionReset(params: {
clearSpawnedCwd?: boolean;
reason: "new" | "reset";
commandSource: string;
- createdBy?: SessionCreatorIdentity;
+ /** Trusted provenance for a reset that materializes a previously missing row. */
+ creation?: { via: SessionCreatedVia; actor?: SessionCreatedActor };
assertCurrent?: () => void;
onCommitted?: (commit: { key: string; sessionId: string }) => void;
}): Promise<
@@ -1166,6 +1172,7 @@ export async function performGatewaySessionReset(params: {
})
: undefined;
+ let createdNewEntry = false;
const lifecycle = await resetSessionEntryLifecycle({
agentId: target.agentId,
storePath,
@@ -1174,6 +1181,7 @@ export async function performGatewaySessionReset(params: {
storeKeys: target.storeKeys,
},
buildNextEntry: ({ currentEntry, primaryKey }) => {
+ createdNewEntry = currentEntry === undefined;
if (!isResetLifecycleCurrent() && currentEntry?.sessionId !== entry?.sessionId) {
// A newer owner already replaced or removed the session while cleanup
// targeted the old id. Preserve that newer state instead of resetting it.
@@ -1193,9 +1201,17 @@ export async function performGatewaySessionReset(params: {
sessionId: nextSessionId,
storePath,
});
+ const creationStamp = currentEntry
+ ? {
+ createdVia: currentEntry.createdVia,
+ createdActor: currentEntry.createdActor,
+ createdAt: currentEntry.createdAt,
+ }
+ : params.creation
+ ? buildSessionCreationStamp(params.creation)
+ : {};
const nextEntry: SessionEntry = {
sessionId: nextSessionId,
- ...(params.createdBy ? { createdBy: { ...params.createdBy } } : {}),
sessionFile,
updatedAt: now,
systemSent: false,
@@ -1249,7 +1265,10 @@ export async function performGatewaySessionReset(params: {
? undefined
: (params.worktree ?? currentEntry?.worktree),
parentSessionKey: currentEntry?.parentSessionKey,
- forkedFromParent: currentEntry?.forkedFromParent,
+ ...creationStamp,
+ forkSource: currentEntry?.forkSource,
+ previousSessionId: currentEntry?.sessionId,
+ forkedFromParent: sessionEntryForkedFromParent(currentEntry) ? true : undefined,
spawnDepth: currentEntry?.spawnDepth,
subagentRole: currentEntry?.subagentRole,
subagentControlScope: currentEntry?.subagentControlScope,
@@ -1295,6 +1314,13 @@ export async function performGatewaySessionReset(params: {
return nextEntry;
},
afterEntryMutation: async (mutation) => {
+ if (createdNewEntry) {
+ recordSessionCreated({
+ sessionKey: target.canonicalKey ?? params.key,
+ agentId,
+ entry: mutation.nextEntry,
+ });
+ }
let committedAcpResetState: { sessionKey: string; meta: SessionAcpMeta } | undefined;
if (deferredAcpResetState) {
const identity = deferredAcpResetState.meta.identity;
diff --git a/src/gateway/session-utils-creators.test.ts b/src/gateway/session-utils-creators.test.ts
index a250f0db7ab8..1993b2fbd2ca 100644
--- a/src/gateway/session-utils-creators.test.ts
+++ b/src/gateway/session-utils-creators.test.ts
@@ -1,17 +1,27 @@
-import { expect, it } from "vitest";
+import { expect, it, vi } from "vitest";
import type { SessionEntry } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
+
+const getUserProfileListItem = vi.hoisted(() =>
+ vi.fn((profileId: string) => ({
+ id: profileId,
+ displayName: profileId === "profile-ada" ? "Ada" : "Bob",
+ })),
+);
+
+vi.mock("../state/user-profiles.js", () => ({ getUserProfileListItem }));
+
import { listSessionsFromStore } from "./session-utils.js";
it("returns the complete deterministic creator facet independently of pagination", () => {
const store: Record = {
"agent:main:ada": {
- createdBy: { id: "profile-ada", label: "Ada" },
+ createdActor: { type: "human", id: "profile-ada" },
sessionId: "session-ada",
updatedAt: 2,
},
"agent:main:bob": {
- createdBy: { id: "profile-bob", label: "Bob" },
+ createdActor: { type: "human", id: "profile-bob" },
sessionId: "session-bob",
updatedAt: 1,
},
@@ -30,6 +40,12 @@ it("returns the complete deterministic creator facet independently of pagination
{ id: "profile-ada", label: "Ada" },
{ id: "profile-bob", label: "Bob" },
]);
+ expect(result.sessions[0]?.createdActor).toEqual({
+ type: "human",
+ id: "profile-ada",
+ label: "Ada",
+ });
+ expect(getUserProfileListItem).toHaveBeenCalledTimes(2);
const filtered = listSessionsFromStore({
cfg: {} as OpenClawConfig,
diff --git a/src/gateway/session-utils.subagent.test.ts b/src/gateway/session-utils.subagent.test.ts
index 9a6851e96ec5..40ff935c9aee 100644
--- a/src/gateway/session-utils.subagent.test.ts
+++ b/src/gateway/session-utils.subagent.test.ts
@@ -111,6 +111,59 @@ describe("listSessionsFromStore subagent metadata", () => {
}
});
+ test("keeps persisted navigation lineage separate from live registry control", () => {
+ const now = Date.now();
+ const childSessionKey = "agent:main:subagent:controlled-child";
+ const entry = {
+ sessionId: "sess-controlled-child",
+ updatedAt: now,
+ spawnedBy: "agent:main:subagent:persisted-spawner",
+ parentSessionKey: "agent:main:dashboard:navigation-parent",
+ createdVia: "spawn",
+ createdActor: { type: "agent", id: "agent:main:main" },
+ createdAt: now - 10_000,
+ forkSource: {
+ sessionKey: "agent:main:main",
+ sessionId: "sess-source",
+ entryId: "entry-source",
+ },
+ previousSessionId: "sess-previous",
+ } satisfies SessionEntry;
+
+ addSubagentRunForTests({
+ runId: "run-controlled-child",
+ childSessionKey,
+ controllerSessionKey: "agent:main:subagent:runtime-controller",
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "main",
+ task: "controlled child",
+ cleanup: "keep",
+ createdAt: now - 5_000,
+ startedAt: now - 4_000,
+ });
+
+ const result = listSessionsFromStore({
+ cfg,
+ storePath: "/tmp/sessions.json",
+ store: { [childSessionKey]: entry },
+ opts: {},
+ });
+ const row = expectDefined(result.sessions[0], "controlled child row");
+
+ expect(row.spawnedBy).toBe("agent:main:subagent:runtime-controller");
+ expect(row.controlOwnerSessionKey).toBe("agent:main:subagent:runtime-controller");
+ expect(row.parentSessionKey).toBe("agent:main:dashboard:navigation-parent");
+ expect(row.createdVia).toBe("spawn");
+ expect(row.createdActor).toEqual({ type: "agent", id: "agent:main:main" });
+ expect(row.createdAt).toBe(now - 10_000);
+ expect(row.forkSource).toEqual({
+ sessionKey: "agent:main:main",
+ sessionId: "sess-source",
+ entryId: "entry-source",
+ });
+ expect(row.previousSessionId).toBe("sess-previous");
+ });
+
test("includes subagent status timing and direct child session keys", () => {
const now = Date.now();
const store: Record = {
@@ -576,7 +629,7 @@ describe("listSessionsFromStore subagent metadata", () => {
expect(result.sessions[0]?.spawnedBy).toBe("agent:main:subagent:new-parent-owner");
});
- test("reports the newest parentSessionKey for moved child session rows", () => {
+ test("keeps the persisted parentSessionKey while reporting the newest runtime controller", () => {
const now = Date.now();
const childSessionKey = "agent:main:subagent:shared-child-parent";
const store: Record = {
@@ -621,7 +674,11 @@ describe("listSessionsFromStore subagent metadata", () => {
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]?.key).toBe(childSessionKey);
- expect(result.sessions[0]?.parentSessionKey).toBe("agent:main:subagent:new-parent-parent");
+ expect(result.sessions[0]?.parentSessionKey).toBe("agent:main:subagent:old-parent-parent");
+ expect(result.sessions[0]?.spawnedBy).toBe("agent:main:subagent:new-parent-parent");
+ expect(result.sessions[0]?.controlOwnerSessionKey).toBe(
+ "agent:main:subagent:new-parent-parent",
+ );
});
test("preserves original session timing across follow-up replacement runs", () => {
diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts
index fa8fbb175bde..93b9ea1ea3fa 100644
--- a/src/gateway/session-utils.test.ts
+++ b/src/gateway/session-utils.test.ts
@@ -227,6 +227,18 @@ describe("gateway session utils", () => {
);
});
+ test("emits a tombstone when a session has no current control owner", () => {
+ const row = buildGatewaySessionRow({
+ cfg: createModelDefaultsConfig({ primary: "openai/gpt-5.4" }),
+ storePath: "",
+ store: {},
+ key: "agent:main:child-without-owner",
+ entry: {} as SessionEntry,
+ });
+
+ expect(buildGatewaySessionEventFields({ sessionRow: row }).controlOwnerSessionKey).toBeNull();
+ });
+
test("projects only unexpired agent status", () => {
const entry = {
sessionId: "session",
diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts
index 8f8ed75ca847..af1a900774e1 100644
--- a/src/gateway/session-utils.ts
+++ b/src/gateway/session-utils.ts
@@ -8,7 +8,7 @@ import {
} from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import type {
- SessionCreatorIdentity,
+ SessionCreatedActor,
SessionsListParams,
} from "../../packages/gateway-protocol/src/index.js";
import {
@@ -96,6 +96,7 @@ import {
listSessionEntries as listAccessorSessionEntries,
listSessionEntriesReadOnly as listAccessorSessionEntriesReadOnly,
} from "../config/sessions/session-accessor.js";
+import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { projectPluginSessionExtensionsSync } from "../plugins/host-hook-state.js";
import { withPinnedActivePluginRegistryWorkspaceDir } from "../plugins/runtime-workspace-state.js";
@@ -107,6 +108,7 @@ import {
import { resolveActiveSessionAgentStatus } from "../sessions/session-agent-status.js";
import { isAcpSessionKey, isCronRunSessionKey } from "../sessions/session-key-utils.js";
import { resolveNonNegativeNumber } from "../shared/number-coercion.js";
+import { getUserProfileListItem } from "../state/user-profiles.js";
import { truncateUtf16Safe } from "../utils.js";
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.shared.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel-constants.js";
@@ -408,6 +410,7 @@ type SessionListRowContext = {
>;
displayModelIdentityByKey: Map;
modelCostConfigByModelRef: Map;
+ userProfileLabelById: Map;
};
type SessionListRowContextProvider = () => SessionListRowContext;
@@ -661,6 +664,7 @@ function buildSessionListRowContextFromParts(params: {
thinkingMetadataByModelRef: new Map(),
displayModelIdentityByKey: new Map(),
modelCostConfigByModelRef: new Map(),
+ userProfileLabelById: new Map(),
};
}
@@ -1919,6 +1923,31 @@ export function resolveSessionDisplayModelIdentityRef(params: {
};
}
+/** Adds the current human profile label without persisting rename-prone display data. */
+function projectSessionCreatedActor(
+ actor: SessionEntry["createdActor"],
+ userProfileLabelById: Map = new Map(),
+): SessionCreatedActor | undefined {
+ if (!actor) {
+ return undefined;
+ }
+ const id = normalizeOptionalString(actor.id);
+ if (actor.type !== "human" || !id) {
+ return { type: actor.type, ...(id ? { id } : {}) };
+ }
+ let label = userProfileLabelById.get(id);
+ if (!userProfileLabelById.has(id)) {
+ try {
+ label = normalizeOptionalString(getUserProfileListItem(id).displayName);
+ } catch {
+ // Human actors can also be channel sender ids; only profile ids resolve here.
+ label = undefined;
+ }
+ userProfileLabelById.set(id, label);
+ }
+ return { type: actor.type, id, ...(label ? { label } : {}) };
+}
+
export function buildGatewaySessionRow(params: {
cfg: OpenClawConfig;
storePath: string;
@@ -2225,18 +2254,24 @@ export function buildGatewaySessionRow(params: {
return {
key,
- createdBy: entry?.createdBy,
spawnedBy: subagentOwner || entry?.spawnedBy,
+ // The live registry controller takes precedence over the persisted spawner.
+ controlOwnerSessionKey: subagentOwner || entry?.spawnedBy,
swarmGroupId: entry?.swarmGroupId,
spawnedWorkspaceDir: entry?.spawnedWorkspaceDir,
spawnedCwd: entry?.spawnedCwd,
worktree: entry?.worktree,
execNode: entry?.execNode,
execCwd: entry?.execCwd,
- forkedFromParent: entry?.forkedFromParent,
+ forkedFromParent: sessionEntryForkedFromParent(entry) ? true : undefined,
spawnDepth: entry?.spawnDepth,
subagentRole: entry?.subagentRole,
subagentControlScope: entry?.subagentControlScope,
+ createdVia: entry?.createdVia,
+ createdActor: projectSessionCreatedActor(entry?.createdActor, rowContext?.userProfileLabelById),
+ createdAt: entry?.createdAt,
+ forkSource: entry?.forkSource,
+ previousSessionId: entry?.previousSessionId,
kind: classifySessionKey(key, entry),
label: entry?.label,
category: entry?.category,
@@ -2299,7 +2334,8 @@ export function buildGatewaySessionRow(params: {
startedAt: subagentRun ? subagentStartedAt : entry?.startedAt,
endedAt: subagentRun ? subagentEndedAt : entry?.endedAt,
runtimeMs: subagentRun ? subagentRuntimeMs : entry?.runtimeMs,
- parentSessionKey: subagentOwner || entry?.parentSessionKey,
+ // Navigation lineage is persisted; runtime control is exposed separately above.
+ parentSessionKey: entry?.parentSessionKey,
childSessions,
responseUsage: entry?.responseUsage,
effectiveResponseUsage: resolveEffectiveResponseUsage(
@@ -2720,7 +2756,7 @@ function selectSessionEntries(params: {
const creatorEntries = filterSessionEntries(params);
const creatorId = normalizeOptionalString(params.opts.creatorId);
const filtered = creatorId
- ? creatorEntries.filter(([, entry]) => entry.createdBy?.id === creatorId)
+ ? creatorEntries.filter(([, entry]) => entry.createdActor?.id === creatorId)
: creatorEntries;
const limit = resolveSessionsListLimit(params.opts, params.defaultLimit);
const offset = resolveSessionsListOffset(params.opts);
@@ -2743,14 +2779,16 @@ function selectSessionEntries(params: {
function listSessionCreatorIdentities(
entries: readonly SessionEntryPair[],
-): SessionCreatorIdentity[] {
- const creators = new Map();
+ userProfileLabelById: Map,
+): Array<{ id: string; label?: string }> {
+ const creators = new Map();
for (const [, entry] of entries) {
- const id = normalizeOptionalString(entry.createdBy?.id);
+ const actor = projectSessionCreatedActor(entry.createdActor, userProfileLabelById);
+ const id = normalizeOptionalString(actor?.id);
if (!id) {
continue;
}
- const label = normalizeOptionalString(entry.createdBy?.label);
+ const label = normalizeOptionalString(actor?.label);
const existing = creators.get(id);
if (!existing || (label && (!existing.label || label.localeCompare(existing.label) < 0))) {
creators.set(id, { id, ...(label ? { label } : {}) });
@@ -2849,7 +2887,10 @@ export function listSessionsFromStore(params: {
offset: offset > 0 ? offset : undefined,
nextOffset,
hasMore,
- creators: listSessionCreatorIdentities(creatorEntries),
+ creators: listSessionCreatorIdentities(
+ creatorEntries,
+ sharedRowContext?.userProfileLabelById ?? new Map(),
+ ),
defaults: getSessionDefaults(cfg, params.modelCatalog, { allowPluginNormalization: false }),
sessions,
};
@@ -2902,7 +2943,8 @@ export async function listSessionsFromStoreAsync(params: {
: undefined,
defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT,
});
- const { entries, totalCount, limitApplied, offset, nextOffset, hasMore } = selection;
+ const { entries, creatorEntries, totalCount, limitApplied, offset, nextOffset, hasMore } =
+ selection;
const fullRowContext =
rowContext || hasSpawnedByFilter || entries.length > SESSIONS_LIST_YIELD_BATCH_SIZE
? getRowContext()
@@ -2981,6 +3023,10 @@ export async function listSessionsFromStoreAsync(params: {
offset: offset > 0 ? offset : undefined,
nextOffset,
hasMore,
+ creators: listSessionCreatorIdentities(
+ creatorEntries,
+ sharedRowContext?.userProfileLabelById ?? new Map(),
+ ),
defaults: getSessionDefaults(cfg, params.modelCatalog, { allowPluginNormalization: false }),
sessions,
};
diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts
index d2e87f4e6fd3..2488c442adbd 100644
--- a/src/gateway/session-utils.types.ts
+++ b/src/gateway/session-utils.types.ts
@@ -1,8 +1,11 @@
// Shared Gateway session projection types.
// Keeps server methods and Control UI payloads aligned.
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
-import type { SessionPlacement } from "../../packages/gateway-protocol/src/index.js";
-import type { SessionCreatorIdentity } from "../../packages/gateway-protocol/src/schema/sessions.js";
+import type {
+ SessionCreatedActor,
+ SessionPlacement,
+ SessionRow,
+} from "../../packages/gateway-protocol/src/index.js";
import type { SessionObserverDigest } from "../../packages/gateway-protocol/src/schema/sessions.js";
import type { QueueMode } from "../auto-reply/reply/queue/types.js";
import type { ChatType } from "../channels/chat-type.js";
@@ -46,8 +49,9 @@ type SessionCompactionCheckpointPreview = Pick<
export type GatewaySessionRow = {
key: string;
- createdBy?: SessionCreatorIdentity;
spawnedBy?: string;
+ /** Current runtime controller, falling back to the durable spawning session. */
+ controlOwnerSessionKey?: string;
/** Collector swarm group that owns this child session, when applicable. */
swarmGroupId?: string;
spawnedWorkspaceDir?: string;
@@ -62,6 +66,11 @@ export type GatewaySessionRow = {
spawnDepth?: number;
subagentRole?: SessionEntry["subagentRole"];
subagentControlScope?: SessionEntry["subagentControlScope"];
+ createdVia?: SessionEntry["createdVia"];
+ createdActor?: SessionCreatedActor;
+ createdAt?: SessionEntry["createdAt"];
+ forkSource?: SessionEntry["forkSource"];
+ previousSessionId?: SessionEntry["previousSessionId"];
kind: "direct" | "group" | "global" | "unknown";
label?: string;
/** User-defined organization bucket; unrelated to chat-group kind/groupChannel. */
@@ -151,6 +160,16 @@ export type GatewaySessionRow = {
pluginExtensions?: PluginSessionExtensionProjection[];
};
+/**
+ * Compile-time drift guard: fails typecheck when the Gateway projection stops
+ * matching the protocol schema's documented row fields. Value-level so the
+ * unused-export scan sees a consumer.
+ */
+const sessionRowSchemaDriftGuard: Pick extends SessionRow
+ ? true
+ : false = true;
+void sessionRowSchemaDriftGuard;
+
export type GatewayAgentRow = SharedGatewayAgentRow;
export type SessionPreviewItem = {
diff --git a/src/gateway/sessions-patch-subagent-policy.ts b/src/gateway/sessions-patch-subagent-policy.ts
new file mode 100644
index 000000000000..b3a3565f82a1
--- /dev/null
+++ b/src/gateway/sessions-patch-subagent-policy.ts
@@ -0,0 +1,99 @@
+import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
+import type { SessionsPatchParams } from "../../packages/gateway-protocol/src/index.js";
+import {
+ normalizeInheritedToolAllowlist,
+ normalizeInheritedToolDenylist,
+} from "../agents/inherited-tool-deny.js";
+import type { SessionEntry } from "../config/sessions.js";
+import { isAcpSessionKey, isSubagentSessionKey } from "../routing/session-key.js";
+
+function supportsSpawnPolicy(storeKey: string): boolean {
+ return isSubagentSessionKey(storeKey) || isAcpSessionKey(storeKey);
+}
+
+function unsupportedField(field: string, storeKey: string): string | undefined {
+ return supportsSpawnPolicy(storeKey)
+ ? undefined
+ : `${field} is only supported for subagent:* or acp:* sessions`;
+}
+
+/** Applies the remaining public child-policy fields after lineage became creation-only. */
+export function applySessionsPatchSubagentPolicy(params: {
+ existing?: SessionEntry;
+ next: SessionEntry;
+ patch: SessionsPatchParams;
+ storeKey: string;
+}): string | undefined {
+ const { existing, next, patch, storeKey } = params;
+ if ("completionOwnerSessionKey" in patch) {
+ const raw = patch.completionOwnerSessionKey;
+ if (raw === null && existing?.completionOwnerSessionKey) {
+ return "completionOwnerSessionKey cannot be cleared once set";
+ }
+ if (raw !== null && raw !== undefined) {
+ const unsupported = unsupportedField("completionOwnerSessionKey", storeKey);
+ if (unsupported) {
+ return unsupported;
+ }
+ const normalized = normalizeOptionalString(raw);
+ if (!normalized) {
+ return "invalid completionOwnerSessionKey: empty";
+ }
+ if (
+ existing?.completionOwnerSessionKey &&
+ existing.completionOwnerSessionKey !== normalized
+ ) {
+ return "completionOwnerSessionKey cannot be changed once set";
+ }
+ next.completionOwnerSessionKey = normalized;
+ }
+ }
+
+ if ("inheritedToolPolicyVersion" in patch) {
+ const raw = patch.inheritedToolPolicyVersion;
+ if (raw === null && existing?.inheritedToolPolicyVersion !== undefined) {
+ return "inheritedToolPolicyVersion cannot be cleared once set";
+ }
+ if (raw !== null && raw !== undefined) {
+ const unsupported = unsupportedField("inheritedToolPolicyVersion", storeKey);
+ if (unsupported) {
+ return unsupported;
+ }
+ if (raw !== 1) {
+ return "invalid inheritedToolPolicyVersion (expected 1)";
+ }
+ next.inheritedToolPolicyVersion = 1;
+ }
+ }
+
+ for (const field of ["inheritedToolDeny", "inheritedToolAllow"] as const) {
+ if (!(field in patch)) {
+ continue;
+ }
+ const raw = patch[field];
+ if (raw === null) {
+ delete next[field];
+ continue;
+ }
+ if (raw === undefined) {
+ continue;
+ }
+ if (!Array.isArray(raw)) {
+ return `invalid ${field} (use an array of tool names)`;
+ }
+ const unsupported = unsupportedField(field, storeKey);
+ if (unsupported) {
+ return unsupported;
+ }
+ const normalized =
+ field === "inheritedToolDeny"
+ ? normalizeInheritedToolDenylist(raw)
+ : normalizeInheritedToolAllowlist(raw);
+ if (normalized.length > 0) {
+ next[field] = normalized;
+ } else {
+ delete next[field];
+ }
+ }
+ return undefined;
+}
diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts
index e877ef7f58ad..a5667f3d65e9 100644
--- a/src/gateway/sessions-patch.test.ts
+++ b/src/gateway/sessions-patch.test.ts
@@ -1012,16 +1012,6 @@ describe("gateway sessions patch", () => {
expect(entry.modelOverrideSource).toBe("user");
});
- test("sets spawnDepth for subagent sessions", async () => {
- const entry = expectPatchOk(
- await runPatch({
- storeKey: "agent:main:subagent:child",
- patch: { key: "agent:main:subagent:child", spawnDepth: 2 },
- }),
- );
- expect(entry.spawnDepth).toBe(2);
- });
-
test("validates thinking patches with live catalog reasoning metadata", async () => {
const registry = createEmptyPluginRegistry();
registry.providers.push({
@@ -1279,19 +1269,6 @@ describe("gateway sessions patch", () => {
expect(entry).toMatchObject({ label: "new label", thinkingLevel: "max" });
});
- test("sets spawnedBy for ACP sessions", async () => {
- const entry = expectPatchOk(
- await runPatch({
- storeKey: "agent:main:acp:child",
- patch: {
- key: "agent:main:acp:child",
- spawnedBy: "agent:main:main",
- },
- }),
- );
- expect(entry.spawnedBy).toBe("agent:main:main");
- });
-
test("sets an immutable completion owner for ACP sessions", async () => {
const entry = expectPatchOk(
await runPatch({
@@ -1315,29 +1292,6 @@ describe("gateway sessions patch", () => {
expectPatchError(result, "completionOwnerSessionKey cannot be changed once set");
});
- test("sets spawnedWorkspaceDir for subagent sessions", async () => {
- const entry = expectPatchOk(
- await runPatch({
- storeKey: "agent:main:subagent:child",
- patch: {
- key: "agent:main:subagent:child",
- spawnedWorkspaceDir: "/tmp/subagent-workspace",
- },
- }),
- );
- expect(entry.spawnedWorkspaceDir).toBe("/tmp/subagent-workspace");
- });
-
- test("sets spawnDepth for ACP sessions", async () => {
- const entry = expectPatchOk(
- await runPatch({
- storeKey: "agent:main:acp:child",
- patch: { key: "agent:main:acp:child", spawnDepth: 2 },
- }),
- );
- expect(entry.spawnDepth).toBe(2);
- });
-
test("sets an immutable requester policy snapshot version for ACP sessions", async () => {
const entry = expectPatchOk(
await runPatch({
@@ -1393,20 +1347,6 @@ describe("gateway sessions patch", () => {
expect(entry.inheritedToolDeny?.at(-1)).toBe("exec");
});
- test("rejects spawnDepth on non-subagent sessions", async () => {
- const result = await runPatch({
- patch: { key: MAIN_SESSION_KEY, spawnDepth: 1 },
- });
- expectPatchError(result, "spawnDepth is only supported");
- });
-
- test("rejects spawnedWorkspaceDir on non-subagent sessions", async () => {
- const result = await runPatch({
- patch: { key: MAIN_SESSION_KEY, spawnedWorkspaceDir: "/tmp/nope" },
- });
- expectPatchError(result, "spawnedWorkspaceDir is only supported");
- });
-
test("rejects inheritedToolDeny on non-subagent sessions", async () => {
const result = await runPatch({
patch: { key: MAIN_SESSION_KEY, inheritedToolDeny: ["exec"] },
diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts
index 4b6a16f053d9..286e7b2f8491 100644
--- a/src/gateway/sessions-patch.ts
+++ b/src/gateway/sessions-patch.ts
@@ -13,10 +13,6 @@ import {
} from "../../packages/gateway-protocol/src/index.js";
import { readAcpSessionMetaForEntry } from "../acp/runtime/session-meta.js";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
-import {
- normalizeInheritedToolAllowlist,
- normalizeInheritedToolDenylist,
-} from "../agents/inherited-tool-deny.js";
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js";
import {
@@ -40,7 +36,6 @@ import type { SessionEntry } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeExecTarget } from "../infra/exec-approvals.js";
import {
- isAcpSessionKey,
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
@@ -74,6 +69,7 @@ import {
shouldPreserveSessionAuthProfileOverride,
snapshotAgentModelFallback,
} from "./session-model-patch-origin.js";
+import { applySessionsPatchSubagentPolicy } from "./sessions-patch-subagent-policy.js";
function invalid(message: string): { ok: false; error: ErrorShape } {
return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) };
@@ -127,26 +123,6 @@ function normalizeExecAsk(raw: string): "off" | "on-miss" | "always" | undefined
return undefined;
}
-function supportsSpawnLineage(storeKey: string): boolean {
- return isSubagentSessionKey(storeKey) || isAcpSessionKey(storeKey);
-}
-
-function normalizeSubagentRole(raw: string): "orchestrator" | "leaf" | undefined {
- const normalized = normalizeOptionalLowercaseString(raw);
- if (normalized === "orchestrator" || normalized === "leaf") {
- return normalized;
- }
- return undefined;
-}
-
-function normalizeSubagentControlScope(raw: string): "children" | "none" | undefined {
- const normalized = normalizeOptionalLowercaseString(raw);
- if (normalized === "children" || normalized === "none") {
- return normalized;
- }
- return undefined;
-}
-
type SessionPatchProjectionEntry = {
entry: SessionEntry;
sessionKey: string;
@@ -238,185 +214,14 @@ export async function projectSessionsPatchEntry(params: {
delete next.displayName;
}
- type PatchError = ReturnType | null;
- const checkSpawnLineage = (field: string): PatchError =>
- supportsSpawnLineage(storeKey)
- ? null
- : invalid(`${field} is only supported for subagent:* or acp:* sessions`);
- const applyImmutableString = (
- field: "spawnedBy" | "completionOwnerSessionKey" | "spawnedWorkspaceDir" | "spawnedCwd",
- checkLineageBeforeEmpty: boolean,
- ): PatchError => {
- if (!(field in patch)) {
- return null;
- }
- const raw = patch[field];
- if (raw === null) {
- return existing?.[field] ? invalid(`${field} cannot be cleared once set`) : null;
- }
- if (raw === undefined) {
- return null;
- }
- const earlyLineage = checkLineageBeforeEmpty ? checkSpawnLineage(field) : null;
- if (earlyLineage) {
- return earlyLineage;
- }
- const trimmed = normalizeOptionalString(raw) ?? "";
- if (!trimmed) {
- return invalid(`invalid ${field}: empty`);
- }
- const lateLineage = checkLineageBeforeEmpty ? null : checkSpawnLineage(field);
- if (lateLineage) {
- return lateLineage;
- }
- if (existing?.[field] && existing[field] !== trimmed) {
- return invalid(`${field} cannot be changed once set`);
- }
- next[field] = trimmed;
- return null;
- };
- const applyImmutableNormalized = (
- field: T,
- normalize: (raw: string) => NonNullable | undefined,
- invalidMessage: string,
- ): PatchError => {
- if (!(field in patch)) {
- return null;
- }
- const raw = patch[field];
- if (raw === null) {
- return existing?.[field] ? invalid(`${field} cannot be cleared once set`) : null;
- }
- if (raw === undefined) {
- return null;
- }
- const lineage = checkSpawnLineage(field);
- if (lineage) {
- return lineage;
- }
- const normalized = normalize(raw);
- if (!normalized) {
- return invalid(invalidMessage);
- }
- if (existing?.[field] && existing[field] !== normalized) {
- return invalid(`${field} cannot be changed once set`);
- }
- next[field] = normalized;
- return null;
- };
-
- for (const fieldParams of [
- { field: "spawnedBy" as const, checkLineageBeforeEmpty: false },
- { field: "completionOwnerSessionKey" as const, checkLineageBeforeEmpty: false },
- { field: "spawnedWorkspaceDir" as const, checkLineageBeforeEmpty: true },
- { field: "spawnedCwd" as const, checkLineageBeforeEmpty: true },
- ]) {
- const result = applyImmutableString(fieldParams.field, fieldParams.checkLineageBeforeEmpty);
- if (result) {
- return result;
- }
- }
-
- if ("spawnDepth" in patch) {
- const raw = patch.spawnDepth;
- if (raw === null) {
- if (typeof existing?.spawnDepth === "number") {
- return invalid("spawnDepth cannot be cleared once set");
- }
- } else if (raw !== undefined) {
- if (!supportsSpawnLineage(storeKey)) {
- return invalid("spawnDepth is only supported for subagent:* or acp:* sessions");
- }
- const numeric = raw;
- if (!Number.isInteger(numeric) || numeric < 0) {
- return invalid("invalid spawnDepth (use an integer >= 0)");
- }
- const normalized = numeric;
- if (typeof existing?.spawnDepth === "number" && existing.spawnDepth !== normalized) {
- return invalid("spawnDepth cannot be changed once set");
- }
- next.spawnDepth = normalized;
- }
- }
-
- for (const fieldParams of [
- {
- field: "subagentRole" as const,
- normalize: normalizeSubagentRole,
- invalidMessage: 'invalid subagentRole (use "orchestrator" or "leaf")',
- },
- {
- field: "subagentControlScope" as const,
- normalize: normalizeSubagentControlScope,
- invalidMessage: 'invalid subagentControlScope (use "children" or "none")',
- },
- ]) {
- const result = applyImmutableNormalized(
- fieldParams.field,
- fieldParams.normalize,
- fieldParams.invalidMessage,
- );
- if (result) {
- return result;
- }
- }
-
- if ("inheritedToolPolicyVersion" in patch) {
- const raw = patch.inheritedToolPolicyVersion;
- if (raw === null) {
- if (existing?.inheritedToolPolicyVersion !== undefined) {
- return invalid("inheritedToolPolicyVersion cannot be cleared once set");
- }
- } else if (raw !== undefined) {
- const lineage = checkSpawnLineage("inheritedToolPolicyVersion");
- if (lineage) {
- return lineage;
- }
- if (raw !== 1) {
- return invalid("invalid inheritedToolPolicyVersion (expected 1)");
- }
- next.inheritedToolPolicyVersion = 1;
- }
- }
-
- if ("inheritedToolDeny" in patch) {
- const raw = patch.inheritedToolDeny;
- if (raw === null) {
- delete next.inheritedToolDeny;
- } else if (raw !== undefined) {
- if (!Array.isArray(raw)) {
- return invalid("invalid inheritedToolDeny (use an array of tool names)");
- }
- if (!supportsSpawnLineage(storeKey)) {
- return invalid("inheritedToolDeny is only supported for subagent:* or acp:* sessions");
- }
- const inheritedToolDeny = normalizeInheritedToolDenylist(raw);
- if (inheritedToolDeny.length > 0) {
- next.inheritedToolDeny = inheritedToolDeny;
- } else {
- delete next.inheritedToolDeny;
- }
- }
- }
-
- if ("inheritedToolAllow" in patch) {
- const raw = patch.inheritedToolAllow;
- if (raw === null) {
- delete next.inheritedToolAllow;
- } else if (raw !== undefined) {
- if (!Array.isArray(raw)) {
- return invalid("invalid inheritedToolAllow (use an array of tool names)");
- }
- if (!supportsSpawnLineage(storeKey)) {
- return invalid("inheritedToolAllow is only supported for subagent:* or acp:* sessions");
- }
- const inheritedToolAllow = normalizeInheritedToolAllowlist(raw);
- if (inheritedToolAllow.length > 0) {
- next.inheritedToolAllow = inheritedToolAllow;
- } else {
- delete next.inheritedToolAllow;
- }
- }
+ const subagentPolicyError = applySessionsPatchSubagentPolicy({
+ existing,
+ next,
+ patch,
+ storeKey,
+ });
+ if (subagentPolicyError) {
+ return invalid(subagentPolicyError);
}
if ("label" in patch) {
@@ -866,4 +671,3 @@ export async function applySessionsPatchToStore(params: {
}
return projected;
}
-/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
diff --git a/src/plugins/runtime/runtime-agent.test.ts b/src/plugins/runtime/runtime-agent.test.ts
index 2728461a29db..1a670e4f836c 100644
--- a/src/plugins/runtime/runtime-agent.test.ts
+++ b/src/plugins/runtime/runtime-agent.test.ts
@@ -145,6 +145,9 @@ describe("plugin runtime session creation", () => {
},
});
expect(created.entry).toMatchObject({
+ createdVia: "plugin",
+ createdActor: { type: "system", id: "anthropic" },
+ createdAt: expect.any(Number),
pluginOwnerId: "anthropic",
providerOverride: "claude-cli",
modelOverride: "claude-opus-4-8",
diff --git a/src/plugins/runtime/runtime-agent.ts b/src/plugins/runtime/runtime-agent.ts
index 6acdde6dae5d..949c3b0e44f0 100644
--- a/src/plugins/runtime/runtime-agent.ts
+++ b/src/plugins/runtime/runtime-agent.ts
@@ -303,6 +303,13 @@ async function createSessionEntry(
},
...(harnessInitial ? { authorizedAgentHarnessId: harnessInitial.agentHarnessId } : {}),
...(cliInitial?.pluginOwnerId ? { authorizedPluginId: cliInitial.pluginOwnerId } : {}),
+ creation: {
+ via: "plugin",
+ actor: {
+ type: "system",
+ ...(cliInitial?.pluginOwnerId ? { id: cliInitial.pluginOwnerId } : {}),
+ },
+ },
commandSource: "plugin-runtime",
...(afterCreate ? { afterCreate: runAfterCreate } : {}),
});
diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts
index 30e5f9a2e3e2..d62c404a2d6a 100644
--- a/src/plugins/session-entry-slot-keys.ts
+++ b/src/plugins/session-entry-slot-keys.ts
@@ -14,7 +14,6 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
"pluginExtensionSlotKeys",
"pluginNextTurnInjections",
"sessionId",
- "createdBy",
"lifecycleRevision",
"updatedAt",
"archivedAt",
@@ -32,6 +31,11 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
"spawnedCwd",
"worktree",
"parentSessionKey",
+ "createdVia",
+ "createdActor",
+ "createdAt",
+ "forkSource",
+ "previousSessionId",
"forkedFromParent",
"spawnDepth",
"swarmGroupId",
diff --git a/src/sessions/session-state-event-kinds.ts b/src/sessions/session-state-event-kinds.ts
index 1187743c5070..aa7caa4a3215 100644
--- a/src/sessions/session-state-event-kinds.ts
+++ b/src/sessions/session-state-event-kinds.ts
@@ -1,6 +1,7 @@
export type SessionStateActorType = "human" | "agent" | "system";
export type SessionStateEventKind =
+ | "created"
| "human_direct_message"
| "adopted"
| "run_completed"
@@ -12,6 +13,7 @@ export type SessionStateEventKind =
// Future utility-model materiality belongs at this deterministic seam; no config until then.
export const NOTIFY_BY_SESSION_STATE_EVENT_KIND: Record = {
+ created: false,
human_direct_message: true,
upstream_missing: true,
adopted: false,
diff --git a/src/sessions/session-state-events.test.ts b/src/sessions/session-state-events.test.ts
index e7de515813c8..3c25b46dad51 100644
--- a/src/sessions/session-state-events.test.ts
+++ b/src/sessions/session-state-events.test.ts
@@ -22,6 +22,7 @@ import {
listAmbientGroupWatchTargets,
listSessionStateEventsSince,
recordSessionCompacted,
+ recordSessionCreated,
recordSessionGoalChanged,
recordSessionHumanDirectMessage,
recordSessionStateEvent,
@@ -776,6 +777,17 @@ describe("session state events", () => {
it("projects spawn, terminal, goal, and compaction producer helpers", () => {
const database = createDatabaseOptions();
+ recordSessionCreated({
+ sessionKey: child,
+ agentId: "main",
+ entry: {
+ sessionId: "session-child",
+ updatedAt: Date.now(),
+ createdVia: "spawn",
+ createdActor: { type: "agent", id: watcher },
+ createdAt: Date.now(),
+ },
+ });
recordSubagentSpawned({
childSessionKey: child,
childRunId: "run-child",
@@ -823,13 +835,19 @@ describe("session state events", () => {
const events = listSessionStateEventsSince(child, "main", 0, 200, database).events;
expect(events.map((event) => event.kind)).toEqual([
+ "created",
"child_spawned",
"run_completed",
"run_failed",
"goal_changed",
"compacted",
]);
- expect(events[2]).toMatchObject({
+ expect(events[0]).toMatchObject({
+ actorType: "agent",
+ actorId: watcher,
+ summary: "session created",
+ });
+ expect(events[3]).toMatchObject({
runId: "run-child-cancelled",
summary: "child run cancelled",
payload: { outcome: "cancelled" },
diff --git a/src/sessions/session-state-events.ts b/src/sessions/session-state-events.ts
index 884641f1cacd..0783fb6166ef 100644
--- a/src/sessions/session-state-events.ts
+++ b/src/sessions/session-state-events.ts
@@ -790,6 +790,28 @@ export function recordSessionGoalChanged(params: {
});
}
+/** Record the child's own creation fact when its durable stamp identifies an actor. */
+export function recordSessionCreated(params: {
+ sessionKey: string;
+ entry: SessionEntry;
+ agentId?: string;
+}): void {
+ const actor = params.entry.createdActor;
+ if (!actor) {
+ return;
+ }
+ recordSessionStateEvent({
+ sessionKey: params.sessionKey,
+ sessionId: params.entry.sessionId,
+ agentId: params.agentId ?? resolveAgentIdFromSessionKey(params.sessionKey),
+ kind: "created",
+ actorType: actor.type,
+ ...(actor.id ? { actorId: actor.id } : {}),
+ dedupeKey: `created:${params.agentId ?? resolveAgentIdFromSessionKey(params.sessionKey)}:${params.sessionKey}:${params.entry.sessionId}`,
+ summary: "session created",
+ });
+}
+
/** True when any seeded or explicitly registered watcher cursor targets this session. */
function hasSessionStateWatchers(
targetSessionKey: string,
diff --git a/src/shared/session-types.ts b/src/shared/session-types.ts
index 63bc2513831b..68aadbab4800 100644
--- a/src/shared/session-types.ts
+++ b/src/shared/session-types.ts
@@ -1,5 +1,3 @@
-import type { SessionCreatorIdentity } from "../../packages/gateway-protocol/src/schema/sessions.js";
-
/** Agent identity fields returned by gateway session listing APIs. */
type GatewayAgentIdentity = {
name?: string;
@@ -64,7 +62,7 @@ export type SessionsListResultBase = {
nextOffset?: number | null;
hasMore?: boolean;
/** Complete creator facet for the filtered result, independent of pagination. */
- creators?: SessionCreatorIdentity[];
+ creators?: Array<{ id: string; label?: string }>;
defaults: TDefaults;
sessions: TRow[];
};
diff --git a/src/state/openclaw-agent-db-schema.ts b/src/state/openclaw-agent-db-schema.ts
index e3487bbba3d9..461532e4aa7e 100644
--- a/src/state/openclaw-agent-db-schema.ts
+++ b/src/state/openclaw-agent-db-schema.ts
@@ -252,15 +252,6 @@ function migrateOpenClawAgentSchema(db: DatabaseSync): void {
backfillTranscriptMutationWatermarks(db);
}
-function ensureAdditiveSessionEntryColumns(db: DatabaseSync): void {
- const columns = readSqliteTableColumns(db, "session_entries");
- if (columns && !columns.has("created_by_json")) {
- // This nullable projection is safe for older readers and intentionally
- // stays outside the schema-version migration ladder.
- db.exec("ALTER TABLE session_entries ADD COLUMN created_by_json TEXT;");
- }
-}
-
/** Backfill one generation token without copying or rewriting transcript rows. */
function migrateSessionTranscriptGenerations(db: DatabaseSync, previousVersion: number): void {
if (previousVersion >= 13) {
@@ -522,7 +513,6 @@ function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string):
dropLegacySessionTranscriptSearchSchema(db);
migrateMemoryIndexSourcesIdentity(db);
migrateOpenClawAgentSchema(db);
- ensureAdditiveSessionEntryColumns(db);
db.exec(
previousVersion === OPENCLAW_AGENT_SCHEMA_VERSION
? OPENCLAW_AGENT_SCHEMA_WITHOUT_BOARD_SQL
diff --git a/src/state/openclaw-agent-db.generated.d.ts b/src/state/openclaw-agent-db.generated.d.ts
index c8e4a758eae3..d125b098dade 100644
--- a/src/state/openclaw-agent-db.generated.d.ts
+++ b/src/state/openclaw-agent-db.generated.d.ts
@@ -186,7 +186,6 @@ export interface SessionConversations {
}
export interface SessionEntries {
- created_by_json: string | null;
entry_json: string;
session_id: string;
session_key: string;
diff --git a/src/state/openclaw-agent-db.test.ts b/src/state/openclaw-agent-db.test.ts
index d845c1de94b3..609d3266dfd6 100644
--- a/src/state/openclaw-agent-db.test.ts
+++ b/src/state/openclaw-agent-db.test.ts
@@ -1820,30 +1820,6 @@ describe("openclaw agent database", () => {
expect(journalMode?.journal_mode?.toLowerCase()).toBe("wal");
});
- it("lazy-ensures the additive session creator column without a version bump", () => {
- const stateDir = createTempStateDir();
- const env = { OPENCLAW_STATE_DIR: stateDir };
- const database = openOpenClawAgentDatabase({ agentId: "worker-1", env });
- const databasePath = database.path;
- const schemaVersion = readSqliteNumberPragma(database.db, "user_version");
- closeOpenClawAgentDatabasesForTest();
-
- const { DatabaseSync } = requireNodeSqlite();
- const legacy = new DatabaseSync(databasePath);
- try {
- legacy.exec("ALTER TABLE session_entries DROP COLUMN created_by_json;");
- } finally {
- legacy.close();
- }
-
- const reopened = openOpenClawAgentDatabase({ agentId: "worker-1", env });
- const columns = reopened.db.prepare("PRAGMA table_info(session_entries)").all() as Array<{
- name: string;
- }>;
- expect(columns.map((column) => column.name)).toContain("created_by_json");
- expect(readSqliteNumberPragma(reopened.db, "user_version")).toBe(schemaVersion);
- });
-
it("backfills per-entry status while migrating a v6 agent database", () => {
const stateDir = createTempStateDir();
const env = { OPENCLAW_STATE_DIR: stateDir };
diff --git a/src/state/openclaw-agent-schema.generated.ts b/src/state/openclaw-agent-schema.generated.ts
index 24ab105185a3..254f47f65712 100644
--- a/src/state/openclaw-agent-schema.generated.ts
+++ b/src/state/openclaw-agent-schema.generated.ts
@@ -169,7 +169,6 @@ CREATE TABLE IF NOT EXISTS session_entries (
entry_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
status TEXT CHECK (status IS NULL OR status IN ('running', 'done', 'failed', 'killed', 'timeout')),
- created_by_json TEXT,
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
) STRICT;
diff --git a/src/state/openclaw-agent-schema.sql b/src/state/openclaw-agent-schema.sql
index ab50e44ea749..afdf06a78c27 100644
--- a/src/state/openclaw-agent-schema.sql
+++ b/src/state/openclaw-agent-schema.sql
@@ -164,7 +164,6 @@ CREATE TABLE IF NOT EXISTS session_entries (
entry_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
status TEXT CHECK (status IS NULL OR status IN ('running', 'done', 'failed', 'killed', 'timeout')),
- created_by_json TEXT,
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
) STRICT;
diff --git a/src/talk/agent-consult-runtime.test.ts b/src/talk/agent-consult-runtime.test.ts
index 3e26d6661bb3..557a832e110e 100644
--- a/src/talk/agent-consult-runtime.test.ts
+++ b/src/talk/agent-consult-runtime.test.ts
@@ -49,6 +49,9 @@ function createAgentRuntime(payloads: unknown[] = [{ text: "Speak this." }]) {
{
sessionId?: string;
updatedAt?: number;
+ createdVia?: SessionEntry["createdVia"];
+ createdActor?: SessionEntry["createdActor"];
+ createdAt?: number;
archivedAt?: number;
sessionFile?: string;
spawnedBy?: string;
@@ -239,7 +242,14 @@ describe("realtime voice agent consult runtime", () => {
if (!voiceSession) {
throw new Error("Expected voice consult session entry");
}
- expect(Object.keys(voiceSession).toSorted()).toStrictEqual(["sessionId", "updatedAt"]);
+ expect(Object.keys(voiceSession).toSorted()).toStrictEqual([
+ "createdAt",
+ "createdVia",
+ "sessionId",
+ "updatedAt",
+ ]);
+ expect(voiceSession.createdVia).toBe("talk");
+ expectPositiveTimestamp(voiceSession.createdAt);
expectNonEmptyString(voiceSession.sessionId);
expectPositiveTimestamp(voiceSession.updatedAt);
const call = requireEmbeddedAgentCall(runEmbeddedAgent);
@@ -551,6 +561,9 @@ describe("realtime voice agent consult runtime", () => {
sessionFile: testTempPath("forked.jsonl"),
spawnedBy: "agent:main:main",
forkedFromParent: true,
+ createdVia: "talk",
+ createdActor: { type: "agent", id: "agent:main:main" },
+ createdAt: forkedEntry.createdAt,
updatedAt: forkedEntry.updatedAt,
});
expectPositiveTimestamp(forkedEntry.updatedAt);
@@ -668,6 +681,9 @@ describe("realtime voice agent consult runtime", () => {
expect(voiceEntry).toStrictEqual({
sessionId: voiceEntry.sessionId,
spawnedBy: "agent:main:discord:channel:123",
+ createdVia: "talk",
+ createdActor: { type: "agent", id: "agent:main:discord:channel:123" },
+ createdAt: voiceEntry.createdAt,
deliveryContext: {
channel: "discord",
to: "channel:123",
diff --git a/src/talk/agent-consult-runtime.ts b/src/talk/agent-consult-runtime.ts
index fb6f4ef8ba9f..f8343fec9bdd 100644
--- a/src/talk/agent-consult-runtime.ts
+++ b/src/talk/agent-consult-runtime.ts
@@ -4,6 +4,7 @@ import { resolveDefaultAgentId } from "../agents/agent-scope-config.js";
import type { RunEmbeddedAgentParams } from "../agents/embedded-agent-runner/run/params.js";
import { forkSessionEntryFromParent } from "../auto-reply/reply/session-fork.js";
import { resolveSessionWorkStartError } from "../config/sessions/lifecycle.js";
+import { buildSessionCreationStamp } from "../config/sessions/session-entry-provenance.js";
import { parseSessionThreadInfoFast } from "../config/sessions/thread-info.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -165,6 +166,10 @@ async function resolveRealtimeVoiceAgentConsultSessionEntry(params: {
const now = Date.now();
const deliveryFields = resolveDeliverySessionFields(params.deliveryContext);
const requesterSessionKey = params.spawnedBy?.trim();
+ const creationStamp = buildSessionCreationStamp({
+ via: "talk",
+ ...(requesterSessionKey ? { actor: { type: "agent" as const, id: requesterSessionKey } } : {}),
+ });
const requesterAgentId = parseAgentSessionKey(requesterSessionKey)?.agentId;
const shouldFork =
params.contextMode === "fork" &&
@@ -181,6 +186,7 @@ async function resolveRealtimeVoiceAgentConsultSessionEntry(params: {
config: params.cfg,
sessionKey: params.sessionKey,
fallbackEntry: {
+ ...creationStamp,
sessionId: "",
updatedAt: now,
},
@@ -206,6 +212,7 @@ async function resolveRealtimeVoiceAgentConsultSessionEntry(params: {
storePath: params.storePath,
sessionKey: params.sessionKey,
fallbackEntry: {
+ ...creationStamp,
sessionId: "",
updatedAt: now,
},
diff --git a/src/talk/client-voice-session.test.ts b/src/talk/client-voice-session.test.ts
index 7222779a5aeb..2d3d2137c599 100644
--- a/src/talk/client-voice-session.test.ts
+++ b/src/talk/client-voice-session.test.ts
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { replaceSessionEntry } from "../config/sessions/session-accessor.js";
+import { loadSessionEntry, replaceSessionEntry } from "../config/sessions/session-accessor.js";
import {
emitTrustedDiagnosticEvent,
waitForDiagnosticEventsDrained,
@@ -14,6 +14,7 @@ import {
closeClientVoiceSession,
closeStaleClientVoiceSessions,
createOrResumeClientVoiceSession,
+ ensureClientVoiceAgentSessionEntry,
isClientVoiceSessionConfirmable,
registerClientVoiceConsultRun,
resolveClientVoiceRunBinding,
@@ -150,6 +151,35 @@ describe("client voice session", () => {
).toThrow("already closed");
});
+ it("stamps the agent session row when Talk creates it", async () => {
+ const sessionKey = "agent:main:talk:new";
+ await ensureClientVoiceAgentSessionEntry({ agentId: "main", sessionKey });
+
+ expect(loadSessionEntry({ agentId: "main", sessionKey })).toMatchObject({
+ createdVia: "talk",
+ createdActor: { type: "human" },
+ createdAt: expect.any(Number),
+ });
+
+ await ensureClientVoiceAgentSessionEntry({ agentId: "main", sessionKey });
+ expect(loadSessionEntry({ agentId: "main", sessionKey })?.createdVia).toBe("talk");
+ });
+
+ it("repairs an incomplete existing row without claiming its creation actor", async () => {
+ const sessionKey = "agent:main:talk:incomplete";
+ await replaceSessionEntry(
+ { agentId: "main", sessionKey },
+ { sessionId: "", updatedAt: 1, createdVia: "internal", createdAt: 1 },
+ );
+
+ await ensureClientVoiceAgentSessionEntry({ agentId: "main", sessionKey });
+
+ const repaired = loadSessionEntry({ agentId: "main", sessionKey });
+ expect(repaired?.sessionId).toBeTruthy();
+ expect(repaired).toMatchObject({ createdVia: "internal", createdAt: 1 });
+ expect(repaired?.createdActor).toBeUndefined();
+ });
+
it("marks confirmability by declared capability, relay origin, or observed transcript", () => {
const capable = createOrResumeClientVoiceSession({
agentId: "main",
diff --git a/src/talk/client-voice-session.ts b/src/talk/client-voice-session.ts
index 049a9146b061..1a0e350ef52f 100644
--- a/src/talk/client-voice-session.ts
+++ b/src/talk/client-voice-session.ts
@@ -2,10 +2,11 @@
import { randomUUID } from "node:crypto";
import {
appendTranscriptMessage,
- loadSessionEntry,
loadSessionEntryReadOnly,
- upsertSessionEntry,
+ patchSessionEntry,
} from "../config/sessions/session-accessor.js";
+import { buildSessionCreationStamp } from "../config/sessions/session-entry-provenance.js";
+import { mergeSessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
onTrustedInternalDiagnosticEvent,
@@ -220,11 +221,19 @@ export async function ensureClientVoiceAgentSessionEntry(params: {
agentId: string;
sessionKey: string;
}): Promise {
- const existing = loadSessionEntry(params);
- if (existing?.sessionId) {
- return;
- }
- const created = await upsertSessionEntry(params, {});
+ const created = await patchSessionEntry(
+ params,
+ (_entry, context) => {
+ if (context.existingEntry?.sessionId) {
+ return null;
+ }
+ if (context.existingEntry) {
+ return { sessionId: randomUUID() };
+ }
+ return buildSessionCreationStamp({ via: "talk", actor: { type: "human" } });
+ },
+ { fallbackEntry: mergeSessionEntry(undefined, {}) },
+ );
if (!created?.sessionId) {
throw new Error(`agent session could not be initialized (${params.sessionKey})`);
}
diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts
index e41489a0434d..3af403a4fa29 100644
--- a/src/tui/embedded-backend.ts
+++ b/src/tui/embedded-backend.ts
@@ -787,6 +787,7 @@ export class EmbeddedTuiBackend implements TuiBackend {
const result = await createGatewaySession({
cfg,
...opts,
+ creation: { via: "operator", actor: { type: "human" } },
emitCommandHooks: Boolean(opts.parentSessionKey),
commandSource: "tui:embedded",
loadGatewayModelCatalog: () => loadEmbeddedTuiModelCatalog(cfg),
diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts
index 40e4d9123286..3bb7cfedb5fa 100644
--- a/test/scripts/package-acceptance-workflow.test.ts
+++ b/test/scripts/package-acceptance-workflow.test.ts
@@ -9,6 +9,7 @@ import {
symlinkSync,
writeFileSync,
} from "node:fs";
+import { resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { parse } from "yaml";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
@@ -43,6 +44,11 @@ const ANDROID_RELEASE_WORKFLOW = ".github/workflows/android-release.yml";
const STABLE_MAIN_CLOSEOUT_WORKFLOW = ".github/workflows/openclaw-stable-main-closeout.yml";
const WINDOWS_NODE_RELEASE_WORKFLOW = ".github/workflows/windows-node-release.yml";
const FULL_RELEASE_VALIDATION_WORKFLOW = ".github/workflows/full-release-validation.yml";
+const REPO_ROOT = process.env.GITHUB_WORKSPACE ?? process.cwd();
+const RELEASE_MAINTAINER_SKILL = resolve(
+ REPO_ROOT,
+ ".agents/skills/release-openclaw-maintainer/SKILL.md",
+);
const QA_LIVE_TRANSPORTS_WORKFLOW = ".github/workflows/qa-live-transports-convex.yml";
const UPDATE_MIGRATION_WORKFLOW = ".github/workflows/update-migration.yml";
const CI_CHECK_TESTBOX_WORKFLOW = ".github/workflows/ci-check-testbox.yml";
@@ -3547,10 +3553,7 @@ describe("package artifact reuse", () => {
it("keeps release publish creation compatible with gh api and prerelease notes", () => {
const workflow = readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8");
const npmWorkflow = readFileSync(".github/workflows/openclaw-npm-release.yml", "utf8");
- const maintainerSkill = readFileSync(
- ".agents/skills/release-openclaw-maintainer/SKILL.md",
- "utf8",
- );
+ const maintainerSkill = readFileSync(RELEASE_MAINTAINER_SKILL, "utf8");
const fullReleaseWorkflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8");
const resolveJob = workflowJob(RELEASE_PUBLISH_WORKFLOW, "resolve_release_target");
const publishJob = workflowJob(RELEASE_PUBLISH_WORKFLOW, "publish");
@@ -3760,10 +3763,7 @@ describe("package artifact reuse", () => {
const releaseWorkflow = readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8");
const windowsWorkflow = readFileSync(WINDOWS_NODE_RELEASE_WORKFLOW, "utf8");
const releaseDocs = readFileSync("docs/reference/RELEASING.md", "utf8");
- const releaseSkill = readFileSync(
- ".agents/skills/release-openclaw-maintainer/SKILL.md",
- "utf8",
- );
+ const releaseSkill = readFileSync(RELEASE_MAINTAINER_SKILL, "utf8");
expect(releaseWorkflow).toContain(
"Stable OpenClaw publish requires an explicit windows_node_tag.",
diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts
index e71aaedcf9f7..1f8b9c133781 100644
--- a/ui/src/api/types.ts
+++ b/ui/src/api/types.ts
@@ -492,8 +492,8 @@ type SessionCompactionCheckpointPreview = Pick<
export type GatewaySessionRow = {
key: string;
- createdBy?: import("../../../packages/gateway-protocol/src/schema/sessions.js").SessionCreatorIdentity;
spawnedBy?: string;
+ controlOwnerSessionKey?: string;
/** Collector swarm group that owns this child session, when applicable. */
swarmGroupId?: string;
parentSessionKey?: string;
@@ -504,8 +504,17 @@ export type GatewaySessionRow = {
spawnedWorkspaceDir?: string;
spawnedCwd?: string;
execCwd?: string;
+ forkedFromParent?: boolean;
+ spawnDepth?: number;
+ subagentRole?: "orchestrator" | "leaf";
+ subagentControlScope?: "children" | "none";
+ createdVia?: "operator" | "spawn" | "channel" | "cron" | "talk" | "run" | "plugin" | "internal";
+ createdActor?: import("../../../packages/gateway-protocol/src/schema/sessions.js").SessionCreatedActor;
+ createdAt?: number;
+ forkSource?: { sessionKey: string; sessionId: string; entryId?: string };
+ previousSessionId?: string;
placement?: import("../../../packages/gateway-protocol/src/index.js").SessionPlacement;
- kind: "cron" | "direct" | "group" | "global" | "unknown";
+ kind: "direct" | "group" | "global" | "unknown";
label?: string;
/** User-defined organization bucket; unrelated to chat-group kind/groupChannel. */
category?: string;
diff --git a/ui/src/components/app-sidebar-session-catalogs.ts b/ui/src/components/app-sidebar-session-catalogs.ts
index 43607e1f852a..be894ad9d6fa 100644
--- a/ui/src/components/app-sidebar-session-catalogs.ts
+++ b/ui/src/components/app-sidebar-session-catalogs.ts
@@ -171,7 +171,7 @@ export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) {
const visibleHosts: SessionCatalogHost[] = [];
for (const host of hosts) {
const sessions = host.sessions.filter(
- (session) => !params.creatorId || session.createdBy?.id === params.creatorId,
+ (session) => !params.creatorId || session.createdActor?.id === params.creatorId,
);
if (sessions.length > 0) {
visibleHosts.push(sessions.length === host.sessions.length ? host : { ...host, sessions });
diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts
index 6ca6f3dc71fe..4b14e98d8c4e 100644
--- a/ui/src/components/app-sidebar-session-navigation.ts
+++ b/ui/src/components/app-sidebar-session-navigation.ts
@@ -157,7 +157,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
}
return {
key: row.key,
- createdBy: row.createdBy,
+ createdActor: row.createdActor,
// The sidebar's zone structure already says what forked from what;
// a "Subagent:" prefix on named threads is noise (other surfaces keep it).
label: resolveSessionDisplayName(row.key, row, {
@@ -612,7 +612,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
mainSessionKeys.has(parentKey) &&
!scopedRootKeys.has(row.key) &&
!row.archived &&
- (this.sessionsShowCron || (row.kind !== "cron" && !isCronSessionKey(row.key)))
+ (this.sessionsShowCron || !isCronSessionKey(row.key))
);
},
);
diff --git a/ui/src/components/app-sidebar-session-ownership.ts b/ui/src/components/app-sidebar-session-ownership.ts
index 541de8d94dc4..a6a9d02ec7fa 100644
--- a/ui/src/components/app-sidebar-session-ownership.ts
+++ b/ui/src/components/app-sidebar-session-ownership.ts
@@ -1,13 +1,17 @@
import { state } from "lit/decorators.js";
import { AppSidebarSessionProjectionElement } from "./app-sidebar-session-projection.ts";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
-import { listSessionCreators, type SessionCreatedBy } from "./session-owner-chip.ts";
+import {
+ listSessionCreators,
+ type SessionCreatedActor,
+ type SessionCreatorOption,
+} from "./session-owner-chip.ts";
/** Creator attribution, solo dormancy, and filtering shared by sidebar session surfaces. */
export abstract class AppSidebarSessionOwnershipElement extends AppSidebarSessionProjectionElement {
@state() protected sessionCreatorFilterId: string | null = null;
- protected sessionCreatorOptions: readonly SessionCreatedBy[] = [];
+ protected sessionCreatorOptions: readonly SessionCreatorOption[] = [];
protected activeSessionCreatorId: string | null = null;
protected sessionCreatorFilterActive = false;
sessionOwnershipVisible = false;
@@ -28,8 +32,8 @@ export abstract class AppSidebarSessionOwnershipElement extends AppSidebarSessio
protected applySessionCreatorFilter(
projected: readonly SidebarRecentSession[],
- creatorRows: readonly { createdBy?: SessionCreatedBy }[] = [],
- creatorFacet?: readonly SessionCreatedBy[],
+ creatorRows: readonly { createdActor?: SessionCreatedActor }[] = [],
+ creatorFacet?: readonly { id: string; label?: string }[],
): SidebarRecentSession[] {
const flattened: SidebarRecentSession[] = [];
const pending = [...projected];
@@ -42,7 +46,9 @@ export abstract class AppSidebarSessionOwnershipElement extends AppSidebarSessio
}
const completeFacet = creatorFacet ?? this.sessionsResult?.creators;
this.sessionCreatorOptions = listSessionCreators([
- ...(completeFacet ?? []).map((createdBy) => ({ createdBy })),
+ ...(completeFacet ?? []).map((creator) => ({
+ createdActor: { type: "human" as const, ...creator },
+ })),
...flattened,
...creatorRows,
]);
@@ -61,7 +67,7 @@ export abstract class AppSidebarSessionOwnershipElement extends AppSidebarSessio
const filtered: SidebarRecentSession[] = [];
for (const row of treeRows) {
const children = filterTree(row.children);
- if (row.createdBy?.id === creatorId) {
+ if (row.createdActor?.id === creatorId) {
filtered.push({ ...row, children });
} else {
for (const child of children) {
diff --git a/ui/src/components/app-sidebar-session-row-render.ts b/ui/src/components/app-sidebar-session-row-render.ts
index 8097496fe47c..730a91620c8c 100644
--- a/ui/src/components/app-sidebar-session-row-render.ts
+++ b/ui/src/components/app-sidebar-session-row-render.ts
@@ -207,7 +207,7 @@ export function renderRecentSession(params: {
@click=${(event: MouseEvent) => host.handleSessionRowClick(event, session)}
>
${renderSessionOwnerChip(
- host.sessionOwnershipVisible ? session.createdBy : undefined,
+ host.sessionOwnershipVisible ? session.createdActor : undefined,
"row",
)}