Skip to content

Commit a27ec88

Browse files
t3dotggguillermolg00claude
authored
feat(server,web): see and stop the dev servers a thread started (#22542)
Co-authored-by: Guillermo <guillermolopezg00@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 8d94203 commit a27ec88

24 files changed

Lines changed: 971 additions & 43 deletions

‎apps/server/src/auth/RpcAuthorization.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ describe("RPC authorization scopes", () => {
5959
WS_METHODS.terminalClear,
6060
WS_METHODS.terminalRestart,
6161
WS_METHODS.terminalClose,
62+
WS_METHODS.previewStopLocalServer,
6263
]) {
6364
expect(requiredScopeForRpcMethod(method)).toBe(AuthTerminalOperateScope);
6465
}

‎apps/server/src/auth/RpcAuthorization.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ export const RPC_REQUIRED_SCOPES = {
196196
[WS_METHODS.previewClearProfile]: AuthPreviewOperateScope,
197197
[WS_METHODS.previewReportProfiles]: AuthPreviewOperateScope,
198198
[WS_METHODS.previewReportStatus]: AuthPreviewOperateScope,
199+
[WS_METHODS.previewStopLocalServer]: AuthTerminalOperateScope,
199200
[WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
200201
[WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
201202
[WS_METHODS.deviceConfigure]: AuthSettingsWriteScope,

‎apps/server/src/environment/ServerEnvironment.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ export const make = Effect.gen(function* () {
255255
threadPullRequestLinking: true,
256256
serverResolvedCommandContext: true,
257257
threadStop: true,
258+
...(hostPlatform === "win32" ? {} : { localServerStop: true }),
258259
environmentIcon: true,
259260
projectCloneTracking: true,
260261
ignoredFileListing: true,

‎apps/server/src/observability/RpcInstrumentation.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ const RPC_AGGREGATES = {
204204
[WS_METHODS.previewReportStatus]: "preview",
205205
[WS_METHODS.subscribePreviewEvents]: "preview",
206206
[WS_METHODS.subscribeDiscoveredLocalServers]: "preview",
207+
[WS_METHODS.previewStopLocalServer]: "preview",
207208
[WS_METHODS.deviceConfigure]: "device",
208209
[WS_METHODS.deviceTestHost]: "device",
209210
[WS_METHODS.deviceEasAccount]: "device",

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess";
7474
import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
7575
import { SpawnExecutableResolution } from "@t3tools/shared/shell";
7676

77+
import * as PortScanner from "../../preview/PortScanner.ts";
7778
import { attachmentRelativePath } from "../../attachmentStore.ts";
7879
import { WorkflowAgentTranscriptReadObserver } from "../workflowAgentTranscript.ts";
7980
import * as ServerConfig from "../../config.ts";
@@ -2325,7 +2326,7 @@ describe("ClaudeAdapterV2 MCP credential channel", () => {
23252326
},
23262327
});
23272328
}).pipe(
2328-
Effect.provide(ClaudeAdapterV2.layerQueryRunner),
2329+
Effect.provide(ClaudeAdapterV2.layerQueryRunner.pipe(Layer.provide(PortScanner.layerTest))),
23292330
Effect.provideService(ProviderEventLoggers.ProviderEventLoggers, {
23302331
...ProviderEventLoggers.NoOpProviderEventLoggers,
23312332
native,

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ import {
138138
type CompiledClaudeModelSelection,
139139
} from "../../claudeModelOptions.ts";
140140
import * as ServerConfig from "../../config.ts";
141+
import * as PortScanner from "../../preview/PortScanner.ts";
141142
import * as ServerSettings from "../../serverSettings.ts";
142143
import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
143144
import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe";
@@ -917,6 +918,7 @@ export const layerQueryRunner: Layer.Layer<
917918
| ChildProcessSpawner.ChildProcessSpawner
918919
| Crypto.Crypto
919920
| Path.Path
921+
| PortScanner.PortDiscovery
920922
| ProviderEventLoggers.ProviderEventLoggers
921923
> = Layer.effect(
922924
ClaudeAgentSdkQueryRunner,
@@ -928,6 +930,8 @@ export const layerQueryRunner: Layer.Layer<
928930
const agentScope = yield* AgentScope;
929931
const platform = yield* HostProcess.Platform;
930932
const runFork = Effect.runForkWith(yield* Effect.context<never>());
933+
const hostEnvironment = yield* HostProcess.Environment;
934+
const portDiscovery = yield* PortScanner.PortDiscovery;
931935

932936
return ClaudeAgentSdkQueryRunner.of({
933937
allocateSessionId: crypto
@@ -951,7 +955,15 @@ export const layerQueryRunner: Layer.Layer<
951955
Stream.toAsyncIterable,
952956
);
953957
// Send MCP credentials over stdin to keep them out of SDK argv and inherited command environments.
954-
const { mcpServers, ...queryOptions } = input.options;
958+
const { mcpServers, ...sdkOptions } = input.options;
959+
// Jobs the agent starts inherit the thread, so port discovery can attribute their servers.
960+
const queryOptions = {
961+
...sdkOptions,
962+
env: {
963+
...(sdkOptions.env ?? hostEnvironment),
964+
[PortScanner.AGENT_THREAD_ENV]: input.threadId,
965+
},
966+
};
955967
// The SDK can prepend a scope wrapper to native binaries; script entries run through node.
956968
const binaryPath = queryOptions.pathToClaudeCodeExecutable;
957969
const launch =
@@ -998,6 +1010,10 @@ export const layerQueryRunner: Layer.Layer<
9981010
const spawnClaudeCodeProcess = (spawnOptions: ClaudeSpawnOptions) => {
9991011
const child = spawnProcess(spawnOptions);
10001012
spawnedProcess = child;
1013+
// A spawn that fails emits only "error".
1014+
const releaseAgent = portDiscovery.trackAgent();
1015+
child.once("exit", releaseAgent);
1016+
child.on("error", releaseAgent);
10011017
processExited = child.exitCode !== null || child.signalCode != null;
10021018
processExit = new Promise<void>((resolve) => {
10031019
if (processExited) resolve();

‎apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { AgentScope } from "@t3tools/shared/AgentScope";
2424
import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers";
2525
import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
2626
import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
27+
import * as PortScanner from "../../preview/PortScanner.ts";
2728
import {
2829
ClaudeAgentSdkQueryRunner,
2930
type ClaudeAgentSdkQueryOpenInput,
@@ -66,6 +67,7 @@ const writeClaudeSession = Effect.fnUntraced(function* (configDir: string) {
6667
});
6768

6869
const forkRunnerLayer = layerQueryRunner.pipe(
70+
Layer.provide(PortScanner.layerTest),
6971
Layer.provide(Layer.succeed(ProviderEventLoggers, { native: undefined, canonical: undefined })),
7072
);
7173

@@ -110,6 +112,7 @@ it.effect("forks a session stored under the instance's own CLAUDE_CONFIG_DIR", (
110112
).pipe(Effect.provide(NodeServices.layer)),
111113
);
112114
const realQueryRunner = layerQueryRunner.pipe(
115+
Layer.provide(PortScanner.layerTest),
113116
Layer.provide(
114117
Layer.succeed(ProviderEventLoggers, {
115118
native: undefined,
@@ -487,4 +490,54 @@ describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI pro
487490
yield* Effect.promise(() => turnStopped);
488491
}),
489492
);
493+
494+
it.effect("marks the CLI's jobs with its thread and counts it as an agent until it exits", () => {
495+
let runningAgents = 0;
496+
let releaseAgent!: () => void;
497+
const agentReleased = new Promise<void>((resolve) => (releaseAgent = resolve));
498+
const runner = layerQueryRunner.pipe(
499+
Layer.provide(
500+
Layer.mock(PortScanner.PortDiscovery)({
501+
trackAgent: () => {
502+
runningAgents += 1;
503+
return () => {
504+
runningAgents -= 1;
505+
releaseAgent();
506+
};
507+
},
508+
}),
509+
),
510+
Layer.provide(
511+
Layer.succeed(ProviderEventLoggers, { native: undefined, canonical: undefined }),
512+
),
513+
Layer.provide(NodeServices.layer),
514+
);
515+
return Effect.gen(function* () {
516+
const cli = fakeClaudeCli(
517+
[
518+
'dir=$(dirname "$0")',
519+
`printf '%s' "$${PortScanner.AGENT_THREAD_ENV}" > "$dir/thread"`,
520+
"cat > /dev/null",
521+
].join("\n"),
522+
);
523+
const dir = NodePath.dirname(cli);
524+
NodeChildProcess.execFileSync("mkfifo", [NodePath.join(dir, "thread")]);
525+
yield* Effect.addFinalizer(() =>
526+
Effect.sync(() => NodeFS.rmSync(dir, { recursive: true, force: true })),
527+
);
528+
const threadRead = new Promise<string>((resolve) => {
529+
let thread = "";
530+
NodeFS.createReadStream(NodePath.join(dir, "thread"), "utf8")
531+
.on("data", (chunk) => (thread += chunk))
532+
.once("end", () => resolve(thread));
533+
});
534+
535+
const session = yield* openFakeCli(cli);
536+
assert.equal(yield* Effect.promise(() => threadRead), "thread-fake-cli");
537+
assert.equal(runningAgents, 1);
538+
yield* session.close;
539+
yield* Effect.promise(() => agentReleased);
540+
assert.equal(runningAgents, 0);
541+
}).pipe(Effect.provide(runner));
542+
});
490543
});

0 commit comments

Comments
 (0)