talentos 1.1.6 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { rm } from "node:fs/promises";
4
- import { dirname, join } from "node:path";
4
+ import { delimiter, dirname, join } from "node:path";
5
5
  import { createInterface } from "node:readline";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { promisify } from "node:util";
@@ -11,6 +11,7 @@ import { gatewayLauncherPath } from "../gateway-install.js";
11
11
  import { providerPathEnv, resolveProviderBinary } from "../provider-binary.js";
12
12
  import { codexRoomArgs, ROOM_TOOLS } from "./codex-policy.js";
13
13
  import { CodexBoundaryError, probeCodexBoundary } from "./codex-probe.js";
14
+ import { prepareOwnerLaunch } from "./native/launch.js";
14
15
  const CHILD_ENV = [
15
16
  "HOME",
16
17
  "PATH",
@@ -190,7 +191,26 @@ export async function runProvider(input) {
190
191
  const catalogPath = join(dirname(workDir), ".talentos-codex-models.json");
191
192
  const binary = resolveProviderBinary(config.provider, config.providerPath);
192
193
  let command, args;
193
- if (config.provider === "claude_code") {
194
+ let ownerLaunch;
195
+ let stopWatching;
196
+ if (input.owner && config.provider === "claude_code") {
197
+ const watching = await input.owner.watch?.();
198
+ stopWatching = watching?.stop;
199
+ ownerLaunch = await prepareOwnerLaunch({
200
+ agentId: config.agentId,
201
+ askSocket: watching?.socketPath,
202
+ directory: dirname(workDir),
203
+ home: input.owner.home,
204
+ instructions,
205
+ jobId: job.jobId,
206
+ prompt,
207
+ sessionId,
208
+ workspace: input.owner.workspace,
209
+ });
210
+ command = binary;
211
+ args = ownerLaunch.args;
212
+ }
213
+ else if (config.provider === "claude_code") {
194
214
  await writePrivateFile(mcpPath, JSON.stringify({
195
215
  mcpServers: {
196
216
  talentos: {
@@ -249,11 +269,26 @@ export async function runProvider(input) {
249
269
  });
250
270
  }
251
271
  const child = spawn(command, args, {
252
- cwd: workDir,
253
- env: childEnvironment(process.env, binary),
272
+ cwd: input.owner?.workspace.cwd ?? workDir,
273
+ env: input.owner
274
+ ? {
275
+ ...input.owner.env,
276
+ PATH: [dirname(binary), input.owner.env.PATH]
277
+ .filter(Boolean)
278
+ .join(delimiter),
279
+ }
280
+ : childEnvironment(process.env, binary),
254
281
  stdio: ["ignore", "pipe", "ignore"],
255
282
  detached: process.platform !== "win32",
256
283
  });
284
+ const marked = new Set();
285
+ const mark = (name) => {
286
+ if (!marked.has(name)) {
287
+ marked.add(name);
288
+ input.mark?.(name);
289
+ }
290
+ };
291
+ child.once("spawn", () => mark("spawn"));
257
292
  let killTimer;
258
293
  const stop = () => {
259
294
  const pid = child.pid;
@@ -302,6 +337,10 @@ export async function runProvider(input) {
302
337
  continue;
303
338
  }
304
339
  const event = readProviderEvent(config.provider, value);
340
+ mark("firstEvent");
341
+ if (event.status) {
342
+ mark("firstTool");
343
+ }
305
344
  if (event.failed) {
306
345
  failed = true;
307
346
  }
@@ -309,7 +348,8 @@ export async function runProvider(input) {
309
348
  await input.remember(event.sessionId);
310
349
  }
311
350
  if (event.status) {
312
- input.status(event.status);
351
+ // Room-visible status never names the owner's private tools.
352
+ input.status(ownerLaunch ? "Working on your computer…" : event.status);
313
353
  }
314
354
  if (event.text !== undefined) {
315
355
  text = event.text.slice(0, 12_000);
@@ -334,5 +374,7 @@ export async function runProvider(input) {
334
374
  }
335
375
  await rm(mcpPath, { force: true });
336
376
  await rm(catalogPath, { force: true });
377
+ stopWatching?.();
378
+ await ownerLaunch?.cleanup();
337
379
  }
338
380
  }
@@ -7,6 +7,11 @@ export const ROOM_TOOLS = [
7
7
  "suggest_change",
8
8
  "create_file",
9
9
  "create_deck",
10
+ "create_web_app",
11
+ "revise_web_app",
12
+ "upload_file",
13
+ "prepare_upload",
14
+ "commit_upload",
10
15
  "post_message",
11
16
  "share_link",
12
17
  "whoami",
@@ -4,8 +4,11 @@ import { AgentClient } from "../client.js";
4
4
  import { startMcpProxy } from "../mcp-proxy.js";
5
5
  // A local stdio entry avoids putting the bearer key in Codex's argv or child
6
6
  // environment. Only the MCP proxy reads the linked-agent credential file.
7
- const config = (await localAgents()).find((agent) => agent.agentId === parseArgv(process.argv.slice(2)).options.agent);
7
+ const { options } = parseArgv(process.argv.slice(2));
8
+ const config = (await localAgents()).find((agent) => agent.agentId === options.agent);
8
9
  if (!config) {
9
10
  throw new Error("Linked agent configuration is unavailable.");
10
11
  }
11
- await startMcpProxy(new AgentClient({ apiUrl: config.apiUrl, apiKey: config.apiKey }));
12
+ await startMcpProxy(new AgentClient({ apiUrl: config.apiUrl, apiKey: config.apiKey }),
13
+ // The launcher names the job's workspace; a job with none uploads nothing.
14
+ options["upload-root"]);
@@ -0,0 +1,188 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { createServer } from "node:net";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ // The supervisor's half of an ask (#10987 PR 2). The hook (a separate process,
6
+ // inside the provider) connects to this process over a unix socket, sends the
7
+ // ask, and WAITS ON THAT CONNECTION. Nothing is ever read back from a file: an
8
+ // answer exists only as bytes this process writes on the asker's own connection,
9
+ // after the server said so. There is no decision file for the model to forge.
10
+ //
11
+ // The socket lives in a fresh 0700 directory under the system temp folder, never
12
+ // inside any workspace or the job folder. Code that runs as the same user (an
13
+ // approved script) could still impersonate this process; that needs running
14
+ // code, which is itself an owner-approved tier 1 action.
15
+ const ACTION_KEY = /^[0-9a-f]{64}$/u;
16
+ const STATUS_EVERY_MS = 1000;
17
+ const MAX_LINE = 4096;
18
+ const asAnswer = (status) => status === "approved" || status === "denied" || status === "expired"
19
+ ? status
20
+ : undefined;
21
+ function parseAsk(line) {
22
+ try {
23
+ const value = JSON.parse(line);
24
+ if (typeof value.actionKey === "string" &&
25
+ ACTION_KEY.test(value.actionKey) &&
26
+ typeof value.kind === "string" &&
27
+ value.kind.length <= 80 &&
28
+ typeof value.summary === "string" &&
29
+ value.summary.length <= 200 &&
30
+ (value.tier === 1 || value.tier === 2)) {
31
+ return {
32
+ actionKey: value.actionKey,
33
+ kind: value.kind,
34
+ summary: value.summary,
35
+ tier: value.tier,
36
+ };
37
+ }
38
+ }
39
+ catch {
40
+ // Not an ask.
41
+ }
42
+ return;
43
+ }
44
+ export class ApprovalBridge {
45
+ gateway;
46
+ onWaiting;
47
+ statusEveryMs;
48
+ waiters = new Map();
49
+ waiting = false;
50
+ polling = false;
51
+ lastPollAt = 0;
52
+ constructor(input) {
53
+ this.gateway = input.gateway;
54
+ this.onWaiting = input.onWaiting ?? (() => undefined);
55
+ this.statusEveryMs = input.statusEveryMs ?? STATUS_EVERY_MS;
56
+ }
57
+ /** Listens for the hook's asks; `stop` refuses anything still held. */
58
+ async start() {
59
+ const directory = mkdtempSync(join(tmpdir(), "tos-"));
60
+ const socketPath = join(directory, "a.sock");
61
+ const server = createServer((socket) => this.accept(socket));
62
+ await new Promise((resolve, reject) => {
63
+ server.once("error", reject);
64
+ server.listen(socketPath, resolve);
65
+ });
66
+ const timer = setInterval(() => {
67
+ this.poll().catch(() => undefined);
68
+ }, Math.max(100, Math.min(this.statusEveryMs, 250)));
69
+ return {
70
+ socketPath,
71
+ stop: () => {
72
+ clearInterval(timer);
73
+ for (const sockets of this.waiters.values()) {
74
+ for (const socket of sockets) {
75
+ socket.destroy();
76
+ }
77
+ }
78
+ this.waiters.clear();
79
+ this.signal();
80
+ server.close();
81
+ rmSync(directory, { force: true, recursive: true });
82
+ },
83
+ };
84
+ }
85
+ accept(socket) {
86
+ let buffer = "";
87
+ socket.setEncoding("utf8");
88
+ socket.on("error", () => socket.destroy());
89
+ socket.on("data", (chunk) => {
90
+ buffer += chunk;
91
+ if (buffer.length > MAX_LINE) {
92
+ socket.destroy();
93
+ return;
94
+ }
95
+ const end = buffer.indexOf("\n");
96
+ if (end < 0) {
97
+ return;
98
+ }
99
+ const ask = parseAsk(buffer.slice(0, end));
100
+ buffer = "";
101
+ if (!ask) {
102
+ socket.destroy();
103
+ return;
104
+ }
105
+ this.forward(ask, socket).catch(() => socket.destroy());
106
+ });
107
+ socket.on("close", () => {
108
+ for (const [key, sockets] of this.waiters) {
109
+ sockets.delete(socket);
110
+ if (sockets.size === 0) {
111
+ this.waiters.delete(key);
112
+ }
113
+ }
114
+ this.signal();
115
+ });
116
+ }
117
+ async forward(ask, socket) {
118
+ try {
119
+ const { status } = await this.gateway.request(ask);
120
+ const answer = asAnswer(status);
121
+ if (answer) {
122
+ this.reply(socket, answer);
123
+ return;
124
+ }
125
+ }
126
+ catch {
127
+ // A lost lease ends the ask; an unreachable server also refuses, because a
128
+ // held call must never outlive the answer it waits for.
129
+ this.reply(socket, "expired");
130
+ return;
131
+ }
132
+ const sockets = this.waiters.get(ask.actionKey) ?? new Set();
133
+ sockets.add(socket);
134
+ this.waiters.set(ask.actionKey, sockets);
135
+ this.signal();
136
+ }
137
+ async poll() {
138
+ if (this.polling ||
139
+ this.waiters.size === 0 ||
140
+ Date.now() - this.lastPollAt < this.statusEveryMs) {
141
+ return;
142
+ }
143
+ this.polling = true;
144
+ this.lastPollAt = Date.now();
145
+ try {
146
+ const keys = [...this.waiters.keys()].slice(0, 10);
147
+ const answers = new Map((await this.gateway.status(keys)).map((row) => [
148
+ row.actionKey,
149
+ row.status,
150
+ ]));
151
+ for (const key of keys) {
152
+ const status = answers.get(key);
153
+ const answer = status === undefined ? "expired" : asAnswer(status);
154
+ if (answer) {
155
+ for (const socket of this.waiters.get(key) ?? []) {
156
+ this.reply(socket, answer);
157
+ }
158
+ this.waiters.delete(key);
159
+ }
160
+ }
161
+ }
162
+ catch (error) {
163
+ const code = error.status;
164
+ if (code === 409 || code === 404) {
165
+ for (const sockets of this.waiters.values()) {
166
+ for (const socket of sockets) {
167
+ this.reply(socket, "expired");
168
+ }
169
+ }
170
+ this.waiters.clear();
171
+ }
172
+ }
173
+ finally {
174
+ this.polling = false;
175
+ this.signal();
176
+ }
177
+ }
178
+ reply(socket, status) {
179
+ socket.end(`${JSON.stringify({ status })}\n`);
180
+ }
181
+ signal() {
182
+ const waiting = this.waiters.size > 0;
183
+ if (waiting !== this.waiting) {
184
+ this.waiting = waiting;
185
+ this.onWaiting(waiting);
186
+ }
187
+ }
188
+ }
@@ -0,0 +1,118 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdirSync, realpathSync, statSync } from "node:fs";
3
+ import { readFile, rm } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { isMissing, writePrivateFile } from "../../agent-config.js";
7
+ import { isInside, reachesProtected } from "./paths.js";
8
+ const exec = promisify(execFile);
9
+ export const NATIVE_FILE = "native.json";
10
+ export async function readNativeConfig(directory) {
11
+ try {
12
+ const value = JSON.parse(await readFile(join(directory, NATIVE_FILE), "utf8"));
13
+ if (typeof value.root === "string" &&
14
+ (value.kind === "repo" || value.kind === "managed") &&
15
+ typeof value.optInAt === "number" &&
16
+ typeof value.dev === "number" &&
17
+ typeof value.ino === "number") {
18
+ return value;
19
+ }
20
+ return;
21
+ }
22
+ catch (error) {
23
+ if (isMissing(error) || error instanceof SyntaxError) {
24
+ return;
25
+ }
26
+ throw error;
27
+ }
28
+ }
29
+ export async function disableNative(directory) {
30
+ await rm(join(directory, NATIVE_FILE), { force: true });
31
+ }
32
+ /** [a-z0-9-] only: a display name never forms a path. */
33
+ export function folderSlug(name, agentId) {
34
+ const slug = name
35
+ .toLowerCase()
36
+ .replace(/[^a-z\d]+/gu, "-")
37
+ .replace(/^-+|-+$/gu, "")
38
+ .slice(0, 24);
39
+ return `${slug || "agent"}-${agentId
40
+ .replace(/[^a-z\d]/giu, "")
41
+ .slice(0, 8)
42
+ .toLowerCase()}`;
43
+ }
44
+ async function repoRoot(cwd) {
45
+ try {
46
+ const { stdout } = await exec("git", ["rev-parse", "--show-toplevel"], {
47
+ cwd,
48
+ timeout: 5000,
49
+ });
50
+ return stdout.trim() || undefined;
51
+ }
52
+ catch {
53
+ return;
54
+ }
55
+ }
56
+ /**
57
+ * Why a root may not serve as the agent's workspace, or undefined. Checked at
58
+ * enable time and again before every owner job: the folder must still be the
59
+ * same directory (device + inode), owned by this user, and neither the home
60
+ * folder nor anything that contains credentials.
61
+ */
62
+ export function rootProblem(config, home) {
63
+ try {
64
+ const real = realpathSync.native(config.root);
65
+ const stats = statSync(real);
66
+ const homeReal = realpathSync.native(home);
67
+ if (!stats.isDirectory()) {
68
+ return "The workspace is not a folder.";
69
+ }
70
+ if (stats.dev !== config.dev || stats.ino !== config.ino) {
71
+ return "The workspace folder changed since it was set up.";
72
+ }
73
+ if (process.getuid && stats.uid !== process.getuid()) {
74
+ return "The workspace folder is not yours.";
75
+ }
76
+ if (real === "/" ||
77
+ real === homeReal ||
78
+ isInside(homeReal, real) ||
79
+ reachesProtected(real, home)) {
80
+ return "The workspace folder is too broad.";
81
+ }
82
+ return;
83
+ }
84
+ catch {
85
+ return "The workspace folder is missing.";
86
+ }
87
+ }
88
+ /**
89
+ * Records the owner's yes. The workspace is automatic: the repo the command
90
+ * runs in, otherwise ~/TalentOS/<name>-<id>/ (created here, empty).
91
+ */
92
+ export async function enableNative(input) {
93
+ let root = await repoRoot(input.cwd);
94
+ let kind = "repo";
95
+ if (root) {
96
+ root = realpathSync.native(root);
97
+ }
98
+ else {
99
+ kind = "managed";
100
+ root = join(input.home, "TalentOS", folderSlug(input.name, input.agentId));
101
+ mkdirSync(root, { recursive: true, mode: 0o700 });
102
+ root = realpathSync.native(root);
103
+ }
104
+ const stats = statSync(root);
105
+ const config = {
106
+ dev: stats.dev,
107
+ ino: stats.ino,
108
+ kind,
109
+ optInAt: input.now ?? Date.now(),
110
+ root,
111
+ };
112
+ const problem = rootProblem(config, input.home);
113
+ if (problem) {
114
+ throw new Error(`${problem} Run this from a project folder, or from a normal folder to use ~/TalentOS.`);
115
+ }
116
+ await writePrivateFile(join(input.directory, NATIVE_FILE), `${JSON.stringify(config)}\n`);
117
+ return config;
118
+ }
@@ -0,0 +1,46 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ // R4 (R12): the hook is the only thing between a steered model and the owner's
4
+ // tools, so every owner job checks the owner's own settings for anything that
5
+ // would switch it off. Cheap file reads, run per job (the behavioural probe is
6
+ // cached; this is not).
7
+ const MANAGED = [
8
+ "/Library/Application Support/ClaudeCode/managed-settings.json",
9
+ "/etc/claude-code/managed-settings.json",
10
+ ];
11
+ function readSettings(path) {
12
+ try {
13
+ const value = JSON.parse(readFileSync(path, "utf8"));
14
+ return value && typeof value === "object" && !Array.isArray(value)
15
+ ? value
16
+ : "bad";
17
+ }
18
+ catch (error) {
19
+ return error.code === "ENOENT"
20
+ ? undefined
21
+ : "bad";
22
+ }
23
+ }
24
+ /** Why the owner's setup cannot run an owner job safely, or undefined. */
25
+ export function claudeDisqualifier(input) {
26
+ const files = [
27
+ ...MANAGED,
28
+ join(input.configDir, "settings.json"),
29
+ join(input.configDir, "settings.local.json"),
30
+ ...input.projectRoots.flatMap((root) => [
31
+ join(root, ".claude", "settings.json"),
32
+ join(root, ".claude", "settings.local.json"),
33
+ ]),
34
+ ];
35
+ for (const file of files) {
36
+ const settings = readSettings(file);
37
+ if (settings === "bad") {
38
+ return "A Claude Code settings file could not be read, so the room safety hook cannot be confirmed.";
39
+ }
40
+ if (settings?.disableAllHooks === true ||
41
+ settings?.allowManagedHooksOnly === true) {
42
+ return "Your Claude Code settings turn hooks off, which the room safety check needs.";
43
+ }
44
+ }
45
+ return;
46
+ }
@@ -0,0 +1,29 @@
1
+ import { execFile } from "node:child_process";
2
+ const MARK = "@@talentos-env@@";
3
+ // TalentOS and its deployment credentials never reach the model's environment,
4
+ // and a parent Claude Code session's markers must not make the child behave
5
+ // as if it were nested.
6
+ const DROP = /^(TALENTOS_|CONVEX_|CLAUDECODE$|CLAUDE_CODE_ENTRYPOINT$|CLAUDE_CODE_SSE_PORT$)/u;
7
+ export function scrubEnv(env) {
8
+ return Object.fromEntries(Object.entries(env).filter(([key, value]) => value !== undefined && !DROP.test(key)));
9
+ }
10
+ /**
11
+ * The owner's login-shell environment (PATH for nvm/uvx/npx, tokens their own
12
+ * MCP servers expect), captured once. A launchd service starts with almost
13
+ * nothing, which would break the very setup the owner wants to bring. Falls
14
+ * back to the service environment when the shell cannot be read.
15
+ */
16
+ export async function loginShellEnv(base = process.env, shell = base.SHELL || "/bin/zsh") {
17
+ const script = `printf %s ${MARK}; "${process.execPath}" -e 'process.stdout.write(JSON.stringify(process.env))'; printf %s ${MARK}`;
18
+ const stdout = await new Promise((resolve) => {
19
+ execFile(shell, ["-ilc", script], { env: base, maxBuffer: 2_000_000, timeout: 8000 }, (error, out) => resolve(error ? "" : String(out)));
20
+ });
21
+ const parts = stdout.split(MARK);
22
+ try {
23
+ const parsed = JSON.parse(parts[1] ?? "");
24
+ return scrubEnv({ ...base, ...parsed });
25
+ }
26
+ catch {
27
+ return scrubEnv(base);
28
+ }
29
+ }
@@ -0,0 +1,102 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { providerPathEnv, resolveProviderBinary, } from "../../provider-binary.js";
6
+ import { NATIVE_FILE, readNativeConfig } from "./config.js";
7
+ import { claudeDisqualifier } from "./disqualify.js";
8
+ import { loginShellEnv } from "./env.js";
9
+ import { ensureOwnerNativeProbe } from "./probe.js";
10
+ import { prepareWorkspace } from "./workspace.js";
11
+ const WHITESPACE = /\s+/u;
12
+ const exec = promisify(execFile);
13
+ const ENV_TTL_MS = 10 * 60_000;
14
+ const RETRY_MS = 10 * 60_000;
15
+ /**
16
+ * Everything the owner-native profile needs on this computer, decided here and
17
+ * nowhere else: the owner's local yes, a passing cached probe, settings that
18
+ * leave the hook on, a workspace that still checks out. Any miss means the job
19
+ * runs room-only exactly as before. The probe runs once per provider change, in
20
+ * the background, never per job.
21
+ */
22
+ export class NativeGate {
23
+ probe = "none";
24
+ retryAt = 0;
25
+ env;
26
+ agent;
27
+ directory;
28
+ home;
29
+ constructor(input) {
30
+ this.agent = input.agent;
31
+ this.directory = input.directory;
32
+ this.home = input.home;
33
+ }
34
+ /** The owner's yes exists on this computer (a file, so `native off` is instant). */
35
+ optedIn() {
36
+ return existsSync(join(this.directory, NATIVE_FILE));
37
+ }
38
+ /** Whether to tell the plane this computer is ready (heartbeat). */
39
+ reportable() {
40
+ return this.probe === "ok" && this.optedIn();
41
+ }
42
+ /** Starts the cached probe when the owner has opted in. Safe to call every beat. */
43
+ refresh() {
44
+ if (this.agent.provider !== "claude_code" ||
45
+ process.platform === "win32" ||
46
+ !this.optedIn() ||
47
+ this.probe === "pending" ||
48
+ this.probe === "ok" ||
49
+ (this.probe === "failed" && Date.now() < this.retryAt)) {
50
+ if (!this.optedIn()) {
51
+ this.probe = "none";
52
+ }
53
+ return;
54
+ }
55
+ this.probe = "pending";
56
+ this.runProbe().then(() => {
57
+ this.probe = "ok";
58
+ }, () => {
59
+ this.probe = "failed";
60
+ // A failing provider is not re-probed on every heartbeat.
61
+ this.retryAt = Date.now() + RETRY_MS;
62
+ });
63
+ }
64
+ async runProbe() {
65
+ const binary = resolveProviderBinary("claude_code", this.agent.providerPath);
66
+ const { stdout } = await exec(binary, ["--version"], {
67
+ env: { ...process.env, PATH: providerPathEnv(binary, this.home) },
68
+ timeout: 5000,
69
+ });
70
+ await ensureOwnerNativeProbe({
71
+ binary,
72
+ cachePath: join(this.directory, "native-probe.json"),
73
+ version: stdout.trim().split(WHITESPACE)[0] ?? "unknown",
74
+ });
75
+ }
76
+ /** The login-shell env, captured once and refreshed after ten minutes. */
77
+ loginEnv() {
78
+ if (!this.env || Date.now() - this.env.at > ENV_TTL_MS) {
79
+ this.env = { at: Date.now(), value: loginShellEnv() };
80
+ }
81
+ return this.env.value;
82
+ }
83
+ /** Per owner job (R4): returns the launch inputs, or undefined to run room-only. */
84
+ async prepare(threadId) {
85
+ this.refresh();
86
+ const config = await readNativeConfig(this.directory);
87
+ if (!config || this.probe !== "ok") {
88
+ return;
89
+ }
90
+ const env = await this.loginEnv();
91
+ const configDir = env.CLAUDE_CONFIG_DIR || join(this.home, ".claude");
92
+ if (claudeDisqualifier({ configDir, projectRoots: [config.root] })) {
93
+ return;
94
+ }
95
+ const workspace = await prepareWorkspace({
96
+ config,
97
+ home: this.home,
98
+ threadId,
99
+ });
100
+ return { env, home: this.home, workspace };
101
+ }
102
+ }