feat(gateway): session-catalog terminal start plans behind cliAgents gate (#121020)

* feat(gateway): add session-catalog terminal start plans

* refactor(gateway): split catalog terminal start handler

* fix(gateway): enforce catalog terminal start eligibility

* test(gateway): split session catalog snapshot coverage
This commit is contained in:
Peter Steinberger
2026-08-09 08:13:39 -07:00
committed by GitHub
parent 0fe49c5731
commit 9a96375e60
33 changed files with 1547 additions and 372 deletions

View File

@@ -510,6 +510,7 @@ enum class GatewayMethod(
AuditRunInspect("audit.run.inspect"),
SessionsPatchMany("sessions.patchMany"),
UpdateHold("update.hold"),
SessionsCatalogStartTerminal("sessions.catalog.startTerminal"),
}
enum class GatewayEvent(

View File

@@ -4616,6 +4616,70 @@ public struct SessionsCatalogArchiveResult: Codable, Sendable {
}
}
public struct SessionsCatalogStartTerminalParams: Codable, Sendable {
public let catalogid: String
public let hostid: String?
public let agentid: String
public let cwd: String
public let initialmessage: String?
public init(
catalogid: String,
hostid: String? = nil,
agentid: String,
cwd: String,
initialmessage: String? = nil)
{
self.catalogid = catalogid
self.hostid = hostid
self.agentid = agentid
self.cwd = cwd
self.initialmessage = initialmessage
}
private enum CodingKeys: String, CodingKey {
case catalogid = "catalogId"
case hostid = "hostId"
case agentid = "agentId"
case cwd
case initialmessage = "initialMessage"
}
}
public struct SessionsCatalogStartTerminalResult: Codable, Sendable {
public let sessionid: String
public let agentid: String
public let shell: String
public let cwd: String
public let confined: Bool
public let title: String?
public init(
sessionid: String,
agentid: String,
shell: String,
cwd: String,
confined: Bool,
title: String? = nil)
{
self.sessionid = sessionid
self.agentid = agentid
self.shell = shell
self.cwd = cwd
self.confined = confined
self.title = title
}
private enum CodingKeys: String, CodingKey {
case sessionid = "sessionId"
case agentid = "agentId"
case shell
case cwd
case confined
case title
}
}
public struct SessionsCleanupParams: Codable, Sendable {
public let agent: String?
public let allagents: Bool?

View File

@@ -3,22 +3,22 @@ c38b59ef4745b7447295baf17900078bf460fe36d98b3516e0f37aa2c25fda5c module/account
71522995185b956a0cc4927a472cc8d1153e5e998874bfd9a750513175174713 module/account-id
62e64563d598ebb16411a0248da2f2d7fc61ca42602335c54b27586d1a4dc3bd module/account-resolution
3fe118210b885af40088457ed81ffa5ede18c8e695295731a2ee059af46843cc module/agent-config-primitives
bf64b124edf54fac9d0296197299fa8218a369959ccf9ce813e8feca615fad0e module/agent-harness
f4212394d8c3a1955a2c332c6b8f90e8619bbd46ee2f792dc185b957aaae85c9 module/agent-harness-runtime
e70c4e1352947a816b093df26be9a128dd17352f1e11d76925cc96eb5091ec21 module/agent-harness
43cad5e5d2d167a86e7032989d2ebfacfb414b125326e861797acf90827f9e62 module/agent-harness-runtime
773943f0f5cc26d4bfd1dc6cfdac5effc2bec14f1bde927e9e407ab4c9701ba0 module/agent-media-payload
769b2dfc03475942c5e4fcf018de830b17aa3c08eb054700c774a15bfb2d2cc5 module/agent-runtime
70104561669cc51fcaf4d7a9cba21cb81bfa4448a67edb65e731397e1a4c7b12 module/agent-runtime
241d467d0af5f81d8a535fe0c65d226356daf8325a037cb23092b8dd82aaa456 module/agent-scope-runtime
8fecb210e22bce4532b6ab649b09465f0bd2c857a44abf40db7d683d6491e6da module/allow-from
ad09805cfb46d6155fb54807ea9794ec3980a5eac62a9d1e4d718dae8d0b61df module/allowlist-config-edit
05e9015f0b462f67be33831ca58e31af3cf99a13ec103a00c3111bc1eff989b0 module/approval-auth-runtime
f06655c8a4524497abe4fac09808a3c18c10260fdcf90f97698efce6cf5d62cf module/approval-client-runtime
5cdacacc7cb9950bd8fa3b4332691adf36e69cd3f9e2928dc59e70897560f86e module/approval-delivery-runtime
2d670f3e370ce6e03937b7ac8502d546bf0a4903b2f93c3957bd5ef39e136b24 module/approval-gateway-runtime
0efcfa871dfeaa2f4563ca5d178fc4df4c1a397c635e1d0d802735073875afb2 module/approval-gateway-runtime
5da9a30393531c72d5df3ea256eaec71ca7ad323ffe9539d27a7bfc4c75d279f module/approval-handler-adapter-runtime
d38d69f95305ddaad8f059d16f6593938ef385991b00f0909434d0855c1e769c module/approval-handler-runtime
f09c3f27c32752980a135018353c07260153c3df7601da39fe7326eeafe515e9 module/approval-handler-runtime
040dd9bbd3d1235c4679fcf219bdca65551f014b6ef08eb94935399f3d83972f module/approval-native-runtime
573a09bfa745b9128aeb73cf736ff0e5718c51ea5fad050bae795ed31c8120f3 module/approval-reply-runtime
de70e007063b51cef54bf7c48150c2eb295d4a14c9c2a60409d3dc036bdc0f9f module/approval-runtime
8209d1ee046f37bbbdeefe2733a560f3e2762dc0e2b188399fc8147f35a9391a module/approval-runtime
01ca912836b8dec672f705e294f72d346e778557e4c591317d67558ea7669c0b module/archive
d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean-param
39769190fc9d790ae5e2c4ebd5de0a78b45b3d529aaf264c84eba4e7f86a3066 module/channel-actions
@@ -26,19 +26,19 @@ d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean
c2cc71d5070b6071c51248b0648d1ad1a9468d3737df890adc77ec02025e8853 module/channel-config-primitives
76ad615d374431580ea1755594e2ce3ce047ac1de9fd4621053dca0fbc3afc4d module/channel-config-schema
cd873744a01a47c5284227d8af5c50d2f6606a815e8586e11c2012f9c301429a module/channel-contract
f57191ecbc801dbce50e4b3f0fa449de07663280b405dd29d742cba093c1bbde module/channel-core
55b8b0cd777348526de7cdfaa3085968fd60305d1cf0f0b9c3ee2a21a1db583b module/channel-core
caa54fde5a2a515491019ec163ed278e09bdc95b9de9926aff04c1743ca0c966 module/channel-dm-policy
bccffec9da8f7c58dfcf765516b272ecacb343f00828ee7ea1eaf4bda7452f4e module/channel-entry-contract
f300dd4668c35a474ae98d4114fe0e871fde05ae2a1f8f28d775a36e8e08e375 module/channel-entry-contract
2c55b3f3d1d275f760a7e1c78764e4030e7a06c4273a4d51d8f3c82b55ea818d module/channel-feedback
1d3056a8709387121c4e99bc8744847f5742cd95e6265a8f3434ce82331fd7e0 module/channel-inbound
819b60a7f7d9cf2e99525a30bdc09a30bc9ec4669d63a91b17f026972d8d17bb module/channel-inbound
d7e21bb831ad5125e6498e88fcc27e820ed296784eb2296c22795c08d55ade06 module/channel-inbound-debounce
79a8f244b0627ce4b601231bf71f0ee539f6ac7692e490847ec8e7ace7d29f1c module/channel-ingress-runtime
e8e08fd9dfcc15758c552fd594055cde15457fac07688553e613de27a6909d98 module/channel-lifecycle
0e47457e38d1df0bd572e1408cde2ca6a788b65205f43c585316b5ad3a8f2f16 module/channel-logging
7db3229db75404d4051268d0586d6d21942768d840907038722aa9098a057c72 module/channel-message
00b3eb03a22523e799d8112ee6e0d60f490082e3347a3c19a9ef6edf4f837290 module/channel-outbound
eef03825bb6d321ed4390a9379eaec19b7d3a5440b644589e380363bc3763953 module/channel-pairing
d7207ec3dfe8622ae1ce4298b42a370362dc35387a7073d91689becc9ea50b81 module/channel-plugin-common
3a1d01d678225010da678942e47b1b3a8c000bdf5fa70a75a5d7bd737b4f3c6a module/channel-message
762bdfdfabc44dcef108bc70be716e40c7937ae58090af68fc0c181fc85555fa module/channel-outbound
20fe9fc24997d3e6b04565058bfb52626ad2d07136ef35194b06911750fbb553 module/channel-pairing
8d56978df5534615b43f0ec7afc57edc6f9d01c0f31fed1afd75f5db9af85f6d module/channel-plugin-common
39e460870b572913c321680b6a4ca2fed963b8c8c71bc2de9193ec68b74ef12b module/channel-policy
dd291daf278dbe9110ad9007048830b6f0bd6701dca09e25094ce98e4642d972 module/channel-reply-pipeline
482370e60135db9bfaf07f24bab549e5fde09ab265a6061a1f587c5d93929e91 module/channel-runtime-context
@@ -51,8 +51,8 @@ e99d6f57a89503e67035da553b0ae62b893722e564031c050f3097cf5fa3bf5f module/channel
67df67da5ae72e9eaeb19d41b6bd2432ec4fd8b7b63b2b616fb98f3b4e0ec41d module/channel-streaming-config
1303df5cc58539c6941e2cd159c93259804c925795219f1630f4d740896a77c1 module/cli-argv
ad12670dbfe538f8d0ebf4fb2b68080e93a760278278e6b1ce9bb129d4b2d533 module/collection-runtime
0883877ab26a6cdc30d31281d2b7aafd075657d7885f16de45fc35872d0ebb97 module/command-auth
eb0803d88af9a0fd8b41d8633ef1f784b9189b8ecaf71fdfb5c86571d54ba3ab module/command-auth-native
e6b30586e42bdad2a14d2c9739c00e5b3a50e54e20a83372fec9654ae3381d23 module/command-auth
ea7eb2d2d38c9ff95f15350c57576635daca0bd2e079f3a39b648e4c863376f4 module/command-auth-native
a41d9effc1656cbc131611099bdeb81423ab48adce8c1b1ba07100dff8d32818 module/command-detection
31044216c6495728a36dbe60da31f7c1ede2b069cd0d0685d887ebca31814d5f module/command-primitives-runtime
e42cc234ace96a64cec0bdb8051ddded77618e384ae2bbce8b633bf35dbd0288 module/command-status
@@ -60,23 +60,23 @@ e42cc234ace96a64cec0bdb8051ddded77618e384ae2bbce8b633bf35dbd0288 module/command
5e866c4f8dea30045a8a94e037f0e202d597afb1ca221d9854b15bc416503643 module/config-mutation
a5328945c964794236201aa2c947d783f210f49af34d213d5a188b1e6c40b1a4 module/config-runtime
c6d742c9e6027647502399560b4264bed80bb8c069c2d08ba8527336f58dc13b module/conversation-runtime
258c06ace77a84ae28d2976bab77dd7b28b48e9e670f5cab536e3ba87bf87767 module/core
f0802bd2172418d41e4aebafe6a35fc37daab8cd3620f6d73d0cb510e8ca23e8 module/dedupe-runtime
387f3cb6ffa65b076f724b5ec45e8f0ace7db912831eba08b156936878580066 module/core
aac69e9d9382eb40d93ef07d906edfe7c64d7dfa259798f961e3bb3e1b6daa43 module/dedupe-runtime
ebef0e650ab45e44c9335e2b3e15588c968cea6dadd125364a076f9c50ad1e8c module/device-bootstrap
fd7c489415aa272af724ac15e95d297eac0edeb229e30d02c4061d5276f874b2 module/diagnostic-runtime
6dba2e37cfd962cc8c0aaae2d74d82407fa660fa56a4cf146b8674dc64239608 module/directory-runtime
3dcd8f6f7f65ddc2532ab2deea93272db066ca707ae55a91381d470e0cefdd20 module/discord
60836fc08d3272564460939f1f5a5485d3d396bf6001766efb4d2672e6452e1b module/discord
c468c0ca5e5fc093ef5bd0cd15c57e19059bbe5c453d68f2664e9aaa35661cab module/error-runtime
6d9b6396888d7cddded108e211053559b10d79397d20098e0616767be4a4bfb3 module/extension-shared
dd9f6e0fd33cc88b22543c1ee30cc09cf4de4d8f30dff7b7f9cebef885c21543 module/gateway-method-runtime
7bd8de3a6bfa3c0a3d7a6c4e38bb7fc31ebe25e275d126be02cc4c9ccd0a4de8 module/gateway-runtime
371a254cbc44fc6e63e8cc2b61de26f39455976701a44cbbf4c206aa05af5f27 module/gateway-runtime
575656e5e0195c8d1813a4e2e3a271e800bb97d44f2dd78c242e3b6714ffa097 module/group-access
92566a68cbf1c635fe3dc29afcab042e2b06aaaa0438c8cbd2c001b07730aa9c module/health
70abcc263a1f320faf7e589ba238bb1b4c7a602d52af42461420523460804aec module/hook-runtime
185a5acedbd7f1a73e5cc773e22bf494b124a09bcd68b5a756ff0f881abf431b module/inbound-envelope
4928af5d2509f696b896f53ac790303a0742202dbcdae3e44fe6d1b434a9c1ba module/inbound-event-delivery
0a8fe52421723605b7d882b3d13f4be7ec10cd9e7e4d0ac01ff453f18e7867ba module/inbound-reply-dispatch
05df434589f317935ef1c251cf95c18406455f04c6c573daed0289ca629c4f22 module/infra-runtime
dd1f0078bff0f6f7aed724c1fba25d68217c00b6f662d716bc9467b38f29166a module/inbound-reply-dispatch
ee37c72512c245c1eb9637e8e9a788f128749fcb5d0308d45fa442a45dfc18c5 module/infra-runtime
ce73721421f1b903dd04ead4df173582e59ea3e9990248102c448b419cc6d272 module/ingress-effect-once
dd7a5a732737c74cfe287ab40d62dd380ab4a3b78eeea6cd52574c0613a874cf module/interactive-runtime
408d257ab5cc4b88a22b7e7595039cb8fc524b261c44141b294fbd0100ba62ee module/json-store
@@ -89,28 +89,28 @@ dfddf0032904cf003c325578bc4b2789b2b695c4f7aaa9d08d2f932df30477cb module/media-r
6a52f93107335f88751704352cc01e62add06f854a5b7d765e2a5ee87c0313b6 module/media-store
7dd4a69b33196e946b03a3579e3b569176ff32db22fa668d5eacc06227574cd1 module/media-understanding
151c5fb10718764a03a913b3d6a8a6716cdc12fb95010f306b228a512d3a3d91 module/media-understanding-runtime
591bd88843dcdc21967d6098dad5489e41e15b70a8fca08e5fc5634b56fba71e module/meeting-runtime
9c88de9566447f79a9e784fde80534da1081c828bff98e79ad1a08651661b04c module/meeting-runtime
d16cbced4f2e6672ac9a032ac41691fe7ff4994e328d44ed6ac8a46dbbec834f module/memory-core-host-engine-foundation
5869a92060114a270f02b76972999d9ba5b31d4d755b0aedf2d4808e50202490 module/memory-host-core
0cc997644fdc0047cd6bfb12716c8b7abe85cd7bdb167f196efff835c09aa334 module/memory-host-core
1efa0aadc4261d1c6073058cbf3dcc9fa681424819bdd14333e19b249bbc4b18 module/messaging-targets
a3c8b86036354d27ae5c669502de05b823f3fb5fe4ef06a4defd4ed1fbcbf9a0 module/model-session-runtime
0372d5d52682bcec06b1c5736276f9b8f54147107f0957fdb80858ca27bdc57f module/models-provider-runtime
b5d10ad7f079481ff4f8e67d2213fa5dc12a8d312dbdc124e41c692a4efc78e8 module/models-provider-runtime
06b3bd19f3dde06b77cb1b0e8e389a0bc69b90f2aca4da86ddbc0e29ef8a33d8 module/native-command-config-runtime
faaa22538f3459cef412c52088cb40dc732aa560fba2e70655938460022287d0 module/native-command-registry
5b968ecbef95fda927d0944409994e355ec878608dd084358970078d2ac545a9 module/param-readers
ca7a56bb1a6169b4cf9befbf5aa21da280a8086fdc49fca4eec520a7a7c98549 module/persistent-dedupe
1bf4d4dfe5a4b264cf6fb8fbd0c7bc76f520ff9845cffad6da4b3a3c2bc3f6f6 module/plugin-config-runtime
a8b04f9efdf7a5923e20dd87579b608f5ffcc925d8587dbbc224a1ac1da44f63 module/plugin-entry
0cb30aac2840b9cb54032e745f6a354c0366852436232aa43dc5626b571c5ba1 module/plugin-runtime
b7b684a81f769f63ff2ec6ab515ea0337339d9f166fc7b3945e65e0bdac4e100 module/provider-auth
395a600ca6fb645f1be130a95cdb834c83073dd36fd98639739107fc48396b29 module/provider-catalog-runtime
86c083e3829e5e9dd11e8b65be31a13fd603a28b564112bb7825fcb16381cda7 module/plugin-entry
7a860d980c9ad73a4286587dd8e2dc7952c16094e15568c444271c0e5b81569f module/plugin-runtime
6a5672fbdf989aaa819cead8f030aaa8a72b05b688d049006e45af057b18f98b module/provider-auth
ac88277ad893bc1c10ba7cfada20e0e022fe42a08cbdb3e3486b749c820d5138 module/provider-catalog-runtime
8131147d699394bd06503e2ea2f5f1a50b1594a87dded6d118b74a8d0328c8f6 module/proxy-capture
69a2931ee70410d6e782c0e6f4ea1964a9f72651999070c7453eb6c992ba6cc8 module/question-gateway-runtime
784c3c5c5dbb1e2c33ccccc62f850b740d2adcbde5e10e4e891d8c0f78aaeb99 module/question-gateway-runtime
158d7fa58b45efc8569684cbb8cd2d0a8e9d331911c9eacd6e1323c4b764cbb5 module/reply-chunking
20f592eb816da5b2e75e38d8f3dfe0c0c01059e1b37d45b05fe438f2fc07b980 module/reply-dispatch-runtime
90f0ffff74ee7eec2602502a3fb14abc26f1572b1d45953e410bc570044b74c5 module/reply-dispatch-runtime
73f861fa3179d5af1159853c5acab0eec7a6c8f9398dcb75ea770e784fca6727 module/reply-history
c4633871d5982f7b3d447ea77afa90031fd2faf90750f6f5dea8367e3f57d52a module/reply-payload
ad838aea4708728b912e64024b75ba6e975cebe6f84897af22daa903599691cb module/reply-runtime
4033c9d0c701fafdc294e618d7085e87f440b26b081b619b20354338eece45f8 module/reply-runtime
aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-walk
e26cc92679c768fa1474f15828f545aec87f32718a6b35f7907c4f56f65542fc module/routing
7877a7e58fa32a64107154e5b714c6d165e96989d4aa5f43e0afac085a187af0 module/run-command
@@ -118,20 +118,20 @@ c83779c80c9e7b196b31a39ec4b986098bd701b089bf6eb2f53c368fb982ea77 module/runtime
159b563aad773cef67f18bf9bd2653420bec94b599e052f2a1d5a238bf341e16 module/runtime-config-snapshot
9fe5bcb52b462010214eda1c01f60b3a018837d9f95dc864f6d457cb3da001cf module/runtime-env
7e871b7319745678bb83fcfc1b54c8751b0ab1af92ff06c01d0659ac92863c11 module/runtime-group-policy
21ab9f99fc7c530caaec1ca15334b598ed7b2f1d01fed5e05b1ec4a69563a756 module/runtime-store
13542a594ad95bd194b7670bd38b03e8a2799e7332bb831e098040a3e355957e module/runtime-store
d17862c40825af1ddf0257b44f1e1cbb9c375e8e5ed668fae75d530d1a465cf9 module/secret-file
8e2ac4d3973d8d8ce4478e3440d66ee5c0d9213b0fe9e927c421d14fd31e5e86 module/secret-input
e7672788f052a1249839c8fa24d6a9a45967c4e3fd94e890b0f90296026cd1ca module/secret-input-runtime
026631cbf010d0325be2c4fa4ccd8bbdf8e1008f0904c742c270811f578b07b8 module/secret-ref-runtime
536d3196e17422652d755286b9921133562907537c906b067a568b874221e7a0 module/security-runtime
2ddfb6d7aa51bc45edb68d005400ff6934162fd4fe32e3190d7b8231b8583960 module/session-catalog
56813dc7cf43f2b662caf2daaa0ba1918c3a40ea45f6aa4d7c73c27344b9866d module/session-discussion
f4eb6f6caedc651fb4dcbaf90eaaf849f10cdcea7468875286b6d862c2b64eaa module/session-catalog
33670dc092160cadf8fac8e6d58d151d14687d1f7cffe50ac6033b0114b27b76 module/session-discussion
7262e6e6dee725b4d9b8226bbb31f24460d7a4d5956aa6d60df81181c4221c07 module/session-store-runtime
e68e6edb57b7dc978431495ab53712d38a02d61620ab272b46a38c3b94199cfa module/setup
f188bdb868523aa17457c9338b02fcdf0df548d2fe776750b39d36c702edc061 module/setup-runtime
44d37e0d9131ad2859f41068f2604090c784e65f1bd6ebda8e051b6f2e5e1660 module/setup-tools
3601dd9f28de70915005457146881685b73690ef82c9bfed80f35d0cf9b645b1 module/skill-commands-runtime
cdf1ec944678336dcc289f795f2004867148ebd4bbe73a938b6443deb7abe294 module/speech-settings
0f22ed63e0ac5129c9d210e871c131c526d763e80ffa07b56cc5c27f598db065 module/speech-settings
4a1bcc606805b5a20d04e048fc268764df9b140be9d161ad981d213343b87fd6 module/ssrf-policy
3ad3f12186d9cf44600904f22d0659b3a72737d0ce7fb33d1177372f44db827c module/ssrf-runtime
5501c65f90feec38049ce100cb320b2a629ebf1ad04b21f015a0d44aa1e4c448 module/state-paths
@@ -141,11 +141,11 @@ c2aca425088c2bc9a74035f34d8b541354792eb0c44b988e314d59c25ac3aa7f module/telegra
aef35bee2502cd6ed8765409b758e452aff8ac9469fd773e6a2a44c9a1bc3f66 module/temp-path
87fa81b9e58d8fc04a4b4202d2d37fca339615f5225687d9db905151439e0f4d module/text-chunking
434804a2166f6bf2e872bff0f03f3af850e44dea04b49b1d13b41df872bb71d1 module/text-runtime
c407126a0ab30c77cb37bba55f747385bde6db7f59f04004c4a7f97a8d7de538 module/tool-plugin
e68bac256d5ff7f2199b9c8398f4ec5f7f17a2f5bfedcf1d9839216b06c487ae module/tool-plugin
dc1a073c59ab61e2789533b777b3f0cb9af689d64a97796b10e8aa82552510db module/tool-results
788e35ecca74d535b95b109f6837025b97cd2b4854008166b9f2e4832c31210a module/tool-send
cda105b721d498df23a554c6b68be150b8fe66b8b9172185c31a0b3b0646b1dc module/web-media
72862995b712c423731878c013bfc610415c030a10510799fd4f8567b944b7aa module/webhook-ingress
51176054606504ef27fdba5f3922ca15730b3749d225f85dec8a21a1f00f250d module/webhook-ingress
a107b97d3c1bb7494e516760d613950d30dbf57ccaea4b037e2e832ca6115839 module/webhook-request-guards
de59e86e126b75d13251cba7ebbe27b44d9b5588785d98df5ff4d6722374c81f module/widget-html
9161b36ec0ab062ea41b363c894fcd672a7727f21cb726739f99f9c184fce69d module/zod

View File

@@ -683,6 +683,9 @@ See [Plugins](/tools/plugin).
- `controlUi.toolTitles`: opt in to AI-generated purpose titles for tool calls in Control UI chat. Default: `false` (tool rendering stays fully deterministic with no background model calls). When enabled, the `chat.toolTitles` method labels complex calls through standard utility-model routing — the agent's `utilityModel` (an operator decision that may send bounded tool arguments to the chosen provider, like every utility task), or the session provider's declared small-model default (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`) — and caches results in the per-agent state database so repeat views never re-bill. `utilityModel: \"\"` disables titles like every other utility task; titles never fall back to the primary model.
- `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy.
- `cliAgents.enabled`: opt in to the experimental **CLI agents** group in the Control UI new-session model picker. Default: `false`. The group appears only when the Gateway advertises `sessions.catalog.list`, and it includes only catalog providers that support creating sessions. Selecting one opens the same catalog-target new-session flow used by the sidebar catalog action.
Catalog providers can also advertise terminal-based session creation. The method is available only when Labs `cliAgents.enabled` is on, the Gateway terminal is available, and the selected provider exposes the capability. Callers supply `cwd`; create a fresh worktree first with `worktrees.create` when needed, because terminal start does not provision one.
- `terminal.enabled`: the admin-scoped operator terminal. Default: `true`; set `false` to opt out. The terminal starts a host PTY in the selected agent workspace, inherits the Gateway process environment, and is refused for agents with `sandbox.mode: "all"`. Disable it on deployments where admin operators should not get a host shell; changing it restarts the Gateway and updates the Control UI content security policy.
- `terminal.shell`: optional shell executable. When unset, OpenClaw uses `$SHELL` on Unix and `%ComSpec%` on Windows.
- `terminal.detachedSessionTimeoutSeconds`: how long a terminal session survives after its connection drops (page reload, laptop sleep), staying reattachable via `terminal.attach` with its recent output replayed. Default: `300`. Set `0` to kill sessions the moment their connection drops. Detached sessions keep running their commands, so shorten this on shared or exposed hosts.

View File

@@ -144,13 +144,34 @@ export default definePluginEntry({
- `id` must match your `openclaw.plugin.json` manifest.
- External session catalogs use
`openclaw/plugin-sdk/session-catalog` and
`api.registerSessionCatalog({ id, label, list, read, continueSession?, archive? })`.
Core owns the `sessions.catalog.*` Gateway methods; providers return host,
session, and normalized transcript projections without registering RPCs. A
list provider should call the optional `onHost(host)` callback as each host
settles; the returned host array remains required as the final compatibility
snapshot.
`openclaw/plugin-sdk/session-catalog` and register a
`SessionCatalogProvider` with `api.registerSessionCatalog(...)`. Required
provider fields are `id`, `label`, `list`, and `read`; optional hooks are
`resolveCreateSession`, `continueSession`, `checkUpstreamActivity`, `archive`,
`openTerminal`, and `startTerminalSession`. Core owns the
`sessions.catalog.*` Gateway methods; providers return host, session,
transcript, and terminal-plan projections without registering RPCs. A list
provider should call the optional
`onHost(host)` callback as each host settles; the returned host array remains
required as the final compatibility snapshot.
`resolveCreateSession({ agentId })` must return a config-derived model/runtime
target before OpenClaw advertises creation or calls `startTerminalSession`.
Use
[`api.runtime.agent.resolveSessionCatalogCreateTarget(...)`](/plugins/sdk-runtime#apiruntimeagent)
to apply the host's runtime and model-allowlist policy instead of duplicating
it.
`startTerminalSession({ agentId, cwd, initialMessage?, nodeId? })` creates a
fresh CLI terminal plan. Return either a local plan (`kind: "local"`, `argv`,
and the exact `cwd`, plus optional `env`, `pathEnv`, and `title`) or a paired-node
plan (`kind: "node"`, `nodeId`, `command`, `paramsJSON`, and the exact `cwd`).
The `sessions.catalog.startTerminal` RPC requires `operator.admin` plus
`gateway.cliAgents.enabled` and `gateway.terminal.enabled`. The caller
provisions `cwd`; the Gateway requires an existing absolute local directory,
rejects a changed plan cwd or host, and applies the normal agent-sandbox,
node-pairing, deadline, and connection-ownership checks before opening the
PTY.
- `kind` is deprecated: declare an exclusive slot (`"memory"` or
`"context-engine"`) in the `openclaw.plugin.json` manifest `kind` field
instead. Runtime-entry `kind` remains only as a compatibility fallback for

View File

@@ -24,6 +24,7 @@ describe("anthropic session catalog lazy imports", () => {
list: async () => [],
read: async () => ({ hostId: "gateway:local", label: "Local", threadId: "", items: [] }),
continueSession: async () => ({ sessionKey: "agent:main:test" }),
startTerminalSession: async () => ({ kind: "local", argv: ["claude"] }),
openTerminal: async () => ({ kind: "local", argv: ["claude"] }),
checkUpstreamActivity: async () => [],
}),

View File

@@ -73,6 +73,8 @@ function registerClaudeSessionCatalog(api: OpenClawPluginApi): void {
list: async (query) => await (await loadCatalogRuntime()).list(query),
read: async (request) => await (await loadCatalogRuntime()).read(request),
continueSession: async (request) => await (await loadCatalogRuntime()).continueSession(request),
startTerminalSession: async (request) =>
await (await loadCatalogRuntime()).startTerminalSession(request),
openTerminal: async (request) => await (await loadCatalogRuntime()).openTerminal(request),
checkUpstreamActivity: async (probes) =>
await (await loadCatalogRuntime()).checkUpstreamActivity(probes),

View File

@@ -67,6 +67,34 @@ export function terminalEligibility(
};
}
export async function startClaudeCatalogTerminal(params: {
cwd: string;
initialMessage?: string;
nodeId?: string;
}): Promise<SessionCatalogTerminalPlan> {
if (params.nodeId) {
throw new ClaudeCatalogParamsError(
"Paired-node Claude terminal start is unavailable; omit hostId to start on the gateway host",
);
}
const resolution = resolveClaudeTerminalExecutable();
if (!resolution) {
throw new ClaudeCatalogParamsError(
"Claude CLI is unavailable; install Claude Code or add claude to PATH, then restart the gateway",
);
}
return {
kind: "local",
argv: [
resolution.executable,
...(params.initialMessage !== undefined ? ["--", params.initialMessage] : []),
],
cwd: params.cwd,
...(resolution.pathEnv ? { pathEnv: resolution.pathEnv } : {}),
title: "claude",
};
}
export async function openClaudeCatalogTerminal(
params: {
api: OpenClawPluginApi;

View File

@@ -2282,6 +2282,54 @@ describe("Claude session catalog", () => {
);
});
it("builds a local Claude terminal start plan with the initial prompt", async () => {
const home = await createHome();
process.env.HOME = home;
const binDir = path.join(home, "bin");
await fs.mkdir(binDir);
const executable = path.join(binDir, process.platform === "win32" ? "claude.cmd" : "claude");
await fs.writeFile(executable, process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n");
if (process.platform !== "win32") {
await fs.chmod(executable, 0o755);
}
process.env.PATH = binDir;
nodeHostMocks.userShellPaths.set("claude", binDir);
const provider = captureCatalogProvider(createPluginRuntimeMock());
await expect(
provider.startTerminalSession?.({
agentId: "main",
cwd: "/work/new-session",
initialMessage: "--help",
}),
).resolves.toEqual({
kind: "local",
argv: [executable, "--", "--help"],
cwd: "/work/new-session",
pathEnv: binDir,
title: "claude",
});
await expect(
provider.startTerminalSession?.({
agentId: "main",
cwd: "/work/command-prompt",
initialMessage: "mcp",
}),
).resolves.toMatchObject({ argv: [executable, "--", "mcp"] });
await expect(
provider.startTerminalSession?.({ agentId: "main", cwd: "/work/blank-session" }),
).resolves.toMatchObject({ argv: [executable], cwd: "/work/blank-session" });
await expect(
provider.startTerminalSession?.({
agentId: "main",
cwd: "/work/new-session",
nodeId: "paired-node",
}),
).rejects.toThrow(
"Paired-node Claude terminal start is unavailable; omit hostId to start on the gateway host",
);
});
it("resolves Claude terminal eligibility and cwd from the node-owned catalog", async () => {
const home = await createHome();
process.env.HOME = home;

View File

@@ -1906,7 +1906,12 @@ function toGenericClaudeHost(
type ClaudeSessionCatalogRuntime = Required<
Pick<
SessionCatalogProvider,
"list" | "read" | "continueSession" | "openTerminal" | "checkUpstreamActivity"
| "list"
| "read"
| "continueSession"
| "startTerminalSession"
| "openTerminal"
| "checkUpstreamActivity"
>
>;
@@ -1940,6 +1945,7 @@ export function createClaudeSessionCatalogRuntime(
},
continueSession: async (request) =>
await continueClaudeSession(api, request.hostId, request.threadId),
startTerminalSession: (request) => catalogTerminal.startClaudeCatalogTerminal(request),
openTerminal: (request) =>
catalogTerminal.openClaudeCatalogTerminal({
api,

View File

@@ -275,3 +275,35 @@ export async function openCodexCatalogTerminal(
title,
};
}
export async function startCodexCatalogTerminal(
params: {
agentId: string;
cwd: string;
initialMessage?: string;
nodeId?: string;
} & CodexTerminalConfigSources,
): Promise<SessionCatalogTerminalPlan> {
if (params.nodeId) {
throw new CatalogParamsError(
"Paired-node Codex terminal start is unavailable; omit hostId to start on the gateway host",
);
}
const resolution = resolveLocalCodexTerminalResolution();
if (!resolution) {
throw new CatalogParamsError(
"Codex CLI is unavailable; install Codex or add codex to PATH, then try again",
);
}
return {
kind: "local",
argv: [
resolution.executable,
...(params.initialMessage !== undefined ? ["--", params.initialMessage] : []),
],
cwd: params.cwd,
env: { CODEX_HOME: resolveCodexCatalogTerminalHome(params) },
...(resolution.pathEnv ? { pathEnv: resolution.pathEnv } : {}),
title: "codex",
};
}

View File

@@ -95,6 +95,7 @@ const transcriptMirrorMocks = vi.hoisted(() => ({
}));
const nodeHostMocks = vi.hoisted(() => ({
runNodePtyCommand: vi.fn(async () => ({ exitCode: 0 })),
userShellPaths: new Map<string, string>(),
}));
vi.mock("./command-rpc.js", () => ({
@@ -121,15 +122,33 @@ vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => {
env?: NodeJS.ProcessEnv;
pathEnv?: string;
includeExtensionless?: boolean;
strategy: "direct" | "fallback" | "prefer";
},
) => {
const env = options.env ?? process.env;
return actual.resolveNodeHostExecutable(command, {
const pathEnv = options.pathEnv ?? env.PATH ?? env.Path ?? "";
const direct = actual.resolveNodeHostExecutable(command, {
env,
pathEnv: options.pathEnv ?? env.PATH ?? env.Path ?? "",
pathEnv,
includeExtensionless: options.includeExtensionless,
strategy: "direct",
});
if (direct && options.strategy !== "prefer") {
return direct;
}
const shellPath = nodeHostMocks.userShellPaths.get(command);
if (!shellPath) {
return direct;
}
const shellExecutable = actual.resolveNodeHostExecutable(command, {
env,
pathEnv: shellPath,
includeExtensionless: options.includeExtensionless,
strategy: "direct",
});
return shellExecutable
? { executable: shellExecutable.executable, pathEnv: shellPath }
: direct;
},
};
});
@@ -424,6 +443,7 @@ function createGatewayApi(runtime: PluginRuntime, apiConfig: OpenClawConfig = {}
beforeEach(() => {
nodeHostMocks.runNodePtyCommand.mockClear();
nodeHostMocks.userShellPaths.clear();
commandRpcMocks.codexControlRequest.mockReset();
pinnedConnectionMocks.getClient.mockReset();
pinnedConnectionMocks.getClient.mockResolvedValue(pinnedConnectionMocks.client);
@@ -3743,7 +3763,8 @@ describe("Codex supervision actions", () => {
const threadId = "123e4567-e89b-12d3-a456-426614174000";
const binDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-terminal-"));
tempDirs.push(binDir);
process.env.PATH = binDir;
process.env.PATH = "";
nodeHostMocks.userShellPaths.set("codex", binDir);
let now = Date.now();
vi.spyOn(Date, "now").mockImplementation(() => now);
const executable = path.join(binDir, process.platform === "win32" ? "codex.cmd" : "codex");
@@ -3819,6 +3840,20 @@ describe("Codex supervision actions", () => {
await expect(
getProvider()?.openTerminal?.({ hostId: CODEX_LOCAL_SESSION_HOST_ID, threadId }),
).rejects.toThrow("Codex CLI is unavailable");
await expect(
getProvider()?.startTerminalSession?.({
agentId: "main",
cwd: "/workspace/new",
initialMessage: "--help",
}),
).rejects.toThrow("install Codex or add codex to PATH");
await expect(
getProvider()?.startTerminalSession?.({
agentId: "main",
cwd: "/workspace/node-new",
nodeId: "devbox",
}),
).rejects.toThrow("omit hostId to start on the gateway host");
await fs.writeFile(executable, process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n");
if (process.platform !== "win32") {
@@ -3843,10 +3878,37 @@ describe("Codex supervision actions", () => {
kind: "local",
argv: [executable, "resume", threadId],
cwd: "/workspace/local",
pathEnv: binDir,
env: {
CODEX_HOME: resolveCodexAppServerHomeDir(resolveDefaultAgentDir(config)),
},
});
await expect(
getProvider()?.startTerminalSession?.({
agentId: "main",
cwd: "/workspace/new",
initialMessage: "--help",
}),
).resolves.toEqual({
kind: "local",
argv: [executable, "--", "--help"],
cwd: "/workspace/new",
env: {
CODEX_HOME: resolveCodexAppServerHomeDir(resolveDefaultAgentDir(config)),
},
pathEnv: binDir,
title: "codex",
});
await expect(
getProvider()?.startTerminalSession?.({
agentId: "main",
cwd: "/workspace/command-prompt",
initialMessage: "resume",
}),
).resolves.toMatchObject({ argv: [executable, "--", "resume"] });
await expect(
getProvider()?.startTerminalSession?.({ agentId: "main", cwd: "/workspace/blank" }),
).resolves.toMatchObject({ argv: [executable], cwd: "/workspace/blank" });
pluginConfig = { appServer: { homeScope: "user" } };
await expect(
getProvider()?.openTerminal?.({ hostId: CODEX_LOCAL_SESSION_HOST_ID, threadId }),

View File

@@ -101,6 +101,7 @@ import {
openCodexCatalogTerminal,
requireCatalogEligibleThread,
resolveLocalCodexTerminalExecutable,
startCodexCatalogTerminal,
type CodexTerminalConfigSources,
} from "./session-catalog-terminal.js";
import { toGenericTranscriptItem } from "./session-catalog-transcript-item.js";
@@ -1539,6 +1540,12 @@ function registerCodexSessionCatalog(params: {
parseCatalogPage,
...request,
}),
startTerminalSession: (request) =>
startCodexCatalogTerminal({
getPluginConfig: params.getPluginConfig,
getRuntimeConfig: params.getRuntimeConfig,
...request,
}),
};
params.api.registerSessionCatalog(provider);
}

View File

@@ -49,6 +49,7 @@ import type {
ConfigSchemaLookupParams,
ModelsListParams,
SessionsCatalogListParams,
SessionsCatalogStartTerminalParams,
TalkEvent,
} from "./index.js";
import * as schemaExportRegistry from "./schema-export-registry.js";
@@ -122,6 +123,7 @@ describe("protocol export registries", () => {
expectTypeOf<ConfigSchemaLookupParams>().toEqualTypeOf<Schema.ConfigSchemaLookupParams>();
expectTypeOf<ModelsListParams>().toEqualTypeOf<Schema.ModelsListParams>();
expectTypeOf<SessionsCatalogListParams>().toEqualTypeOf<Schema.SessionsCatalogListParams>();
expectTypeOf<SessionsCatalogStartTerminalParams>().toEqualTypeOf<Schema.SessionsCatalogStartTerminalParams>();
expectTypeOf<TalkEvent>().toEqualTypeOf<Schema.TalkEvent>();
});

View File

@@ -17,4 +17,6 @@ export type {
SessionsCatalogHostEvent,
SessionsCatalogReadParams,
SessionsCatalogReadResult,
SessionsCatalogStartTerminalParams,
SessionsCatalogStartTerminalResult,
} from "./schema/sessions-catalog.js";

View File

@@ -148,6 +148,8 @@ export {
SessionsCatalogContinueResultSchema,
SessionsCatalogArchiveParamsSchema,
SessionsCatalogArchiveResultSchema,
SessionsCatalogStartTerminalParamsSchema,
SessionsCatalogStartTerminalResultSchema,
SessionsSearchHitSchema,
SessionsSearchParamsSchema,
SessionsSearchResultSchema,

View File

@@ -18,6 +18,8 @@ export const SessionCoreProtocolSchemas = {
SessionsCatalogContinueResult: sessionsCatalog.SessionsCatalogContinueResultSchema,
SessionsCatalogArchiveParams: sessionsCatalog.SessionsCatalogArchiveParamsSchema,
SessionsCatalogArchiveResult: sessionsCatalog.SessionsCatalogArchiveResultSchema,
SessionsCatalogStartTerminalParams: sessionsCatalog.SessionsCatalogStartTerminalParamsSchema,
SessionsCatalogStartTerminalResult: sessionsCatalog.SessionsCatalogStartTerminalResultSchema,
SessionsCleanupParams: sessions.SessionsCleanupParamsSchema,
SessionsPreviewParams: sessions.SessionsPreviewParamsSchema,
SessionsDescribeParams: sessions.SessionsDescribeParamsSchema,

View File

@@ -4,6 +4,8 @@ import {
SessionsCatalogHostEventSchema,
SessionsCatalogListParamsSchema,
SessionsCatalogListResultSchema,
SessionsCatalogStartTerminalParamsSchema,
SessionsCatalogStartTerminalResultSchema,
} from "./sessions-catalog.js";
describe("SessionsCatalogListResultSchema", () => {
@@ -17,7 +19,10 @@ describe("SessionsCatalogListResultSchema", () => {
capabilities: {
continueSession: true,
archive: false,
createSession: { model: "anthropic/claude-opus-4-8" },
createSession: {
model: "anthropic/claude-opus-4-8",
startTerminal: true,
},
openTerminal: true,
},
hosts: [
@@ -46,6 +51,35 @@ describe("SessionsCatalogListResultSchema", () => {
});
});
describe("SessionsCatalogStartTerminal schemas", () => {
it("accepts the terminal start contract and rejects unknown fields", () => {
const params = {
catalogId: "codex",
hostId: "gateway:local",
agentId: "main",
cwd: "/tmp/worktree",
initialMessage: "Inspect the failing test",
};
const result = {
sessionId: "terminal-1",
agentId: "main",
shell: "/bin/zsh",
cwd: "/tmp/worktree",
confined: false,
title: "Codex",
};
expect(Value.Check(SessionsCatalogStartTerminalParamsSchema, params)).toBe(true);
expect(
Value.Check(SessionsCatalogStartTerminalParamsSchema, { ...params, unexpected: true }),
).toBe(false);
expect(Value.Check(SessionsCatalogStartTerminalResultSchema, result)).toBe(true);
expect(
Value.Check(SessionsCatalogStartTerminalResultSchema, { ...result, unexpected: true }),
).toBe(false);
});
});
describe("SessionsCatalogListParamsSchema", () => {
it("accepts an optional progressive stream id without a catalog selector", () => {
expect(

View File

@@ -16,7 +16,12 @@ export const SessionCatalogLocatorSchema = closedObject({
export const SessionCatalogCapabilitiesSchema = closedObject({
continueSession: Type.Boolean(),
archive: Type.Boolean(),
createSession: Type.Optional(closedObject({ model: NonEmptyString })),
createSession: Type.Optional(
closedObject({
model: NonEmptyString,
startTerminal: Type.Optional(Type.Boolean()),
}),
),
openTerminal: Type.Optional(Type.Boolean()),
});
@@ -154,6 +159,24 @@ export const SessionsCatalogArchiveParamsSchema = closedObject({
export const SessionsCatalogArchiveResultSchema = closedObject({ ok: Type.Literal(true) });
export const SessionsCatalogStartTerminalParamsSchema = closedObject({
catalogId: NonEmptyString,
hostId: Type.Optional(NonEmptyString),
agentId: NonEmptyString,
cwd: NonEmptyString,
initialMessage: Type.Optional(Type.String()),
});
// Mirrors terminal.open so callers can hand the new session to the same terminal UI.
export const SessionsCatalogStartTerminalResultSchema = closedObject({
sessionId: NonEmptyString,
agentId: NonEmptyString,
shell: NonEmptyString,
cwd: NonEmptyString,
confined: Type.Boolean(),
title: Type.Optional(NonEmptyString),
});
export type SessionCatalogCapabilities = Static<typeof SessionCatalogCapabilitiesSchema>;
export type SessionCatalogLocator = Static<typeof SessionCatalogLocatorSchema>;
export type SessionCatalogDescriptor = Static<typeof SessionCatalogDescriptorSchema>;
@@ -173,3 +196,9 @@ export type SessionsCatalogContinueParams = Static<typeof SessionsCatalogContinu
export type SessionsCatalogContinueResult = Static<typeof SessionsCatalogContinueResultSchema>;
export type SessionsCatalogArchiveParams = Static<typeof SessionsCatalogArchiveParamsSchema>;
export type SessionsCatalogArchiveResult = Static<typeof SessionsCatalogArchiveResultSchema>;
export type SessionsCatalogStartTerminalParams = Static<
typeof SessionsCatalogStartTerminalParamsSchema
>;
export type SessionsCatalogStartTerminalResult = Static<
typeof SessionsCatalogStartTerminalResultSchema
>;

View File

@@ -173,6 +173,9 @@ export const validateSessionsCatalogListParams = compile(S.SessionsCatalogListPa
export const validateSessionsCatalogReadParams = compile(S.SessionsCatalogReadParamsSchema);
export const validateSessionsCatalogContinueParams = compile(S.SessionsCatalogContinueParamsSchema);
export const validateSessionsCatalogArchiveParams = compile(S.SessionsCatalogArchiveParamsSchema);
export const validateSessionsCatalogStartTerminalParams = compile(
S.SessionsCatalogStartTerminalParamsSchema,
);
export const validateSessionsSearchParams = compile(S.SessionsSearchParamsSchema);
export const validateSessionsCleanupParams = compile(S.SessionsCleanupParamsSchema);
export const validateSessionsPreviewParams = compile(S.SessionsPreviewParamsSchema);

View File

@@ -248,7 +248,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
// +4: focused agent scope functions for doctor migration enumeration.
// +1: shared transcript credential-safety prompt for plugin-owned agent harnesses.
// +3: channel streaming config reader re-exports and session-agent scope resolver.
4844,
// +3: session-catalog terminal-start provider request and Gateway params/result contracts.
4847,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(

View File

@@ -82,6 +82,7 @@ describe("method scope resolution", () => {
["sessions.catalog.read", ["operator.read"]],
["sessions.catalog.continue", ["operator.write"]],
["sessions.catalog.archive", ["operator.write"]],
["sessions.catalog.startTerminal", ["operator.admin"]],
["session.discussion.info", ["operator.read"]],
["session.discussion.open", ["operator.write"]],
["environments.status", ["operator.read"]],

View File

@@ -96,5 +96,8 @@ describe("core gateway method release trains", () => {
.toSorted(),
).toEqual(CURRENT_TRAIN_METHODS.toSorted());
expect(methods.find((method) => method.name === "update.hold")?.since).toBe("2026.8");
expect(methods.find((method) => method.name === "sessions.catalog.startTerminal")?.since).toBe(
"2026.8",
);
});
});

View File

@@ -489,6 +489,8 @@ const CORE_GATEWAY_METHOD_SPECS = [
["sessions.patchMany", "sessions-mutations", "dynamic", "2026.8"],
// Update campaign mutations share update.run's admin and control-plane write policy.
["update.hold", "update", "operator.admin", "2026.8", { controlPlaneWrite: true }],
// Additive catalog terminal start appends so older advertised indices stay stable.
["sessions.catalog.startTerminal", "session-catalog", "operator.admin", "2026.8"],
] as const satisfies readonly CoreGatewayMethodSpecRow[];
export type CoreGatewayHandlerFamily = Exclude<(typeof CORE_GATEWAY_METHOD_SPECS)[number][1], null>;

View File

@@ -66,7 +66,7 @@ describe("listGatewayMethods", () => {
});
it("appends new methods after model probing without shifting older method indices", () => {
expect(listGatewayMethods().slice(-33)).toEqual([
expect(listGatewayMethods().slice(-34)).toEqual([
"models.probe",
"migrations.memory.plan",
"migrations.memory.apply",
@@ -100,6 +100,7 @@ describe("listGatewayMethods", () => {
"audit.run.inspect",
"sessions.patchMany",
"update.hold",
"sessions.catalog.startTerminal",
]);
const methods = listGatewayMethods();
expect(methods.indexOf("node.pluginSurface.refresh")).toBe(
@@ -172,7 +173,7 @@ describe("listGatewayMethods", () => {
"exec.approval.get",
]);
expect(methods).toContain("tts.speak");
expect(coreMethods.slice(-40)).toEqual([
expect(coreMethods.slice(-41)).toEqual([
"sessions.catalog.continue",
"sessions.catalog.archive",
"approval.get",
@@ -213,12 +214,16 @@ describe("listGatewayMethods", () => {
"audit.run.inspect",
"sessions.patchMany",
"update.hold",
"sessions.catalog.startTerminal",
]);
expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak"));
expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1);
expect(methods.indexOf("audit.run.inspect")).toBe(methods.indexOf("tasks.dismiss") + 1);
expect(methods.indexOf("sessions.patchMany")).toBe(methods.indexOf("audit.run.inspect") + 1);
expect(methods.indexOf("update.hold")).toBe(methods.indexOf("sessions.patchMany") + 1);
expect(methods.indexOf("sessions.catalog.startTerminal")).toBe(
methods.indexOf("update.hold") + 1,
);
});
it("advertises the versioned Talk session RPCs", () => {

View File

@@ -0,0 +1,173 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
import type { PluginRegistry } from "../../plugins/registry-types.js";
import { createPluginRuntime } from "../../plugins/runtime/index.js";
import {
listSessionCatalogEntries,
type SessionCatalogProvider,
} from "../../plugins/session-catalog.js";
type TestPluginRegistry = Omit<PluginRegistry, "sessionCatalogs"> & {
sessionCatalogs: Array<{ provider: SessionCatalogProvider }>;
};
const hoisted = vi.hoisted(() => ({
activeRegistry: {} as TestPluginRegistry,
listSessionEntriesReadOnly: vi.fn<
(scope?: { agentId?: string; clone?: boolean; projection?: "full" | "list" }) => Array<{
sessionKey: string;
entry: {
createdActor?: { type: "human" | "agent" | "system"; id?: string };
updatedAt?: number;
};
}>
>(() => []),
}));
vi.mock("../../plugins/runtime.js", () => ({
getActivePluginRegistry: () => hoisted.activeRegistry,
}));
vi.mock("../../config/sessions/session-accessor.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../config/sessions/session-accessor.js")>();
return { ...actual, listSessionEntriesReadOnly: hoisted.listSessionEntriesReadOnly };
});
const { sessionCatalogHandlers } = await import("./session-catalog.js");
function provider(id: string, sessionKey: string): SessionCatalogProvider {
return {
id,
label: id.toUpperCase(),
read: vi.fn(async ({ hostId, threadId }) => ({ hostId, threadId, items: [] })),
list: vi.fn(async ({ sessionEntries }) => {
const entries = listSessionCatalogEntries({
config: {},
runtime: createPluginRuntime(),
sessionEntries,
});
const adopted = entries.find((candidate) => candidate.sessionKey === sessionKey);
return [
{
hostId: `gateway:${id}`,
label: `${id} host`,
kind: "gateway" as const,
connected: true,
sessions: adopted
? [
{
threadId: `${id}-thread`,
status: "stored" as const,
archived: false,
sessionKey: adopted.sessionKey,
canContinue: true,
canArchive: false,
},
]
: [],
},
];
}),
};
}
describe("session catalog entry snapshots", () => {
beforeEach(() => {
hoisted.activeRegistry = createEmptyPluginRegistry() as TestPluginRegistry;
hoisted.listSessionEntriesReadOnly.mockReset();
});
it("shares one flattened entry snapshot across catalogs and creator projection", async () => {
hoisted.listSessionEntriesReadOnly.mockReturnValue([
{
sessionKey: "agent:main:alpha-adopted",
entry: { createdActor: { type: "agent", id: "worker-alpha" }, updatedAt: 2 },
},
{
sessionKey: "agent:main:zeta-adopted",
entry: { createdActor: { type: "system", id: "scheduler" }, updatedAt: 1 },
},
]);
const flattenedEntries: unknown[] = [];
for (const catalog of [
provider("zeta", "agent:main:zeta-adopted"),
provider("alpha", "agent:main:alpha-adopted"),
]) {
const list = catalog.list;
catalog.list = vi.fn(async (params) => {
const result = await list(params);
flattenedEntries.push(
listSessionCatalogEntries({
config: {},
runtime: createPluginRuntime(),
sessionEntries: params.sessionEntries,
}),
);
return result;
});
hoisted.activeRegistry.sessionCatalogs.push({ provider: catalog });
}
const respond = vi.fn();
await sessionCatalogHandlers["sessions.catalog.list"]?.({
params: {},
respond,
context: { getRuntimeConfig: () => ({}) },
} as never);
expect(hoisted.listSessionEntriesReadOnly).toHaveBeenCalledOnce();
expect(flattenedEntries).toHaveLength(2);
expect(flattenedEntries[0]).toBe(flattenedEntries[1]);
expect(respond).toHaveBeenCalledWith(true, {
catalogs: [
{
id: "alpha",
label: "ALPHA",
capabilities: { continueSession: false, archive: false },
hosts: [
{
hostId: "gateway:alpha",
label: "alpha host",
kind: "gateway",
connected: true,
sessions: [
{
threadId: "alpha-thread",
status: "stored",
archived: false,
sessionKey: "agent:main:alpha-adopted",
canContinue: true,
canArchive: false,
createdActor: { type: "agent", id: "worker-alpha" },
},
],
},
],
},
{
id: "zeta",
label: "ZETA",
capabilities: { continueSession: false, archive: false },
hosts: [
{
hostId: "gateway:zeta",
label: "zeta host",
kind: "gateway",
connected: true,
sessions: [
{
threadId: "zeta-thread",
status: "stored",
archived: false,
sessionKey: "agent:main:zeta-adopted",
canContinue: true,
canArchive: false,
createdActor: { type: "system", id: "scheduler" },
},
],
},
],
},
],
});
});
});

View File

@@ -0,0 +1,358 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js";
import type { SessionCatalogProvider } from "../../plugins/session-catalog.js";
import { catalogStartHandler } from "./session-catalog-terminal-start.js";
function provider(overrides: Partial<SessionCatalogProvider> = {}): SessionCatalogProvider {
return {
id: "codex",
label: "Codex",
list: vi.fn(async () => []),
read: vi.fn(async ({ hostId, threadId }) => ({ hostId, threadId, items: [] })),
...overrides,
};
}
let activeProvider: SessionCatalogProvider;
const resolveCreateTarget = vi.fn((): { ok: true } | { ok: false; message: string } => ({
ok: true,
}));
const handler = catalogStartHandler(
(catalogId) => (activeProvider.id === catalogId ? activeProvider : undefined),
resolveCreateTarget,
);
function startCall(
params: unknown,
config: Record<string, unknown> = {},
client?: { connect?: { scopes?: string[] }; connId?: string },
contextOverrides: Record<string, unknown> = {},
) {
const respond = vi.fn();
const completion = Promise.resolve(
handler({
params,
respond,
client,
context: { getRuntimeConfig: () => config, ...contextOverrides },
} as never),
);
return { completion, respond };
}
async function call(
params: unknown,
config: Record<string, unknown> = {},
client?: { connect?: { scopes?: string[] }; connId?: string },
contextOverrides: Record<string, unknown> = {},
) {
const pending = startCall(params, config, client, contextOverrides);
await pending.completion;
return pending.respond;
}
describe("sessions.catalog.startTerminal", () => {
beforeAll(async () => {
await import("./terminal.js");
});
beforeEach(() => {
activeProvider = provider();
resolveCreateTarget.mockReset();
resolveCreateTarget.mockReturnValue({ ok: true });
});
it("requires the cliAgents opt-in before terminal start", async () => {
const startTerminalSession = vi.fn();
activeProvider = provider({ startTerminalSession });
const respond = await call({
catalogId: "codex",
agentId: "main",
cwd: process.cwd(),
});
expect(startTerminalSession).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: ErrorCodes.UNAVAILABLE,
message: "CLI agent terminal start is disabled; enable gateway.cliAgents.enabled and retry",
}),
);
});
it("refuses terminal start when the terminal is disabled", async () => {
const startTerminalSession = vi.fn();
activeProvider = provider({ startTerminalSession });
const respond = await call(
{ catalogId: "codex", agentId: "main", cwd: process.cwd() },
{ gateway: { cliAgents: { enabled: true } } },
{ connId: "conn-1" },
{ isTerminalEnabled: () => false },
);
expect(startTerminalSession).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: ErrorCodes.UNAVAILABLE,
message: "terminal is disabled; enable gateway.terminal.enabled and retry",
}),
);
});
it("refuses missing local cwd instead of falling back to home", async () => {
const startTerminalSession = vi.fn();
activeProvider = provider({ startTerminalSession });
const respond = await call(
{ catalogId: "codex", agentId: "main", cwd: "relative/missing" },
{ gateway: { cliAgents: { enabled: true } } },
{ connId: "conn-1" },
{ isTerminalEnabled: () => true, terminalSessions: {} },
);
expect(startTerminalSession).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: ErrorCodes.INVALID_REQUEST,
message:
"cwd must be an existing absolute directory; create or choose a worktree and retry",
}),
);
});
it("rechecks local cwd after the provider plan resolves", async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-catalog-start-"));
let releasePlan!: () => void;
const planGate = new Promise<void>((resolve) => {
releasePlan = resolve;
});
const startTerminalSession = vi.fn(async () => {
await planGate;
return { kind: "local" as const, argv: ["codex"], cwd };
});
const open = vi.fn();
activeProvider = provider({ startTerminalSession });
try {
const pending = startCall(
{ catalogId: "codex", agentId: "main", cwd },
{ gateway: { cliAgents: { enabled: true } } },
{ connId: "conn-1", connect: { scopes: ["operator.admin"] } },
{
isTerminalEnabled: () => true,
terminalSessions: { open },
resolveTerminalLaunchPolicy: () => ({
ok: true,
plan: { agentId: "main", cwd: "/agent/workspace", shell: "/bin/zsh", args: [] },
}),
isConnectionActive: () => true,
},
);
await vi.waitFor(() => expect(startTerminalSession).toHaveBeenCalledOnce());
await fs.rm(cwd, { recursive: true });
releasePlan();
await pending.completion;
expect(open).not.toHaveBeenCalled();
expect(pending.respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: ErrorCodes.INVALID_REQUEST,
message: expect.stringContaining(
"cwd is no longer available; recreate or choose the worktree and retry",
),
}),
);
} finally {
releasePlan();
await fs.rm(cwd, { recursive: true, force: true });
}
});
it("keeps the terminal recovery hint on provider errors", async () => {
const cwd = process.cwd();
const startTerminalSession = vi.fn(async () => {
throw new Error("provider failed to build a start plan");
});
const open = vi.fn();
activeProvider = provider({ startTerminalSession });
const respond = await call(
{ catalogId: "codex", agentId: "main", cwd },
{ gateway: { cliAgents: { enabled: true } } },
{ connId: "conn-1", connect: { scopes: ["operator.admin"] } },
{
isTerminalEnabled: () => true,
terminalSessions: { open },
resolveTerminalLaunchPolicy: () => ({
ok: true,
plan: { agentId: "main", cwd: "/agent/workspace", shell: "/bin/zsh", args: [] },
}),
},
);
expect(open).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: ErrorCodes.INVALID_REQUEST,
message:
"provider failed to build a start plan; check the selected CLI, host, and terminal configuration, then retry",
}),
);
});
it("reuses terminal.open admission and manager ownership for terminal start", async () => {
const cwd = process.cwd();
const startTerminalSession = vi.fn(async () => ({
kind: "local" as const,
argv: ["codex", "--", "Inspect the failing test"],
cwd,
title: "Codex",
env: { CODEX_HOME: "/tmp/codex-home" },
pathEnv: "/usr/local/bin:/usr/bin:/bin",
}));
const open = vi.fn(async () => ({
ok: true as const,
sessionId: "terminal-1",
agentId: "research",
cwd,
shell: "/bin/zsh",
}));
activeProvider = provider({ startTerminalSession });
const config = { gateway: { cliAgents: { enabled: true } } };
const respond = await call(
{
catalogId: "codex",
hostId: "gateway:local",
agentId: "research",
cwd,
initialMessage: "Inspect the failing test",
},
config,
{ connId: "conn-1", connect: { scopes: ["operator.admin"] } },
{
isTerminalEnabled: () => true,
terminalSessions: { open },
resolveTerminalLaunchPolicy: () => ({
ok: true,
plan: { agentId: "research", cwd: "/agent/workspace", shell: "/bin/zsh", args: [] },
}),
isConnectionActive: () => true,
logGateway: { info: vi.fn() },
},
);
expect(resolveCreateTarget).toHaveBeenCalledWith("codex", "research", config);
expect(startTerminalSession).toHaveBeenCalledWith({
agentId: "research",
cwd,
initialMessage: "Inspect the failing test",
});
expect(open).toHaveBeenCalledWith(
expect.objectContaining({
owner: { kind: "conn", connId: "conn-1" },
agentId: "research",
cwd,
shell: "/bin/zsh",
args: ["-il", "-c", "'codex' '--' 'Inspect the failing test'"],
cols: 80,
rows: 24,
env: expect.objectContaining({
CODEX_HOME: "/tmp/codex-home",
PATH: "/usr/local/bin:/usr/bin:/bin",
}),
}),
);
expect(respond).toHaveBeenCalledWith(true, {
sessionId: "terminal-1",
agentId: "research",
cwd,
shell: "/bin/zsh",
confined: false,
title: "Codex",
});
});
it("does not fall back to local when a node host was requested", async () => {
const startTerminalSession = vi.fn(async ({ cwd }: { cwd: string }) => ({
kind: "local" as const,
argv: ["codex"],
cwd,
}));
const open = vi.fn();
activeProvider = provider({ startTerminalSession });
const respond = await call(
{ catalogId: "codex", hostId: "node:remote", agentId: "main", cwd: "/remote/worktree" },
{ gateway: { cliAgents: { enabled: true } } },
{ connId: "conn-1", connect: { scopes: ["operator.admin"] } },
{
isTerminalEnabled: () => true,
terminalSessions: { open },
resolveTerminalLaunchPolicy: () => ({
ok: true,
plan: { agentId: "main", cwd: "/agent/workspace", shell: "/bin/zsh", args: [] },
}),
},
);
expect(startTerminalSession).toHaveBeenCalledWith({
agentId: "main",
cwd: "/remote/worktree",
nodeId: "remote",
});
expect(open).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: ErrorCodes.INVALID_REQUEST,
message: expect.stringContaining("cannot start on the selected node"),
}),
);
});
it("refuses sandboxed agents before requesting a terminal plan", async () => {
const startTerminalSession = vi.fn();
activeProvider = provider({ startTerminalSession });
const respond = await call(
{ catalogId: "codex", agentId: "locked", cwd: process.cwd() },
{ gateway: { cliAgents: { enabled: true } } },
{ connId: "conn-1", connect: { scopes: ["operator.admin"] } },
{
isTerminalEnabled: () => true,
terminalSessions: { open: vi.fn() },
resolveTerminalLaunchPolicy: () => ({
ok: false,
block: { kind: "sandboxed", agentId: "locked", mode: "all" },
}),
},
);
expect(startTerminalSession).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: ErrorCodes.INVALID_REQUEST,
message: expect.stringContaining('agent "locked" runs in a sandbox'),
}),
);
});
});

View File

@@ -0,0 +1,170 @@
import { statSync } from "node:fs";
import path from "node:path";
import {
ErrorCodes,
errorShape,
type SessionsCatalogStartTerminalParams,
validateSessionsCatalogStartTerminalParams,
} from "../../../packages/gateway-protocol/src/index.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { SessionCatalogProvider } from "../../plugins/session-catalog.js";
import type { GatewayRequestHandlers } from "./types.js";
import { assertValidParams } from "./validation.js";
type SessionCatalogProviderResolver = (catalogId: string) => SessionCatalogProvider | undefined;
type SessionCatalogCreateTargetResolver = (
catalogId: string,
agentId: string,
config: OpenClawConfig,
) => { ok: true } | { ok: false; message: string };
/** Builds the catalog terminal-start handler around the active provider registry. */
export function catalogStartHandler(
resolveProvider: SessionCatalogProviderResolver,
resolveCreateTarget: SessionCatalogCreateTargetResolver,
): GatewayRequestHandlers["sessions.catalog.startTerminal"] {
return async (opts) => {
const { params, respond, context } = opts;
if (
!assertValidParams(
params,
validateSessionsCatalogStartTerminalParams,
"sessions.catalog.startTerminal",
respond,
)
) {
return;
}
const request = params as SessionsCatalogStartTerminalParams;
const config = context.getRuntimeConfig();
if (config.gateway?.cliAgents?.enabled !== true) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
"CLI agent terminal start is disabled; enable gateway.cliAgents.enabled and retry",
),
);
return;
}
if (!context.isTerminalEnabled()) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
"terminal is disabled; enable gateway.terminal.enabled and retry",
),
);
return;
}
if (!context.terminalSessions) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
"terminal is not available; restart the Gateway with terminal support and retry",
),
);
return;
}
const provider = resolveProvider(request.catalogId);
if (!provider) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unknown session catalog: ${request.catalogId}`),
);
return;
}
if (!provider.startTerminalSession) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"session catalog cannot start terminal sessions; choose a catalog that advertises createSession.startTerminal",
),
);
return;
}
const createTarget = resolveCreateTarget(request.catalogId, request.agentId, config);
if (!createTarget.ok) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, createTarget.message));
return;
}
let nodeId: string | undefined;
if (request.hostId && request.hostId !== "gateway:local") {
nodeId = request.hostId.startsWith("node:")
? request.hostId.slice("node:".length).trim()
: undefined;
if (!nodeId || request.hostId !== `node:${nodeId}`) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
'invalid catalog host; choose "gateway:local" or a listed "node:<id>" host and retry',
),
);
return;
}
}
if (!nodeId) {
let cwdIsDirectory = false;
try {
cwdIsDirectory = path.isAbsolute(request.cwd) && statSync(request.cwd).isDirectory();
} catch {
// The caller owns worktree provisioning; missing/unreadable paths must not fall back home.
}
if (!cwdIsDirectory) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"cwd must be an existing absolute directory; create or choose a worktree and retry",
),
);
return;
}
}
const startTerminalSession = provider.startTerminalSession;
const { openTerminalSession, CATALOG_TERMINAL_INITIAL_SIZE } = await import("./terminal.js");
await openTerminalSession(opts, {
agentId: request.agentId,
...CATALOG_TERMINAL_INITIAL_SIZE,
...(!nodeId ? { requiredCwd: request.cwd } : {}),
failureHint: "check the selected CLI, host, and terminal configuration, then retry",
resolveCatalogPlan: async () => {
const plan = await startTerminalSession.call(provider, {
agentId: request.agentId,
cwd: request.cwd,
...(request.initialMessage !== undefined
? { initialMessage: request.initialMessage }
: {}),
...(nodeId ? { nodeId } : {}),
});
if (plan.cwd !== request.cwd) {
throw new Error(
"session catalog did not preserve the requested cwd; choose the worktree again and retry",
);
}
if (nodeId && (plan.kind !== "node" || plan.nodeId !== nodeId)) {
throw new Error(
"session catalog cannot start on the selected node; choose a supported host and retry",
);
}
if (!nodeId && plan.kind !== "local") {
throw new Error(
'session catalog returned a remote plan for the local host; select its "node:<id>" host and retry',
);
}
return plan;
},
catalogFailureMessage: "catalog terminal start failed",
});
};
}

View File

@@ -4,10 +4,7 @@ import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
import { bindPluginRegistryRuntime } from "../../plugins/registry-runtime-binding.js";
import type { PluginRegistry } from "../../plugins/registry-types.js";
import { createPluginRuntime } from "../../plugins/runtime/index.js";
import {
listSessionCatalogEntries,
type SessionCatalogProvider,
} from "../../plugins/session-catalog.js";
import type { SessionCatalogProvider } from "../../plugins/session-catalog.js";
type TestPluginRegistry = Omit<PluginRegistry, "sessionCatalogs"> & {
sessionCatalogs: Array<{
@@ -415,111 +412,6 @@ describe("session catalog Gateway methods", () => {
}
});
it("shares one flattened entry snapshot across catalogs and creator projection", async () => {
hoisted.listSessionEntriesReadOnly.mockReturnValue([
{
sessionKey: "agent:main:alpha-adopted",
entry: { createdActor: { type: "agent", id: "worker-alpha" }, updatedAt: 2 },
},
{
sessionKey: "agent:main:zeta-adopted",
entry: { createdActor: { type: "system", id: "scheduler" }, updatedAt: 1 },
},
]);
const flattenedEntries: unknown[] = [];
const runtime = createPluginRuntime();
const catalogProvider = (id: string, sessionKey: string) =>
provider(id, {
list: vi.fn(async ({ sessionEntries }) => {
const entries = listSessionCatalogEntries({ config: {}, runtime, sessionEntries });
flattenedEntries.push(entries);
const adopted = entries.find((candidate) => candidate.sessionKey === sessionKey);
return [
{
hostId: `gateway:${id}`,
label: `${id} host`,
kind: "gateway" as const,
connected: true,
sessions: adopted
? [
{
threadId: `${id}-thread`,
status: "stored" as const,
archived: false,
sessionKey: adopted.sessionKey,
canContinue: true,
canArchive: false,
},
]
: [],
},
];
}),
});
hoisted.activeRegistry.sessionCatalogs = [
{ provider: catalogProvider("zeta", "agent:main:zeta-adopted") },
{ provider: catalogProvider("alpha", "agent:main:alpha-adopted") },
];
const respond = await call("sessions.catalog.list", {});
expect(hoisted.listSessionEntriesReadOnly).toHaveBeenCalledOnce();
expect(flattenedEntries).toHaveLength(2);
expect(flattenedEntries[0]).toBe(flattenedEntries[1]);
expect(respond).toHaveBeenCalledWith(true, {
catalogs: [
{
id: "alpha",
label: "ALPHA",
capabilities: { continueSession: false, archive: false },
hosts: [
{
hostId: "gateway:alpha",
label: "alpha host",
kind: "gateway",
connected: true,
sessions: [
{
threadId: "alpha-thread",
status: "stored",
archived: false,
sessionKey: "agent:main:alpha-adopted",
canContinue: true,
canArchive: false,
createdActor: { type: "agent", id: "worker-alpha" },
},
],
},
],
},
{
id: "zeta",
label: "ZETA",
capabilities: { continueSession: false, archive: false },
hosts: [
{
hostId: "gateway:zeta",
label: "zeta host",
kind: "gateway",
connected: true,
sessions: [
{
threadId: "zeta-thread",
status: "stored",
archived: false,
sessionKey: "agent:main:zeta-adopted",
canContinue: true,
canArchive: false,
createdActor: { type: "system", id: "scheduler" },
},
],
},
],
},
],
});
});
it("shares one lazy Gateway node snapshot across catalog providers", async () => {
const dispatchNodeList = vi.fn(async () => ({
nodes: [{ nodeId: "shared-node", connected: true }],
@@ -640,6 +532,72 @@ describe("session catalog Gateway methods", () => {
});
});
it("advertises terminal start only inside implemented create capabilities", async () => {
const createTarget = () => ({ model: "openai/gpt-5.6-sol", agentRuntime: "codex" });
hoisted.activeRegistry.sessionCatalogs = [
{
provider: provider("codex", {
resolveCreateSession: createTarget,
startTerminalSession: async ({ cwd }) => ({ kind: "local", argv: ["codex"], cwd }),
}),
},
{
provider: provider("readonly", { resolveCreateSession: createTarget }),
},
];
const respond = await call("sessions.catalog.list", {});
expect(respond).toHaveBeenCalledWith(true, {
catalogs: [
expect.objectContaining({
id: "codex",
capabilities: expect.objectContaining({
createSession: { model: "openai/gpt-5.6-sol", startTerminal: true },
}),
}),
expect.objectContaining({
id: "readonly",
capabilities: expect.objectContaining({
createSession: { model: "openai/gpt-5.6-sol" },
}),
}),
],
});
});
it("refuses direct terminal start when the requested agent has no create target", async () => {
const resolveCreateSession = vi.fn(() => undefined);
const startTerminalSession = vi.fn();
const open = vi.fn();
hoisted.activeRegistry.sessionCatalogs = [
{
provider: provider("codex", { resolveCreateSession, startTerminalSession }),
},
];
const config = { gateway: { cliAgents: { enabled: true } } };
const respond = await call(
"sessions.catalog.startTerminal",
{ catalogId: "codex", agentId: "restricted", cwd: process.cwd() },
config,
{ connId: "conn-1", connect: { scopes: ["operator.admin"] } },
{ isTerminalEnabled: () => true, terminalSessions: { open } },
);
expect(resolveCreateSession).toHaveBeenCalledWith({ agentId: "restricted" });
expect(startTerminalSession).not.toHaveBeenCalled();
expect(open).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: ErrorCodes.UNAVAILABLE,
message: "session catalog codex cannot create sessions",
}),
);
});
it("memoizes a provider's create target until runtime config identity changes", async () => {
let createSession: { model: string; agentRuntime: string } | undefined = {
model: "anthropic/claude-opus-4-8",

View File

@@ -32,6 +32,7 @@ import type { GatewayBroadcastToConnIdsFn } from "../server-broadcast-types.js";
import { resolveAgentIdOrRespondError } from "./agent-id-shared.js";
import { createSessionCatalogRequestEntrySnapshot } from "./session-catalog-entry-snapshot.js";
import { SessionCatalogListAdmission } from "./session-catalog-list-admission.js";
import { catalogStartHandler } from "./session-catalog-terminal-start.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
@@ -410,7 +411,12 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
const catalogList = await Promise.all(
selected.map(async (provider): Promise<SessionCatalog> => {
const createTarget = resolveProviderCreateTarget(provider, resolvedAgent.agentId, config);
const createSession = createTarget.ok ? { model: createTarget.target.model } : undefined;
const createSession = createTarget.ok
? {
model: createTarget.target.model,
...(provider.startTerminalSession ? { startTerminal: true as const } : {}),
}
: undefined;
const onHost = (host: SessionCatalog["hosts"][number]) => {
const catalog = catalogResult(
provider,
@@ -588,6 +594,11 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
}
},
"sessions.catalog.startTerminal": catalogStartHandler(
resolveSessionCatalogProvider,
resolveSessionCatalogCreateTarget,
),
"sessions.catalog.archive": async ({ params, respond }) => {
if (
!assertValidParams(

View File

@@ -62,8 +62,19 @@ function terminalEnabled(context: GatewayRequestHandlerOptions["context"]): bool
export { TERMINAL_OPEN_DEADLINE_MS } from "../terminal/open-deadline.js";
function respondTerminalOpenTimeout(respond: GatewayRequestHandlerOptions["respond"]): void {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal open timed out"));
function terminalFailureMessage(message: string, hint?: string): string {
return hint ? `${message}; ${hint}` : message;
}
function respondTerminalOpenTimeout(
respond: GatewayRequestHandlerOptions["respond"],
hint?: string,
): void {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, terminalFailureMessage("terminal open timed out", hint)),
);
}
function parseNodePayload(payload: unknown, payloadJSON?: string | null): unknown {
@@ -105,16 +116,24 @@ async function stageNodeTerminalUpload(
function respondLaunchBlocked(
respond: GatewayRequestHandlerOptions["respond"],
block: Extract<TerminalLaunchResolution, { ok: false }>["block"],
hint?: string,
): void {
if (block.kind === "disabled") {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is disabled"));
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, terminalFailureMessage("terminal is disabled", hint)),
);
return;
}
if (block.kind === "unknown-agent") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unknown agent "${block.agentId}"`),
errorShape(
ErrorCodes.INVALID_REQUEST,
terminalFailureMessage(`unknown agent "${block.agentId}"`, hint),
),
);
return;
}
@@ -124,46 +143,324 @@ function respondLaunchBlocked(
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`terminal unavailable: agent "${block.agentId}" runs in a sandbox (mode "${block.mode}"); in-sandbox terminals are not supported yet`,
terminalFailureMessage(
`terminal unavailable: agent "${block.agentId}" runs in a sandbox (mode "${block.mode}"); in-sandbox terminals are not supported yet`,
hint,
),
),
);
}
// A start RPC has no emulator dimensions yet. Match the Control UI's existing
// fallback grid; terminal.resize replaces it once the new tab is mounted.
export const CATALOG_TERMINAL_INITIAL_SIZE = { cols: 80, rows: 24 } as const;
type TerminalSessionOpenRequest = {
agentId?: string;
cols: number;
rows: number;
requiredCwd?: string;
resolveCatalogPlan?: () => Promise<SessionCatalogTerminalPlan>;
catalogFailureMessage?: string;
failureHint?: string;
};
/** Canonical terminal admission and launch path shared by shell, resume, and start RPCs. */
export async function openTerminalSession(
opts: GatewayRequestHandlerOptions,
request: TerminalSessionOpenRequest,
): Promise<void> {
const { respond, context } = opts;
const connId = requireConnId(opts);
if (!connId) {
return;
}
const manager = context.terminalSessions;
if (!manager) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
terminalFailureMessage("terminal is not available", request.failureHint),
),
);
return;
}
const launch = context.resolveTerminalLaunchPolicy(request.agentId);
if (!launch.ok) {
respondLaunchBlocked(respond, launch.block, request.failureHint);
return;
}
const deadline = createTerminalOpenDeadline();
let catalogPlan: SessionCatalogTerminalPlan | undefined;
let title: string | undefined;
let createBackend: (() => ReturnType<typeof createNodeRelayBackend>) | undefined;
let nodeRelay:
| {
plan: Extract<SessionCatalogTerminalPlan, { kind: "node" }>;
params: Record<string, unknown>;
}
| undefined;
let stageUpload: ((file: TerminalUploadFile) => Promise<TerminalUploadResult>) | undefined;
if (request.resolveCatalogPlan) {
try {
catalogPlan = await waitForTerminalOpenDeadline(request.resolveCatalogPlan, deadline);
} catch (error) {
if (error instanceof TerminalOpenDeadlineError) {
respondTerminalOpenTimeout(respond, request.failureHint);
return;
}
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
error instanceof Error
? terminalFailureMessage(error.message, request.failureHint)
: terminalFailureMessage(
request.catalogFailureMessage ?? "catalog terminal open failed",
request.failureHint,
),
),
);
return;
}
title = catalogPlan.title;
if (catalogPlan.kind === "local") {
if (catalogPlan.argv.length === 0) {
invalid(
respond,
terminalFailureMessage("catalog terminal plan has no command", request.failureHint),
);
return;
}
} else {
const nodeCatalogPlan = catalogPlan;
const access = authorizeCatalogTerminalNode(context, nodeCatalogPlan);
if (!access.ok) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
terminalFailureMessage(access.message, request.failureHint),
),
);
return;
}
let nodeParams: Record<string, unknown>;
try {
const parsed = JSON.parse(catalogPlan.paramsJSON) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("invalid params");
}
nodeParams = {
...(parsed as Record<string, unknown>),
cols: request.cols,
rows: request.rows,
};
} catch {
invalid(
respond,
terminalFailureMessage("catalog terminal plan has invalid params", request.failureHint),
);
return;
}
let policyResult: Awaited<ReturnType<typeof applyPluginNodeInvokePolicy>>;
try {
policyResult = await waitForTerminalOpenDeadline(
() =>
applyPluginNodeInvokePolicy({
context,
client: opts.client,
nodeSession: access.node,
command: nodeCatalogPlan.command,
params: nodeParams,
}),
deadline,
);
} catch (error) {
if (error instanceof TerminalOpenDeadlineError) {
respondTerminalOpenTimeout(respond, request.failureHint);
return;
}
throw error;
}
if (policyResult && !policyResult.ok) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
terminalFailureMessage(policyResult.message, request.failureHint),
),
);
return;
}
nodeRelay = { plan: nodeCatalogPlan, params: nodeParams };
stageUpload = async (file) =>
await stageNodeTerminalUpload(context, nodeCatalogPlan.nodeId, file);
}
}
if (context.isConnectionActive?.(connId) === false) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
terminalFailureMessage("terminal connection closed", request.failureHint),
),
);
return;
}
if (!terminalEnabled(context)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
terminalFailureMessage("terminal is disabled", request.failureHint),
),
);
return;
}
const refreshedLaunch = context.resolveTerminalLaunchPolicy(request.agentId);
if (!refreshedLaunch.ok) {
respondLaunchBlocked(respond, refreshedLaunch.block, request.failureHint);
return;
}
if (nodeRelay) {
const relay = nodeRelay;
const access = authorizeCatalogTerminalNode(context, relay.plan);
if (!access.ok) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
terminalFailureMessage(access.message, request.failureHint),
),
);
return;
}
createBackend = async () =>
await createNodeRelayBackend({
registry: context.nodeRegistry,
nodeId: relay.plan.nodeId,
expectedConnId: access.node.connId,
expectedPairingGeneration: access.node.pairingGeneration,
command: relay.plan.command,
params: relay.params,
});
}
const spawnPlan = resolveTerminalOpenSpawnPlan(refreshedLaunch.plan, catalogPlan);
if (request.requiredCwd !== undefined && spawnPlan.cwd !== request.requiredCwd) {
invalid(
respond,
terminalFailureMessage(
"cwd is no longer available; recreate or choose the worktree and retry",
request.failureHint,
),
);
return;
}
const terminalEnv =
catalogPlan?.kind === "local"
? mergeProcessEnv([
buildTerminalEnv(process.env),
catalogPlan.env,
// Preserve the PATH that found a login-shell CLI so env-based shebangs
// can resolve their interpreter inside the spawned terminal process.
catalogPlan.pathEnv ? { PATH: catalogPlan.pathEnv } : undefined,
])
: buildTerminalEnv(process.env);
let openingTerminal: ReturnType<typeof manager.open> | undefined;
let outcome: Awaited<ReturnType<typeof manager.open>>;
try {
outcome = await waitForTerminalOpenDeadline(() => {
openingTerminal = manager.open({
owner: { kind: "conn", connId },
agentId: spawnPlan.agentId,
cwd: spawnPlan.cwd,
shell: spawnPlan.shell,
args: spawnPlan.args,
cols: request.cols,
rows: request.rows,
env: terminalEnv,
signal: deadline.controller.signal,
...(createBackend ? { createBackend } : {}),
...(stageUpload ? { stageUpload } : {}),
});
return openingTerminal;
}, deadline);
} catch (error) {
if (error instanceof TerminalOpenDeadlineError) {
// The backend can register immediately before deadline arbitration.
// Close a late success by id so timeout never leaves an unreachable PTY.
if (openingTerminal) {
void openingTerminal.then(
(lateOutcome) => {
if (lateOutcome.ok) {
manager.close(connId, lateOutcome.sessionId);
}
},
() => undefined,
);
}
respondTerminalOpenTimeout(respond, request.failureHint);
return;
}
throw error;
}
if (!outcome.ok) {
const code = outcome.code === "limit" ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE;
respond(
false,
undefined,
errorShape(code, terminalFailureMessage(outcome.message, request.failureHint)),
);
return;
}
if (context.isConnectionActive?.(connId) === false) {
// A browser deadline can close the socket while PTY creation is still
// finishing. Release the raced session instead of leaving an orphan.
manager.close(connId, outcome.sessionId);
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
terminalFailureMessage("terminal connection closed", request.failureHint),
),
);
return;
}
context.logGateway.info(
`terminal opened session=${outcome.sessionId} agent=${outcome.agentId} conn=${connId} shell=${outcome.shell}`,
);
respond(true, {
sessionId: outcome.sessionId,
agentId: outcome.agentId,
shell: outcome.shell,
cwd: outcome.cwd,
confined: false,
...(title ? { title } : {}),
});
}
/** Handlers for the operator terminal method family. */
export const terminalHandlers: GatewayRequestHandlers = {
...terminalUploadHandlers,
"terminal.open": async (opts) => {
const { params, respond, context } = opts;
const { params, respond } = opts;
if (!assertValidParams(params, validateTerminalOpenParams, "terminal.open", respond)) {
return;
}
const connId = requireConnId(opts);
if (!connId) {
return;
}
const manager = context.terminalSessions;
if (!manager) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is not available"));
return;
}
const p = params as TerminalOpenParams;
const launch = context.resolveTerminalLaunchPolicy(p.agentId);
if (!launch.ok) {
respondLaunchBlocked(respond, launch.block);
return;
}
const deadline = createTerminalOpenDeadline();
let catalogPlan: SessionCatalogTerminalPlan | undefined;
let title: string | undefined;
let createBackend: (() => ReturnType<typeof createNodeRelayBackend>) | undefined;
let nodeRelay:
| {
plan: Extract<SessionCatalogTerminalPlan, { kind: "node" }>;
params: Record<string, unknown>;
}
| undefined;
let stageUpload: ((file: TerminalUploadFile) => Promise<TerminalUploadResult>) | undefined;
let resolveCatalogPlan: (() => Promise<SessionCatalogTerminalPlan>) | undefined;
if (p.catalog) {
const provider = resolveSessionCatalogProvider(p.catalog.catalogId);
if (!provider) {
@@ -184,185 +481,18 @@ export const terminalHandlers: GatewayRequestHandlers = {
}
const openTerminal = provider.openTerminal;
const catalog = p.catalog;
try {
catalogPlan = await waitForTerminalOpenDeadline(
() =>
openTerminal.call(provider, {
hostId: catalog.hostId,
threadId: catalog.threadId,
}),
deadline,
);
} catch (error) {
if (error instanceof TerminalOpenDeadlineError) {
respondTerminalOpenTimeout(respond);
return;
}
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
error instanceof Error ? error.message : "catalog terminal open failed",
),
);
return;
}
title = catalogPlan.title;
if (catalogPlan.kind === "local") {
if (catalogPlan.argv.length === 0) {
invalid(respond, "catalog terminal plan has no command");
return;
}
} else {
const nodeCatalogPlan = catalogPlan;
const access = authorizeCatalogTerminalNode(context, nodeCatalogPlan);
if (!access.ok) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, access.message));
return;
}
let nodeParams: Record<string, unknown>;
try {
const parsed = JSON.parse(catalogPlan.paramsJSON) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("invalid params");
}
nodeParams = { ...(parsed as Record<string, unknown>), cols: p.cols, rows: p.rows };
} catch {
invalid(respond, "catalog terminal plan has invalid params");
return;
}
let policyResult: Awaited<ReturnType<typeof applyPluginNodeInvokePolicy>>;
try {
policyResult = await waitForTerminalOpenDeadline(
() =>
applyPluginNodeInvokePolicy({
context,
client: opts.client,
nodeSession: access.node,
command: nodeCatalogPlan.command,
params: nodeParams,
}),
deadline,
);
} catch (error) {
if (error instanceof TerminalOpenDeadlineError) {
respondTerminalOpenTimeout(respond);
return;
}
throw error;
}
if (policyResult && !policyResult.ok) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, policyResult.message));
return;
}
nodeRelay = { plan: nodeCatalogPlan, params: nodeParams };
stageUpload = async (file) =>
await stageNodeTerminalUpload(context, nodeCatalogPlan.nodeId, file);
}
}
if (context.isConnectionActive?.(connId) === false) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal connection closed"));
return;
}
if (!terminalEnabled(context)) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is disabled"));
return;
}
const refreshedLaunch = context.resolveTerminalLaunchPolicy(p.agentId);
if (!refreshedLaunch.ok) {
respondLaunchBlocked(respond, refreshedLaunch.block);
return;
}
if (nodeRelay) {
const relay = nodeRelay;
const access = authorizeCatalogTerminalNode(context, relay.plan);
if (!access.ok) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, access.message));
return;
}
createBackend = async () =>
await createNodeRelayBackend({
registry: context.nodeRegistry,
nodeId: relay.plan.nodeId,
expectedConnId: access.node.connId,
expectedPairingGeneration: access.node.pairingGeneration,
command: relay.plan.command,
params: relay.params,
resolveCatalogPlan = async () =>
await openTerminal.call(provider, {
hostId: catalog.hostId,
threadId: catalog.threadId,
});
}
const spawnPlan = resolveTerminalOpenSpawnPlan(refreshedLaunch.plan, catalogPlan);
const terminalEnv =
catalogPlan?.kind === "local"
? mergeProcessEnv([
buildTerminalEnv(process.env),
catalogPlan.env,
// Preserve the PATH that found a login-shell CLI so env-based shebangs
// can resolve their interpreter inside the spawned terminal process.
catalogPlan.pathEnv ? { PATH: catalogPlan.pathEnv } : undefined,
])
: buildTerminalEnv(process.env);
let openingTerminal: ReturnType<typeof manager.open> | undefined;
let outcome: Awaited<ReturnType<typeof manager.open>>;
try {
outcome = await waitForTerminalOpenDeadline(() => {
openingTerminal = manager.open({
owner: { kind: "conn", connId },
agentId: spawnPlan.agentId,
cwd: spawnPlan.cwd,
shell: spawnPlan.shell,
args: spawnPlan.args,
cols: p.cols,
rows: p.rows,
env: terminalEnv,
signal: deadline.controller.signal,
...(createBackend ? { createBackend } : {}),
...(stageUpload ? { stageUpload } : {}),
});
return openingTerminal;
}, deadline);
} catch (error) {
if (error instanceof TerminalOpenDeadlineError) {
// The backend can register immediately before deadline arbitration.
// Close a late success by id so timeout never leaves an unreachable PTY.
if (openingTerminal) {
void openingTerminal.then(
(lateOutcome) => {
if (lateOutcome.ok) {
manager.close(connId, lateOutcome.sessionId);
}
},
() => undefined,
);
}
respondTerminalOpenTimeout(respond);
return;
}
throw error;
}
if (!outcome.ok) {
const code = outcome.code === "limit" ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE;
respond(false, undefined, errorShape(code, outcome.message));
return;
}
if (context.isConnectionActive?.(connId) === false) {
// A browser deadline can close the socket while PTY creation is still
// finishing. Release the raced session instead of leaving an orphan.
manager.close(connId, outcome.sessionId);
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal connection closed"));
return;
}
context.logGateway.info(
`terminal opened session=${outcome.sessionId} agent=${outcome.agentId} conn=${connId} shell=${outcome.shell}`,
);
respond(true, {
sessionId: outcome.sessionId,
agentId: outcome.agentId,
shell: outcome.shell,
cwd: outcome.cwd,
confined: false,
...(title ? { title } : {}),
await openTerminalSession(opts, {
...(p.agentId ? { agentId: p.agentId } : {}),
cols: p.cols,
rows: p.rows,
...(resolveCatalogPlan ? { resolveCatalogPlan } : {}),
catalogFailureMessage: "catalog terminal open failed",
});
},

View File

@@ -7,6 +7,7 @@ export type {
SessionCatalogListProviderParams,
SessionCatalogProvider,
SessionCatalogReadProviderParams,
SessionCatalogStartTerminalProviderParams,
SessionCatalogTerminalPlan,
SessionUpstreamActivity,
SessionUpstreamJsonValue,
@@ -40,6 +41,8 @@ export type {
SessionsCatalogListResult,
SessionsCatalogReadParams,
SessionsCatalogReadResult,
SessionsCatalogStartTerminalParams,
SessionsCatalogStartTerminalResult,
} from "../../packages/gateway-protocol/src/schema/sessions-catalog.js";
export {
deleteSessionUpstreamLink,

View File

@@ -34,6 +34,14 @@ export type SessionCatalogContinueProviderParams = Omit<
};
export type SessionCatalogArchiveProviderParams = Omit<SessionsCatalogArchiveParams, "catalogId">;
export type SessionCatalogStartTerminalProviderParams = {
agentId: string;
cwd: string;
initialMessage?: string;
/** Present only when the caller selected a catalog host backed by this node. */
nodeId?: string;
};
export type SessionCatalogTerminalPlan =
| {
kind: "local";
@@ -156,6 +164,9 @@ export type SessionCatalogProvider = {
hostId: string;
threadId: string;
}) => Promise<SessionCatalogTerminalPlan>;
startTerminalSession?: (
request: SessionCatalogStartTerminalProviderParams,
) => Promise<SessionCatalogTerminalPlan>;
};
type SessionCatalogAdoptedSource = { hostId: string; threadId: string };