Skip to content

Commit f80d481

Browse files
Nifuryclaude
andcommitted
feat(server): add global session status endpoint
GET /global/session/status reports every non-idle session across all loaded instances, keyed by directory, without needing a directory header. Previously /session/status was instance-scoped, so answering "is anything running anywhere" meant enumerating projects and polling each directory (which spins up instances as a side effect). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ff35723 commit f80d481

9 files changed

Lines changed: 148 additions & 1 deletion

File tree

‎packages/opencode/src/effect/instance-state.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export const useEffect = <A, E, R, B, E2, R2>(
5656
select: (value: A) => Effect.Effect<B, E2, R2>,
5757
) => Effect.flatMap(get(self), select)
5858

59+
export const entries = <A, E, R>(self: InstanceState<A, E, R>) => ScopedCache.entries(self.cache)
60+
5961
export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
6062
Effect.gen(function* () {
6163
return yield* ScopedCache.has(self.cache, yield* directory)

‎packages/opencode/src/server/routes/instance/httpapi/groups/global.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import "@/server/event"
77
import { Schema } from "effect"
88
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
99
import semver from "semver"
10+
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
1011
import { described } from "./metadata"
1112

1213
const GlobalHealth = Schema.Struct({
@@ -71,8 +72,13 @@ export const GlobalPaths = {
7172
config: "/global/config",
7273
dispose: "/global/dispose",
7374
upgrade: "/global/upgrade",
75+
sessionStatus: "/global/session/status",
7476
} as const
7577

78+
// directory -> sessionID -> status. Only non-idle sessions are tracked, so an
79+
// empty object means nothing is running anywhere.
80+
const GlobalSessionStatus = Schema.Record(Schema.String, Schema.Record(Schema.String, SessionStatusEvent.Info))
81+
7682
export const GlobalApi = HttpApi.make("global").add(
7783
HttpApiGroup.make("global")
7884
.add(
@@ -123,6 +129,16 @@ export const GlobalApi = HttpApi.make("global").add(
123129
description: "Clean up and dispose all OpenCode instances, releasing all resources.",
124130
}),
125131
),
132+
HttpApiEndpoint.get("sessionStatus", GlobalPaths.sessionStatus, {
133+
success: described(GlobalSessionStatus, "Busy sessions grouped by directory"),
134+
}).annotateMerge(
135+
OpenApi.annotations({
136+
identifier: "global.sessionStatus",
137+
summary: "Get session status across all directories",
138+
description:
139+
"Retrieve every non-idle session across all loaded instances, keyed by directory. Instances that are not currently loaded are not reported.",
140+
}),
141+
),
126142
HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, {
127143
payload: GlobalUpgradeInput,
128144
success: described(GlobalUpgradeResult, "Upgrade result"),

‎packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
33
import { EffectBridge } from "@/effect/bridge"
44
import { EventV2 } from "@opencode-ai/core/event"
55
import { Installation } from "@/installation"
6+
import { SessionStatus } from "@/session/status"
67
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
78
import { InstallationVersion } from "@opencode-ai/core/installation/version"
89
import { Effect, Queue } from "effect"
@@ -61,6 +62,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
6162
Effect.gen(function* () {
6263
const config = yield* Config.Service
6364
const installation = yield* Installation.Service
65+
const sessionStatusSvc = yield* SessionStatus.Service
6466
const bridge = yield* EffectBridge.make()
6567

6668
const health = Effect.fn("GlobalHttpApi.health")(function* () {
@@ -71,6 +73,11 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
7173
return yield* eventResponse()
7274
})
7375

76+
const sessionStatus = Effect.fn("GlobalHttpApi.sessionStatus")(function* () {
77+
const all = yield* sessionStatusSvc.listAll()
78+
return Object.fromEntries(all.entries().map(([dir, statuses]) => [dir, Object.fromEntries(statuses)]))
79+
})
80+
7481
const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () {
7582
return yield* config.getGlobal()
7683
})
@@ -122,5 +129,6 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
122129
.handle("configUpdate", configUpdate)
123130
.handle("dispose", dispose)
124131
.handle("upgrade", upgrade)
132+
.handle("sessionStatus", sessionStatus)
125133
}),
126134
)

‎packages/opencode/src/session/status.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export const Event = SessionStatusEvent
1313
export interface Interface {
1414
readonly get: (sessionID: SessionID) => Effect.Effect<Info>
1515
readonly list: () => Effect.Effect<Map<SessionID, Info>>
16+
/** Every non-idle session across all loaded instances, keyed by directory. */
17+
readonly listAll: () => Effect.Effect<Map<string, Map<SessionID, Info>>>
1618
readonly set: (sessionID: SessionID, status: Info) => Effect.Effect<void>
1719
}
1820

@@ -36,6 +38,13 @@ const layer = Layer.effect(
3638
return new Map(yield* InstanceState.get(state))
3739
})
3840

41+
const listAll = Effect.fn("SessionStatus.listAll")(function* () {
42+
const entries = yield* InstanceState.entries(state)
43+
return new Map(
44+
entries.filter(([, statuses]) => statuses.size > 0).map(([dir, statuses]) => [dir, new Map(statuses)]),
45+
)
46+
})
47+
3948
const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) {
4049
const data = yield* InstanceState.get(state)
4150
yield* events.publish(Event.Status, { sessionID, status })
@@ -47,7 +56,7 @@ const layer = Layer.effect(
4756
data.set(sessionID, status)
4857
})
4958

50-
return Service.of({ get, list, set })
59+
return Service.of({ get, list, listAll, set })
5160
}),
5261
)
5362

‎packages/opencode/test/server/httpapi-control-plane.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Auth } from "../../src/auth"
1010
import { Config } from "../../src/config/config"
1111
import { Installation } from "../../src/installation"
1212
import { ServerAuth } from "../../src/server/auth"
13+
import { SessionStatus } from "../../src/session/status"
1314
import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api"
1415
import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control"
1516
import { controlPlaneHandlers } from "../../src/server/routes/instance/httpapi/handlers/control-plane"
@@ -44,6 +45,11 @@ const apiLayer = HttpRouter.serve(
4445
moveSession: (value) => Ref.set(called, value),
4546
}),
4647
),
48+
Layer.provide(
49+
Layer.mock(SessionStatus.Service)({
50+
listAll: () => Effect.succeed(new Map([["/tmp/proj", new Map([["ses_1" as never, { type: "busy" as const }]])]])),
51+
}),
52+
),
4753
Layer.provide(ServerAuth.Config.configLayer({ password: Option.none(), username: "opencode" })),
4854
)
4955
const it = testEffect(apiLayer)

‎packages/opencode/test/server/httpapi-global.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Config } from "../../src/config/config"
88
import { Installation } from "../../src/installation"
99
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
1010
import { ServerAuth } from "../../src/server/auth"
11+
import { SessionStatus } from "../../src/session/status"
1112
import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api"
1213
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
1314
import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control"
@@ -38,11 +39,25 @@ const apiLayer = HttpRouter.serve(
3839
upgrade: () => Effect.void,
3940
}),
4041
),
42+
Layer.provide(
43+
Layer.mock(SessionStatus.Service)({
44+
listAll: () => Effect.succeed(new Map([["/tmp/proj", new Map([["ses_1" as never, { type: "busy" as const }]])]])),
45+
}),
46+
),
4147
Layer.provide(ServerAuth.Config.configLayer({ password: Option.none(), username: "opencode" })),
4248
)
4349
const it = testEffect(apiLayer)
4450

4551
describe("global HttpApi", () => {
52+
it.live("reports busy sessions across every directory", () =>
53+
Effect.gen(function* () {
54+
const response = yield* HttpClientRequest.get(GlobalPaths.sessionStatus).pipe(HttpClient.execute)
55+
56+
expect(response.status).toBe(200)
57+
expect(yield* response.json).toEqual({ "/tmp/proj": { ses_1: { type: "busy" } } })
58+
}),
59+
)
60+
4661
it.live("upgrades to the requested version", () =>
4762
Effect.gen(function* () {
4863
const response = yield* HttpClientRequest.post(GlobalPaths.upgrade).pipe(

‎packages/sdk/js/src/v2/gen/sdk.gen.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ import type {
8686
GlobalEventResponses,
8787
GlobalHealthErrors,
8888
GlobalHealthResponses,
89+
GlobalSessionStatusErrors,
90+
GlobalSessionStatusResponses,
8991
GlobalUpgradeErrors,
9092
GlobalUpgradeResponses,
9193
InstanceDisposeErrors,
@@ -1352,6 +1354,18 @@ export class Global extends HeyApiClient {
13521354
})
13531355
}
13541356

1357+
/**
1358+
* Get session status across all directories
1359+
*
1360+
* Retrieve every non-idle session across all loaded instances, keyed by directory. Instances that are not currently loaded are not reported.
1361+
*/
1362+
public sessionStatus<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
1363+
return (options?.client ?? this.client).get<GlobalSessionStatusResponses, GlobalSessionStatusErrors, ThrowOnError>({
1364+
url: "/global/session/status",
1365+
...options,
1366+
})
1367+
}
1368+
13551369
/**
13561370
* Upgrade opencode
13571371
*

‎packages/sdk/js/src/v2/gen/types.gen.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2024,6 +2024,7 @@ export type Config = {
20242024
experimental?: {
20252025
disable_paste_summary?: boolean
20262026
batch_tool?: boolean
2027+
small_model_enabled?: boolean
20272028
openTelemetry?: boolean
20282029
primary_tools?: Array<string>
20292030
continue_loop_on_deny?: boolean
@@ -7354,6 +7355,35 @@ export type GlobalDisposeResponses = {
73547355

73557356
export type GlobalDisposeResponse = GlobalDisposeResponses[keyof GlobalDisposeResponses]
73567357

7358+
export type GlobalSessionStatusData = {
7359+
body?: never
7360+
path?: never
7361+
query?: never
7362+
url: "/global/session/status"
7363+
}
7364+
7365+
export type GlobalSessionStatusErrors = {
7366+
/**
7367+
* Bad request
7368+
*/
7369+
400: BadRequestError
7370+
}
7371+
7372+
export type GlobalSessionStatusError = GlobalSessionStatusErrors[keyof GlobalSessionStatusErrors]
7373+
7374+
export type GlobalSessionStatusResponses = {
7375+
/**
7376+
* Busy sessions grouped by directory
7377+
*/
7378+
200: {
7379+
[key: string]: {
7380+
[key: string]: SessionStatus
7381+
}
7382+
}
7383+
}
7384+
7385+
export type GlobalSessionStatusResponse = GlobalSessionStatusResponses[keyof GlobalSessionStatusResponses]
7386+
73577387
export type GlobalUpgradeData = {
73587388
body?: {
73597389
target: string

‎packages/sdk/openapi.json‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,50 @@
484484
]
485485
}
486486
},
487+
"/global/session/status": {
488+
"get": {
489+
"tags": ["global"],
490+
"operationId": "global.sessionStatus",
491+
"parameters": [],
492+
"responses": {
493+
"200": {
494+
"description": "Busy sessions grouped by directory",
495+
"content": {
496+
"application/json": {
497+
"schema": {
498+
"type": "object",
499+
"additionalProperties": {
500+
"type": "object",
501+
"additionalProperties": {
502+
"$ref": "#/components/schemas/SessionStatus"
503+
}
504+
},
505+
"description": "Busy sessions grouped by directory"
506+
}
507+
}
508+
}
509+
},
510+
"400": {
511+
"description": "Bad request",
512+
"content": {
513+
"application/json": {
514+
"schema": {
515+
"$ref": "#/components/schemas/BadRequestError"
516+
}
517+
}
518+
}
519+
}
520+
},
521+
"description": "Retrieve every non-idle session across all loaded instances, keyed by directory. Instances that are not currently loaded are not reported.",
522+
"summary": "Get session status across all directories",
523+
"x-codeSamples": [
524+
{
525+
"lang": "js",
526+
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.sessionStatus({\n ...\n})"
527+
}
528+
]
529+
}
530+
},
487531
"/global/upgrade": {
488532
"post": {
489533
"tags": ["global"],
@@ -21537,6 +21581,9 @@
2153721581
"batch_tool": {
2153821582
"type": "boolean"
2153921583
},
21584+
"small_model_enabled": {
21585+
"type": "boolean"
21586+
},
2154021587
"openTelemetry": {
2154121588
"type": "boolean"
2154221589
},

0 commit comments

Comments
 (0)