impel-cli 0.19.2-beta.0 → 0.20.0-beta.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.
- package/README.md +55 -0
- package/package.json +6 -5
- package/src/agents.js +147 -6
- package/src/apps.js +98 -11
- package/src/cliProfiles.js +47 -8
- package/src/commands/mcp.js +201 -17
- package/src/commands/remote.js +166 -36
- package/src/directAnswer.js +59 -0
- package/src/remote/transfer.js +53 -2
- package/src/selfInvocation.js +27 -0
package/README.md
CHANGED
|
@@ -228,6 +228,30 @@ impel remote dispatch --provider claude --session <session-id> --fork
|
|
|
228
228
|
impel remote dispatch --provider codex --session <session-id> --fork
|
|
229
229
|
```
|
|
230
230
|
|
|
231
|
+
The installable `remote-control` skill turns this into an in-app handoff. Ask
|
|
232
|
+
Claude Code or Codex to run the current session remotely, or invoke the skill
|
|
233
|
+
directly, and it resolves the current Claude session/Codex thread id before
|
|
234
|
+
running:
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
impel remote handoff . --provider <codex|claude> --session <session-id>
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Unlike `up` or `dispatch`, `handoff` launches a detached headless worker inside
|
|
241
|
+
Fargate and follows the provider's structured event stream. Inference, tools,
|
|
242
|
+
commands, and edits execute in the runner; the local app only displays the
|
|
243
|
+
stream. Normal completion stops the task, revokes its disposable PAT, and
|
|
244
|
+
removes its SSH alias. To let the task continue without keeping the stream
|
|
245
|
+
open, add `--detach`, then reconnect later:
|
|
246
|
+
|
|
247
|
+
```sh
|
|
248
|
+
impel remote follow <run-id>
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
If the viewer disconnects, the detached worker keeps running and its normal
|
|
252
|
+
session hooks continue syncing the remote transcript to `impel-sessions`. The
|
|
253
|
+
task TTL remains the fallback stop and credential-revocation boundary.
|
|
254
|
+
|
|
231
255
|
Remote vendor commands run in dangerous/bypass mode as the unprivileged
|
|
232
256
|
`agent` user; the disposable Fargate task is the isolation boundary. Existing
|
|
233
257
|
Impel lifecycle hooks continue mirroring remote transcripts to
|
|
@@ -326,6 +350,37 @@ impel tasks delete IMP-123 --yes
|
|
|
326
350
|
Use `--json` when exact machine-readable fields matter. Gateway-only members
|
|
327
351
|
cannot use task CRUD.
|
|
328
352
|
|
|
353
|
+
### Native Tasks MCP App
|
|
354
|
+
|
|
355
|
+
Setup and update register a second tenant-bound MCP server named
|
|
356
|
+
`impel-tasks` in Impel-managed Claude and Codex/ChatGPT profiles. It is separate
|
|
357
|
+
from the existing `impel` specialist server: removing or disabling Tasks does
|
|
358
|
+
not change specialist routing or approvals.
|
|
359
|
+
|
|
360
|
+
The managed entry launches an absolute, upgrade-stable invocation equivalent
|
|
361
|
+
to:
|
|
362
|
+
|
|
363
|
+
```sh
|
|
364
|
+
impel mcp --target tasks --tenant <fixed-tenant>
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
This is an internal transport command, not an interactive PAT interface. The
|
|
368
|
+
child process reads the PAT from Impel's private config, sends it only as the
|
|
369
|
+
control-plane HTTP bearer credential, and fixes tenant selection in the
|
|
370
|
+
transport header. Generated Claude/Codex MCP entries contain neither the PAT
|
|
371
|
+
nor a tenant-derived credential.
|
|
372
|
+
|
|
373
|
+
- The pinned Impel Claude Desktop can render the standard MCP Apps Tasks
|
|
374
|
+
resource when its host supports that protocol.
|
|
375
|
+
- Claude Code and stable Codex receive complete headless task tools and text
|
|
376
|
+
results; no experimental Codex MCP Apps flag is enabled.
|
|
377
|
+
- Profiles preserve unrelated MCP servers. If a user-authored server already
|
|
378
|
+
owns the `impel-tasks` name, setup/update fails with remediation instead of
|
|
379
|
+
overwriting it.
|
|
380
|
+
- The Tasks bridge accepts Streamable HTTP's required JSON/SSE media types but
|
|
381
|
+
deliberately requires bounded JSON responses from the control plane. It
|
|
382
|
+
does not establish an SSE session or persist protocol state.
|
|
383
|
+
|
|
329
384
|
## PAT lifecycle
|
|
330
385
|
|
|
331
386
|
Minting and revocation remain explicit account operations:
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "impel-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0-beta.0",
|
|
4
4
|
"description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"packageManager": "pnpm@11.17.0",
|
|
6
7
|
"bin": {
|
|
7
8
|
"impel": "bin/impel.js"
|
|
8
9
|
},
|
|
@@ -19,6 +20,9 @@
|
|
|
19
20
|
"engines": {
|
|
20
21
|
"node": ">=18.0.0"
|
|
21
22
|
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test"
|
|
25
|
+
},
|
|
22
26
|
"license": "UNLICENSED",
|
|
23
27
|
"publishConfig": {
|
|
24
28
|
"access": "public",
|
|
@@ -27,8 +31,5 @@
|
|
|
27
31
|
"repository": {
|
|
28
32
|
"type": "git",
|
|
29
33
|
"url": "git+https://github.com/UseImpel/impel-cli.git"
|
|
30
|
-
},
|
|
31
|
-
"scripts": {
|
|
32
|
-
"test": "node --test"
|
|
33
34
|
}
|
|
34
|
-
}
|
|
35
|
+
}
|
package/src/agents.js
CHANGED
|
@@ -10,6 +10,13 @@ import os from "node:os";
|
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
|
|
12
12
|
import { normalizeGatewayUrl, redactSecretText } from "./config.js";
|
|
13
|
+
import {
|
|
14
|
+
adapterAnswerHardCompletionGuidance,
|
|
15
|
+
adapterAnswerSoftCompletionGuidance,
|
|
16
|
+
claudeAnswerHardCompletionGuidance,
|
|
17
|
+
claudeAnswerSoftCompletionGuidance,
|
|
18
|
+
usesDirectAnswer,
|
|
19
|
+
} from "./directAnswer.js";
|
|
13
20
|
import { impelMcpInvocation } from "./selfInvocation.js";
|
|
14
21
|
import { normalizeTenantId } from "./tenants.js";
|
|
15
22
|
import {
|
|
@@ -29,14 +36,22 @@ export const MANAGED_AGENT_MANIFEST = ".manifest.json";
|
|
|
29
36
|
export const NATIVE_AGENT_LIST_TOOL = "impel_specialists-list_native_agents";
|
|
30
37
|
export const NATIVE_AGENT_START_TOOL = "impel_specialists-start_native_agent_run";
|
|
31
38
|
export const NATIVE_AGENT_READ_TOOL = "impel_specialists-read_native_agent_run";
|
|
39
|
+
export const NATIVE_AGENT_ANSWER_TOOL = "impel_specialists-answer_native_agent";
|
|
32
40
|
export const MANAGED_AGENT_MCP_SERVER = "impel_agent";
|
|
33
|
-
export const MANAGED_AGENT_MANIFEST_VERSION =
|
|
41
|
+
export const MANAGED_AGENT_MANIFEST_VERSION = 6;
|
|
34
42
|
|
|
35
|
-
const
|
|
43
|
+
const NATIVE_AGENT_SESSION_TOOL_NAMES = [
|
|
36
44
|
NATIVE_AGENT_LIST_TOOL,
|
|
37
45
|
NATIVE_AGENT_START_TOOL,
|
|
38
46
|
NATIVE_AGENT_READ_TOOL,
|
|
39
47
|
];
|
|
48
|
+
|
|
49
|
+
function nativeAgentToolNames(agent) {
|
|
50
|
+
return usesDirectAnswer(agent)
|
|
51
|
+
? [NATIVE_AGENT_ANSWER_TOOL]
|
|
52
|
+
: NATIVE_AGENT_SESSION_TOOL_NAMES;
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
const SAFE_AGENT_ID_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
|
|
41
56
|
const SAFE_SCOPE_PARAM_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
|
|
42
57
|
const MAX_CATALOG_ITEMS = 500;
|
|
@@ -137,6 +152,9 @@ export function normalizeNativeAgentCatalog(payload, expectedTenantId) {
|
|
|
137
152
|
if (agent.verbatimRelay !== undefined && typeof agent.verbatimRelay !== "boolean") {
|
|
138
153
|
throw new Error("native-agent catalog returned an invalid verbatimRelay");
|
|
139
154
|
}
|
|
155
|
+
if (agent.directAnswer !== undefined && typeof agent.directAnswer !== "boolean") {
|
|
156
|
+
throw new Error("native-agent catalog returned an invalid directAnswer");
|
|
157
|
+
}
|
|
140
158
|
return {
|
|
141
159
|
agentId,
|
|
142
160
|
title: boundedString(agent.title, "title", { max: 160 }),
|
|
@@ -148,6 +166,7 @@ export function normalizeNativeAgentCatalog(payload, expectedTenantId) {
|
|
|
148
166
|
requiredContext: stringList(agent.requiredContext, "requiredContext"),
|
|
149
167
|
sideEffects: enumString(agent.sideEffects, "sideEffects", ["read-only", "writes"]),
|
|
150
168
|
...(agent.verbatimRelay === true ? { verbatimRelay: true } : {}),
|
|
169
|
+
...(agent.directAnswer === true ? { directAnswer: true } : {}),
|
|
151
170
|
};
|
|
152
171
|
});
|
|
153
172
|
return { orgId: tenantId, agents };
|
|
@@ -345,6 +364,20 @@ function claudeAdapterInstructions(tenantId, agent) {
|
|
|
345
364
|
const callerSpawnGuidance = usesVerbatimRelay(agent)
|
|
346
365
|
? adapterCallerSpawnGuidance("Claude")
|
|
347
366
|
: null;
|
|
367
|
+
if (usesDirectAnswer(agent)) {
|
|
368
|
+
const completionGuidance = usesVerbatimRelay(agent)
|
|
369
|
+
? claudeAnswerHardCompletionGuidance()
|
|
370
|
+
: claudeAnswerSoftCompletionGuidance();
|
|
371
|
+
return [
|
|
372
|
+
`You are a thin transport adapter for the exact Impel native agent ${JSON.stringify(agent.agentId)} in tenant ${JSON.stringify(tenantId)}.`,
|
|
373
|
+
...(callerSpawnGuidance ? [callerSpawnGuidance] : []),
|
|
374
|
+
`Do not perform the assigned task yourself, do not delegate to any other agent, and do not call Impel start_native_agent_run or read_native_agent_run.`,
|
|
375
|
+
`Confirm that the assigned request fits the cataloged capabilities ${JSON.stringify(agent.capabilities)} and none of the exclusions ${JSON.stringify(agent.exclusions)} before answering.${contextRequirement}`,
|
|
376
|
+
sideEffectInstruction,
|
|
377
|
+
`Call ${toolName(NATIVE_AGENT_ANSWER_TOOL)} exactly once with agentId ${JSON.stringify(agent.agentId)}, scopeParam ${JSON.stringify(agent.scopeParam)}, question set to the complete assigned task, optional context set to one string containing all supplied context (omit it when no context was supplied), contextKeys naming the context fields present in that string, and confirmedSideEffects as directed above.`,
|
|
378
|
+
completionGuidance,
|
|
379
|
+
].join(" ");
|
|
380
|
+
}
|
|
348
381
|
const completionGuidance = usesVerbatimRelay(agent)
|
|
349
382
|
? claudeHardCompletionGuidance()
|
|
350
383
|
: claudeSoftCompletionGuidance();
|
|
@@ -369,7 +402,96 @@ const CODEX_CONTEXT_PLACEHOLDER = "__IMPEL_OPTIONAL_CONTEXT_STRING_OR_NULL_JSON_
|
|
|
369
402
|
const CODEX_CONTEXT_KEYS_PLACEHOLDER = "__IMPEL_SUPPLIED_CONTEXT_KEYS_JSON__";
|
|
370
403
|
const CODEX_IDEMPOTENCY_KEY_PLACEHOLDER = "__IMPEL_LOGICAL_INVOCATION_IDEMPOTENCY_KEY_JSON__";
|
|
371
404
|
|
|
405
|
+
function renderCodexDirectAnswerOrchestration(tenantId, agent) {
|
|
406
|
+
const expectedTenantId = normalizeTenantId(tenantId);
|
|
407
|
+
const nestedToolName = (toolName) => nativeToolName(toolName).replaceAll("-", "_");
|
|
408
|
+
const answerTool = nestedToolName(NATIVE_AGENT_ANSWER_TOOL);
|
|
409
|
+
return [
|
|
410
|
+
'// @exec: {"yield_time_ms": 30000, "max_output_tokens": 30000}',
|
|
411
|
+
`const assignedTask = ${CODEX_TASK_PLACEHOLDER};`,
|
|
412
|
+
`const suppliedContext = ${CODEX_CONTEXT_PLACEHOLDER};`,
|
|
413
|
+
`const suppliedContextKeys = ${CODEX_CONTEXT_KEYS_PLACEHOLDER};`,
|
|
414
|
+
`const idempotencyKey = ${CODEX_IDEMPOTENCY_KEY_PLACEHOLDER};`,
|
|
415
|
+
`const expectedTenantId = ${JSON.stringify(expectedTenantId)};`,
|
|
416
|
+
`const expectedAgent = ${JSON.stringify(agent)};`,
|
|
417
|
+
`const answerToolName = ${JSON.stringify(answerTool)};`,
|
|
418
|
+
"",
|
|
419
|
+
"function contentText(response) {",
|
|
420
|
+
' return response?.content?.find((item) => item?.type === "text" && typeof item.text === "string")?.text;',
|
|
421
|
+
"}",
|
|
422
|
+
"",
|
|
423
|
+
"function toolPayload(response, label) {",
|
|
424
|
+
' if (!response || typeof response !== "object") throw new Error(label + " returned no result");',
|
|
425
|
+
' if (response.isError) throw new Error(contentText(response) || label + " failed");',
|
|
426
|
+
' if (response.structuredContent && typeof response.structuredContent === "object") return response.structuredContent;',
|
|
427
|
+
" const serialized = contentText(response);",
|
|
428
|
+
" if (serialized !== undefined) {",
|
|
429
|
+
" try {",
|
|
430
|
+
" return JSON.parse(serialized);",
|
|
431
|
+
" } catch {",
|
|
432
|
+
' throw new Error(label + " returned invalid JSON");',
|
|
433
|
+
" }",
|
|
434
|
+
" }",
|
|
435
|
+
" return response;",
|
|
436
|
+
"}",
|
|
437
|
+
"",
|
|
438
|
+
"function extractAnswerFinalText(payload) {",
|
|
439
|
+
' if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;',
|
|
440
|
+
' for (const key of ["forUser", "answer"]) {',
|
|
441
|
+
" const value = payload[key];",
|
|
442
|
+
' if (typeof value === "string" && value.trim()) return value;',
|
|
443
|
+
" }",
|
|
444
|
+
" return null;",
|
|
445
|
+
"}",
|
|
446
|
+
"",
|
|
447
|
+
"function errorValue(error, fallback) {",
|
|
448
|
+
" if (error === undefined || error === null) return fallback;",
|
|
449
|
+
' if (error instanceof Error) return error.message;',
|
|
450
|
+
" return error;",
|
|
451
|
+
"}",
|
|
452
|
+
"",
|
|
453
|
+
"async function orchestrate() {",
|
|
454
|
+
" try {",
|
|
455
|
+
' if (typeof assignedTask !== "string" || !assignedTask.trim()) throw new Error("the complete assigned task is required");',
|
|
456
|
+
' if (suppliedContext !== null && typeof suppliedContext !== "string") throw new Error("supplied context must be a string or null");',
|
|
457
|
+
' if (expectedAgent.requiredContext.length && (typeof suppliedContext !== "string" || !suppliedContext.trim())) {',
|
|
458
|
+
' throw new Error("nonblank supplied context is required for required context keys");',
|
|
459
|
+
" }",
|
|
460
|
+
' if (!Array.isArray(suppliedContextKeys)) throw new Error("supplied context keys must be an array");',
|
|
461
|
+
" const missingContext = expectedAgent.requiredContext.filter((key) =>",
|
|
462
|
+
" !suppliedContextKeys.includes(key)",
|
|
463
|
+
" );",
|
|
464
|
+
' if (missingContext.length) throw new Error("missing required context: " + missingContext.join(", "));',
|
|
465
|
+
"",
|
|
466
|
+
" const answerArguments = {",
|
|
467
|
+
" agentId: expectedAgent.agentId,",
|
|
468
|
+
" scopeParam: expectedAgent.scopeParam,",
|
|
469
|
+
" question: assignedTask,",
|
|
470
|
+
" contextKeys: suppliedContextKeys,",
|
|
471
|
+
" };",
|
|
472
|
+
" if (suppliedContext !== null) answerArguments.context = suppliedContext;",
|
|
473
|
+
' if (expectedAgent.sideEffects === "writes") answerArguments.confirmedSideEffects = true;',
|
|
474
|
+
" const answered = toolPayload(await tools[answerToolName](answerArguments), \"native-agent answer\");",
|
|
475
|
+
" const finalText = extractAnswerFinalText(answered);",
|
|
476
|
+
' if (typeof finalText !== "string") throw new Error("native-agent answer returned no forUser/answer");',
|
|
477
|
+
" text(finalText);",
|
|
478
|
+
" } catch (error) {",
|
|
479
|
+
" text(JSON.stringify({",
|
|
480
|
+
' error: errorValue(error, "native-agent answer adapter failed"),',
|
|
481
|
+
" tenantId: expectedTenantId,",
|
|
482
|
+
" agentId: expectedAgent.agentId,",
|
|
483
|
+
" }));",
|
|
484
|
+
" }",
|
|
485
|
+
"}",
|
|
486
|
+
"",
|
|
487
|
+
"await orchestrate();",
|
|
488
|
+
].join("\n");
|
|
489
|
+
}
|
|
490
|
+
|
|
372
491
|
export function renderCodexAdapterOrchestration(tenantId, agent) {
|
|
492
|
+
if (usesDirectAnswer(agent)) {
|
|
493
|
+
return renderCodexDirectAnswerOrchestration(tenantId, agent);
|
|
494
|
+
}
|
|
373
495
|
const expectedTenantId = normalizeTenantId(tenantId);
|
|
374
496
|
const nestedToolName = (toolName) => nativeToolName(toolName).replaceAll("-", "_");
|
|
375
497
|
const listTool = nestedToolName(NATIVE_AGENT_LIST_TOOL);
|
|
@@ -517,6 +639,23 @@ function codexAdapterInstructions(tenantId, agent) {
|
|
|
517
639
|
const callerSpawnGuidance = usesVerbatimRelay(agent)
|
|
518
640
|
? adapterCallerSpawnGuidance("Codex")
|
|
519
641
|
: null;
|
|
642
|
+
if (usesDirectAnswer(agent)) {
|
|
643
|
+
const completionGuidance = usesVerbatimRelay(agent)
|
|
644
|
+
? adapterAnswerHardCompletionGuidance()
|
|
645
|
+
: adapterAnswerSoftCompletionGuidance();
|
|
646
|
+
return [
|
|
647
|
+
`You are a thin transport adapter for the exact Impel native agent ${JSON.stringify(agent.agentId)} in tenant ${JSON.stringify(tenantId)}.`,
|
|
648
|
+
...(callerSpawnGuidance ? [callerSpawnGuidance] : []),
|
|
649
|
+
`Do not perform the assigned task yourself, do not delegate to any other agent, do not independently synthesize or rewrite the result, and do not call Impel start_native_agent_run or read_native_agent_run.`,
|
|
650
|
+
`Confirm that the assigned request fits the cataloged capabilities ${JSON.stringify(agent.capabilities)} and none of the exclusions ${JSON.stringify(agent.exclusions)} before answering.${contextRequirement}`,
|
|
651
|
+
sideEffectInstruction,
|
|
652
|
+
`Invoke functions.exec exactly once for the orchestration below. Do not call the MCP tools directly. Replace ${CODEX_TASK_PLACEHOLDER} with a JSON string literal for the complete assigned task. Replace ${CODEX_CONTEXT_PLACEHOLDER} with one JSON string literal containing all supplied caller context, or with null when no context was supplied; never use an object or array. Replace ${CODEX_CONTEXT_KEYS_PLACEHOLDER} with a JSON array naming the context fields present in that string, or [] when context is absent. Replace ${CODEX_IDEMPOTENCY_KEY_PLACEHOLDER} with one new opaque idempotency key for this logical invocation (unused by the answer tool but kept for adapter parity): choose it exactly once. Then pass the raw JavaScript without Markdown fences.`,
|
|
653
|
+
`The JavaScript validates required context and calls Impel answer_native_agent exactly once, returning forUser (else answer). If functions.exec yields a running cell, use functions.wait with max_tokens 30000 only to resume that same orchestration; never start another orchestration or open an Impel/Eve session.`,
|
|
654
|
+
completionGuidance,
|
|
655
|
+
"",
|
|
656
|
+
source,
|
|
657
|
+
].join("\n\n");
|
|
658
|
+
}
|
|
520
659
|
const completionGuidance = usesVerbatimRelay(agent)
|
|
521
660
|
? adapterHardCompletionGuidance()
|
|
522
661
|
: adapterSoftCompletionGuidance();
|
|
@@ -544,13 +683,14 @@ function customAgentDescription(tenantId, agent) {
|
|
|
544
683
|
|
|
545
684
|
function renderClaudeAgent({ tenantId, agent, name, invocation }) {
|
|
546
685
|
const description = customAgentDescription(tenantId, agent);
|
|
686
|
+
const toolNames = nativeAgentToolNames(agent);
|
|
547
687
|
const lines = [
|
|
548
688
|
"---",
|
|
549
689
|
`name: ${JSON.stringify(name)}`,
|
|
550
690
|
`description: ${JSON.stringify(description)}`,
|
|
551
691
|
"model: inherit",
|
|
552
692
|
"tools:",
|
|
553
|
-
...
|
|
693
|
+
...toolNames.map((tool) => ` - ${JSON.stringify(nativeToolName(tool))}`),
|
|
554
694
|
"mcpServers:",
|
|
555
695
|
` - ${MANAGED_AGENT_MCP_SERVER}:`,
|
|
556
696
|
" type: stdio",
|
|
@@ -569,6 +709,7 @@ function renderClaudeAgent({ tenantId, agent, name, invocation }) {
|
|
|
569
709
|
|
|
570
710
|
function renderCodexAgent({ tenantId, agent, name, invocation }) {
|
|
571
711
|
const description = customAgentDescription(tenantId, agent);
|
|
712
|
+
const toolNames = nativeAgentToolNames(agent);
|
|
572
713
|
const envEntries = Object.entries(invocation.env || {})
|
|
573
714
|
.map(([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)}`)
|
|
574
715
|
.join(", ");
|
|
@@ -581,11 +722,11 @@ function renderCodexAgent({ tenantId, agent, name, invocation }) {
|
|
|
581
722
|
`[mcp_servers.${MANAGED_AGENT_MCP_SERVER}]`,
|
|
582
723
|
`command = ${JSON.stringify(invocation.command)}`,
|
|
583
724
|
`args = [${invocation.args.map((argument) => JSON.stringify(argument)).join(", ")}]`,
|
|
584
|
-
`enabled_tools = [${
|
|
725
|
+
`enabled_tools = [${toolNames.map((tool) => JSON.stringify(tool)).join(", ")}]`,
|
|
585
726
|
...(envEntries ? [`env = { ${envEntries} }`] : []),
|
|
586
727
|
"",
|
|
587
728
|
];
|
|
588
|
-
for (const tool of
|
|
729
|
+
for (const tool of toolNames) {
|
|
589
730
|
lines.push(`[mcp_servers.${MANAGED_AGENT_MCP_SERVER}.tools.${JSON.stringify(tool)}]`, 'approval_mode = "approve"', "");
|
|
590
731
|
}
|
|
591
732
|
return lines.join("\n");
|
|
@@ -662,7 +803,7 @@ export function syncAgentProfile({ client, root, label, tenantId, agents, now =
|
|
|
662
803
|
const prior = readManifest(manifestPath);
|
|
663
804
|
const rendered = renderManagedAgents(client, tenantId, agents);
|
|
664
805
|
const priorFiles = new Set(prior?.files || []);
|
|
665
|
-
const priorUsesDiscoveryRoot = [2, 3, 4, MANAGED_AGENT_MANIFEST_VERSION].includes(prior?.version);
|
|
806
|
+
const priorUsesDiscoveryRoot = [2, 3, 4, 5, MANAGED_AGENT_MANIFEST_VERSION].includes(prior?.version);
|
|
666
807
|
|
|
667
808
|
// Native clients discover standalone definitions directly under `agents/`.
|
|
668
809
|
// Preflight every destination before writing so an unmanaged file with the
|
package/src/apps.js
CHANGED
|
@@ -10,7 +10,13 @@ import {
|
|
|
10
10
|
secureManagedCodexHome,
|
|
11
11
|
} from "./codexSecurity.js";
|
|
12
12
|
import { normalizeTenantId } from "./tenants.js";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
IMPEL_CLI_ENTRYPOINT,
|
|
15
|
+
IMPEL_TASKS_MCP_SERVER_NAME,
|
|
16
|
+
impelCliInvocation,
|
|
17
|
+
impelTasksMcpInvocation,
|
|
18
|
+
isImpelTasksMcpInvocation,
|
|
19
|
+
} from "./selfInvocation.js";
|
|
14
20
|
import { crossAppModelsEnabled, redactSecretText } from "./config.js";
|
|
15
21
|
import { ensureClaudeSessionHooks, ensureCodexSessionHooks } from "./sessionHooks.js";
|
|
16
22
|
import { ADHOC_IDENTITY, codesignIdentityArgs, desiredSigningMode, resolveSigningIdentity } from "./codesign.js";
|
|
@@ -267,8 +273,11 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
|
|
|
267
273
|
// 26: remember the managed provider defaults and move legacy GPT-5.5 profiles
|
|
268
274
|
// onto GPT-5.6 Sol so ChatGPT's compact Work picker does not fall back to its
|
|
269
275
|
// unsupported-selection "Reset to default" treatment.
|
|
270
|
-
// 27:
|
|
271
|
-
|
|
276
|
+
// 27: register the separate tenant-bound Tasks MCP server in managed Claude
|
|
277
|
+
// and ChatGPT/Codex profiles without changing the specialist MCP server.
|
|
278
|
+
// 28: add managed ChatGPT parent delegation instructions, including custom-agent
|
|
279
|
+
// verbatim relay opt-in and directAnswer one-shot MCP answer routing.
|
|
280
|
+
export const CURRENT_CONFIG_VERSION = 28;
|
|
272
281
|
|
|
273
282
|
// Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
|
|
274
283
|
// helper rebranding, and signing. A vendored bundle is rebuilt only when this
|
|
@@ -809,7 +818,7 @@ export function installManagedAppFiles({
|
|
|
809
818
|
}
|
|
810
819
|
if (target === "claude") {
|
|
811
820
|
migrateLegacyClaudeAppSessions(paths.claude.userData, homeDir);
|
|
812
|
-
writeClaudeNative3PSelection(paths);
|
|
821
|
+
writeClaudeNative3PSelection(paths, config);
|
|
813
822
|
writeClaudeCodeSettings(paths);
|
|
814
823
|
writeClaudeConfig(paths, config, models);
|
|
815
824
|
if (RUNTIME_BRAND.features.sessions) ensureClaudeSessionHooks(paths.claude.userData, config.tenantId, "claude_desktop");
|
|
@@ -1155,7 +1164,7 @@ function writeClaudeCodeSettings(paths) {
|
|
|
1155
1164
|
writeAtomic(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 0o600);
|
|
1156
1165
|
}
|
|
1157
1166
|
|
|
1158
|
-
function writeClaudeNative3PSelection(paths) {
|
|
1167
|
+
function writeClaudeNative3PSelection(paths, config) {
|
|
1159
1168
|
const configPath = path.join(paths.claude.userData, "claude_desktop_config.json");
|
|
1160
1169
|
let current = {};
|
|
1161
1170
|
if (fs.existsSync(configPath)) {
|
|
@@ -1168,15 +1177,64 @@ function writeClaudeNative3PSelection(paths) {
|
|
|
1168
1177
|
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
|
1169
1178
|
throw new Error(`Claude desktop config must contain a JSON object: ${configPath}`);
|
|
1170
1179
|
}
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1180
|
+
if (Object.hasOwn(current, "mcpServers") && (
|
|
1181
|
+
!current.mcpServers
|
|
1182
|
+
|| typeof current.mcpServers !== "object"
|
|
1183
|
+
|| Array.isArray(current.mcpServers)
|
|
1184
|
+
)) {
|
|
1185
|
+
throw new Error(`Claude desktop config has an invalid mcpServers value: ${configPath}`);
|
|
1186
|
+
}
|
|
1187
|
+
const currentMcpServers = current.mcpServers
|
|
1188
|
+
&& typeof current.mcpServers === "object"
|
|
1189
|
+
&& !Array.isArray(current.mcpServers)
|
|
1190
|
+
? current.mcpServers
|
|
1191
|
+
: {};
|
|
1192
|
+
const currentTasksServer = currentMcpServers[IMPEL_TASKS_MCP_SERVER_NAME];
|
|
1193
|
+
if (RUNTIME_BRAND.features.mcp && config.tenantId) {
|
|
1194
|
+
if (Object.hasOwn(currentMcpServers, IMPEL_TASKS_MCP_SERVER_NAME)
|
|
1195
|
+
&& !isImpelTasksMcpInvocation(currentTasksServer)) {
|
|
1196
|
+
throw new Error(
|
|
1197
|
+
`${configPath} already has an mcpServers.${IMPEL_TASKS_MCP_SERVER_NAME} entry `
|
|
1198
|
+
+ "that wasn't written by impel-cli. Remove or rename it, then re-run."
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Mirror the pinned app's native "select 3P" persistence contract and add
|
|
1204
|
+
// only the tenant-bound Tasks entry we own. Claude-owned preferences and
|
|
1205
|
+
// foreign MCP entries remain untouched.
|
|
1175
1206
|
const next = { ...current, deploymentMode: "3p" };
|
|
1176
1207
|
delete next.awaitingSignIn;
|
|
1208
|
+
if (RUNTIME_BRAND.features.mcp && config.tenantId) {
|
|
1209
|
+
next.mcpServers = {
|
|
1210
|
+
...currentMcpServers,
|
|
1211
|
+
[IMPEL_TASKS_MCP_SERVER_NAME]: impelTasksMcpInvocation(config.tenantId),
|
|
1212
|
+
};
|
|
1213
|
+
} else if (isImpelTasksMcpInvocation(currentTasksServer)) {
|
|
1214
|
+
next.mcpServers = { ...currentMcpServers };
|
|
1215
|
+
delete next.mcpServers[IMPEL_TASKS_MCP_SERVER_NAME];
|
|
1216
|
+
if (Object.keys(next.mcpServers).length === 0) delete next.mcpServers;
|
|
1217
|
+
}
|
|
1177
1218
|
writeAtomic(configPath, `${JSON.stringify(next, null, 2)}\n`, 0o600);
|
|
1178
1219
|
}
|
|
1179
1220
|
|
|
1221
|
+
function tasksInvocationFromBaseMcp(mcp, tenantId) {
|
|
1222
|
+
if (!mcp || typeof mcp.command !== "string" || !Array.isArray(mcp.args)) {
|
|
1223
|
+
return impelTasksMcpInvocation(tenantId);
|
|
1224
|
+
}
|
|
1225
|
+
const mcpIndex = mcp.args.lastIndexOf("mcp");
|
|
1226
|
+
if (mcpIndex === -1) return impelTasksMcpInvocation(tenantId);
|
|
1227
|
+
return {
|
|
1228
|
+
command: mcp.command,
|
|
1229
|
+
args: [
|
|
1230
|
+
...mcp.args.slice(0, mcpIndex + 1),
|
|
1231
|
+
"--target",
|
|
1232
|
+
"tasks",
|
|
1233
|
+
...mcp.args.slice(mcpIndex + 1),
|
|
1234
|
+
],
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1180
1238
|
function writeChatGPTConfig(
|
|
1181
1239
|
paths,
|
|
1182
1240
|
config,
|
|
@@ -1258,6 +1316,25 @@ function writeChatGPTConfig(
|
|
|
1258
1316
|
command: mcpInvocation.command,
|
|
1259
1317
|
args: [...mcpInvocation.args],
|
|
1260
1318
|
};
|
|
1319
|
+
const tasksMcp = invocations?.tasksMcp
|
|
1320
|
+
|| tasksInvocationFromBaseMcp(mcp, config.tenantId);
|
|
1321
|
+
|
|
1322
|
+
if (RUNTIME_BRAND.features.mcp && config.tenantId) {
|
|
1323
|
+
const unmanagedToml = stripManagedChatGPTToml(currentToml);
|
|
1324
|
+
const tasksName = escapeRegex(IMPEL_TASKS_MCP_SERVER_NAME);
|
|
1325
|
+
const mcpServersKey = `(?:mcp_servers|"mcp_servers"|'mcp_servers')`;
|
|
1326
|
+
const tasksKey = `(?:${tasksName}|"${tasksName}"|'${tasksName}')`;
|
|
1327
|
+
const foreignTasksTable = new RegExp(
|
|
1328
|
+
`^[ \\t]*\\[[ \\t]*${mcpServersKey}[ \\t]*\\.[ \\t]*${tasksKey}(?:[ \\t]*\\.[ \\t]*|[ \\t]*\\])`,
|
|
1329
|
+
"mu",
|
|
1330
|
+
);
|
|
1331
|
+
if (foreignTasksTable.test(unmanagedToml)) {
|
|
1332
|
+
throw new Error(
|
|
1333
|
+
`${configPath} already has an mcp_servers.${IMPEL_TASKS_MCP_SERVER_NAME} table `
|
|
1334
|
+
+ "outside the impel-cli managed block. Remove or rename it, then re-run."
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1261
1338
|
|
|
1262
1339
|
const managedToml = [
|
|
1263
1340
|
CHATGPT_CONFIG_START,
|
|
@@ -1301,6 +1378,12 @@ function writeChatGPTConfig(
|
|
|
1301
1378
|
`[mcp_servers.${RUNTIME_BRAND.cli.providerId}]`,
|
|
1302
1379
|
`command = ${tomlString(mcp.command)}`,
|
|
1303
1380
|
`args = [${mcp.args.map((argument) => tomlString(argument)).join(", ")}]`,
|
|
1381
|
+
...(config.tenantId ? [
|
|
1382
|
+
"",
|
|
1383
|
+
`[mcp_servers.${IMPEL_TASKS_MCP_SERVER_NAME}]`,
|
|
1384
|
+
`command = ${tomlString(tasksMcp.command)}`,
|
|
1385
|
+
`args = [${tasksMcp.args.map((argument) => tomlString(argument)).join(", ")}]`,
|
|
1386
|
+
] : []),
|
|
1304
1387
|
] : []),
|
|
1305
1388
|
CHATGPT_CONFIG_END,
|
|
1306
1389
|
].join("\n");
|
|
@@ -1474,7 +1557,7 @@ function readVendorCodexModels(vendorPath) {
|
|
|
1474
1557
|
return new Map();
|
|
1475
1558
|
}
|
|
1476
1559
|
|
|
1477
|
-
function
|
|
1560
|
+
function stripManagedChatGPTToml(current) {
|
|
1478
1561
|
let remainder = current;
|
|
1479
1562
|
const start = current.indexOf(CHATGPT_CONFIG_START);
|
|
1480
1563
|
const end = current.indexOf(CHATGPT_CONFIG_END);
|
|
@@ -1484,7 +1567,11 @@ function mergeManagedChatGPTToml(current, managed) {
|
|
|
1484
1567
|
const legacyEnd = current.match(/refresh_interval_ms = \d+\r?\n/u);
|
|
1485
1568
|
if (legacyEnd?.index != null) remainder = current.slice(legacyEnd.index + legacyEnd[0].length);
|
|
1486
1569
|
}
|
|
1487
|
-
|
|
1570
|
+
return remainder.replace(/^\s+/u, "");
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
function mergeManagedChatGPTToml(current, managed) {
|
|
1574
|
+
const remainder = stripManagedChatGPTToml(current);
|
|
1488
1575
|
return `${managed}\n${remainder ? `\n${remainder.replace(/\s*$/u, "")}\n` : ""}`;
|
|
1489
1576
|
}
|
|
1490
1577
|
|
package/src/cliProfiles.js
CHANGED
|
@@ -10,7 +10,13 @@ import {
|
|
|
10
10
|
} from "./codexSecurity.js";
|
|
11
11
|
import { CONFIG_DIR } from "./config.js";
|
|
12
12
|
import { normalizeTenantId } from "./tenants.js";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
IMPEL_TASKS_MCP_SERVER_NAME,
|
|
15
|
+
impelCliInvocation,
|
|
16
|
+
impelMcpInvocation,
|
|
17
|
+
impelTasksMcpInvocation,
|
|
18
|
+
isImpelTasksMcpInvocation,
|
|
19
|
+
} from "./selfInvocation.js";
|
|
14
20
|
import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
|
|
15
21
|
import { renameWithWindowsRetry } from "./windowsFs.js";
|
|
16
22
|
import { ensureClaudeSessionHooks, ensureCodexSessionHooks } from "./sessionHooks.js";
|
|
@@ -132,13 +138,30 @@ export function ensureImpelClaudeProfile(gatewayUrl, tenantId, { crossAppModels
|
|
|
132
138
|
settings.env = managedEnvironment;
|
|
133
139
|
applyImpelClaudeSandbox(settings);
|
|
134
140
|
if (RUNTIME_BRAND.features.mcp) {
|
|
141
|
+
if (Object.hasOwn(userConfig, "mcpServers") && (
|
|
142
|
+
!userConfig.mcpServers
|
|
143
|
+
|| typeof userConfig.mcpServers !== "object"
|
|
144
|
+
|| Array.isArray(userConfig.mcpServers)
|
|
145
|
+
)) {
|
|
146
|
+
throw new Error(`${userConfigPath} has an invalid mcpServers value. Fix or remove it, then re-run.`);
|
|
147
|
+
}
|
|
148
|
+
const existingServers = userConfig.mcpServers
|
|
149
|
+
&& typeof userConfig.mcpServers === "object"
|
|
150
|
+
&& !Array.isArray(userConfig.mcpServers)
|
|
151
|
+
? userConfig.mcpServers
|
|
152
|
+
: {};
|
|
153
|
+
const existingTasksServer = existingServers[IMPEL_TASKS_MCP_SERVER_NAME];
|
|
154
|
+
if (Object.hasOwn(existingServers, IMPEL_TASKS_MCP_SERVER_NAME)
|
|
155
|
+
&& !isImpelTasksMcpInvocation(existingTasksServer)) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`${userConfigPath} already has an mcpServers.${IMPEL_TASKS_MCP_SERVER_NAME} entry `
|
|
158
|
+
+ "that wasn't written by impel-cli. Remove or rename it, then re-run."
|
|
159
|
+
);
|
|
160
|
+
}
|
|
135
161
|
userConfig.mcpServers = {
|
|
136
|
-
...
|
|
137
|
-
typeof userConfig.mcpServers === "object" &&
|
|
138
|
-
!Array.isArray(userConfig.mcpServers)
|
|
139
|
-
? userConfig.mcpServers
|
|
140
|
-
: {}),
|
|
162
|
+
...existingServers,
|
|
141
163
|
[RUNTIME_BRAND.cli.providerId]: impelMcpInvocation(["--tenant", tenantId]),
|
|
164
|
+
[IMPEL_TASKS_MCP_SERVER_NAME]: impelTasksMcpInvocation(tenantId),
|
|
142
165
|
};
|
|
143
166
|
}
|
|
144
167
|
|
|
@@ -194,6 +217,13 @@ function codexManagedBlock(gatewayUrl, tenantId) {
|
|
|
194
217
|
"",
|
|
195
218
|
);
|
|
196
219
|
}
|
|
220
|
+
const tasksMcp = impelTasksMcpInvocation(tenantId);
|
|
221
|
+
lines.push(
|
|
222
|
+
`[mcp_servers.${IMPEL_TASKS_MCP_SERVER_NAME}]`,
|
|
223
|
+
`command = ${JSON.stringify(tasksMcp.command)}`,
|
|
224
|
+
`args = [${tasksMcp.args.map((argument) => JSON.stringify(argument)).join(", ")}]`,
|
|
225
|
+
"",
|
|
226
|
+
);
|
|
197
227
|
}
|
|
198
228
|
lines.push(CODEX_END_MARK);
|
|
199
229
|
return lines.join("\n");
|
|
@@ -208,9 +238,18 @@ export function ensureImpelCodexProfile(gatewayUrl, tenantId) {
|
|
|
208
238
|
const withoutManagedBlock = stripCodexManagedBlock(original, configPath);
|
|
209
239
|
|
|
210
240
|
const providerPattern = RUNTIME_BRAND.cli.providerId.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
211
|
-
|
|
241
|
+
const tasksPattern = IMPEL_TASKS_MCP_SERVER_NAME.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
242
|
+
const providerKey = `(?:${providerPattern}|"${providerPattern}"|'${providerPattern}')`;
|
|
243
|
+
const tasksKey = `(?:${tasksPattern}|"${tasksPattern}"|'${tasksPattern}')`;
|
|
244
|
+
const modelProvidersKey = `(?:model_providers|"model_providers"|'model_providers')`;
|
|
245
|
+
const mcpServersKey = `(?:mcp_servers|"mcp_servers"|'mcp_servers')`;
|
|
246
|
+
const dot = "[ \\t]*\\.[ \\t]*";
|
|
247
|
+
if (new RegExp(
|
|
248
|
+
`^[ \\t]*\\[[ \\t]*(?:${modelProvidersKey}${dot}${providerKey}|${mcpServersKey}${dot}(?:${providerKey}|${tasksKey}))(?:${dot}|[ \\t]*\\])`,
|
|
249
|
+
"m",
|
|
250
|
+
).test(withoutManagedBlock)) {
|
|
212
251
|
throw new Error(
|
|
213
|
-
`${configPath} contains a ${RUNTIME_BRAND.product.displayName} provider or MCP table outside the managed profile block. ` +
|
|
252
|
+
`${configPath} contains a ${RUNTIME_BRAND.product.displayName} provider or managed MCP table outside the managed profile block. ` +
|
|
214
253
|
"Remove or rename that table, then re-run."
|
|
215
254
|
);
|
|
216
255
|
}
|
package/src/commands/mcp.js
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import readline from "node:readline";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
loadConfig,
|
|
5
|
+
normalizeGatewayUrl,
|
|
6
|
+
redactSecretText,
|
|
7
|
+
resolveDefaultAppUrl,
|
|
8
|
+
resolveDefaultGateway,
|
|
9
|
+
} from "../config.js";
|
|
4
10
|
import { parseFlags } from "../args.js";
|
|
5
11
|
import { ensureTenantSelection, normalizeTenantId, tenantCredential } from "../tenants.js";
|
|
6
12
|
|
|
13
|
+
const TASKS_TARGET = "tasks";
|
|
14
|
+
const TASKS_TENANT_HEADER = "X-Impel-Tenant";
|
|
15
|
+
const TASKS_ERROR_MESSAGE_MAX_LENGTH = 512;
|
|
16
|
+
const TASKS_ERROR_TOKEN_MAX_LENGTH = 128;
|
|
17
|
+
|
|
7
18
|
function rpcError(id, message) {
|
|
8
19
|
return JSON.stringify({
|
|
9
20
|
jsonrpc: "2.0",
|
|
@@ -12,6 +23,28 @@ function rpcError(id, message) {
|
|
|
12
23
|
});
|
|
13
24
|
}
|
|
14
25
|
|
|
26
|
+
function tasksJsonRpcRequestId(message) {
|
|
27
|
+
if (
|
|
28
|
+
!message
|
|
29
|
+
|| typeof message !== "object"
|
|
30
|
+
|| Array.isArray(message)
|
|
31
|
+
|| message.jsonrpc !== "2.0"
|
|
32
|
+
|| typeof message.method !== "string"
|
|
33
|
+
) {
|
|
34
|
+
return { valid: false, id: null };
|
|
35
|
+
}
|
|
36
|
+
if (!Object.hasOwn(message, "id")) return { valid: true, id: null };
|
|
37
|
+
const id = message.id;
|
|
38
|
+
if (
|
|
39
|
+
id === null
|
|
40
|
+
|| typeof id === "string"
|
|
41
|
+
|| (typeof id === "number" && Number.isFinite(id))
|
|
42
|
+
) {
|
|
43
|
+
return { valid: true, id };
|
|
44
|
+
}
|
|
45
|
+
return { valid: false, id: null };
|
|
46
|
+
}
|
|
47
|
+
|
|
15
48
|
function responseLines(contentType, body) {
|
|
16
49
|
if (!contentType.toLowerCase().includes("text/event-stream")) {
|
|
17
50
|
return body.trim() ? [JSON.stringify(JSON.parse(body))] : [];
|
|
@@ -24,18 +57,146 @@ function responseLines(contentType, body) {
|
|
|
24
57
|
.map((line) => JSON.stringify(JSON.parse(line)));
|
|
25
58
|
}
|
|
26
59
|
|
|
60
|
+
function isApplicationJson(contentType) {
|
|
61
|
+
return contentType.split(";", 1)[0].trim().toLowerCase() === "application/json";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function jsonResponseLines(contentType, body) {
|
|
65
|
+
if (!body.trim()) return [];
|
|
66
|
+
if (!isApplicationJson(contentType)) {
|
|
67
|
+
throw new Error("Impel Tasks MCP returned a non-JSON response.");
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
return [JSON.stringify(JSON.parse(body))];
|
|
71
|
+
} catch {
|
|
72
|
+
throw new Error("Impel Tasks MCP returned invalid JSON.");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function tasksHttpError(status) {
|
|
77
|
+
if (status === 401 || status === 403) {
|
|
78
|
+
return `Impel Tasks MCP authentication failed (HTTP ${status}). Run \`impel setup\` to refresh access.`;
|
|
79
|
+
}
|
|
80
|
+
return `Impel Tasks MCP returned HTTP ${status}.`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function loadTasksMcpConnection() {
|
|
84
|
+
const config = loadConfig();
|
|
85
|
+
if (
|
|
86
|
+
!config
|
|
87
|
+
|| typeof config.pat !== "string"
|
|
88
|
+
|| !config.pat.trim()
|
|
89
|
+
|| config.pat !== config.pat.trim()
|
|
90
|
+
) {
|
|
91
|
+
throw new Error("not authenticated; run `impel setup` (or `impel auth`) first");
|
|
92
|
+
}
|
|
93
|
+
const appUrl = normalizeGatewayUrl(config.appUrl || resolveDefaultAppUrl());
|
|
94
|
+
let endpoint;
|
|
95
|
+
try {
|
|
96
|
+
endpoint = new URL("/api/mcp/tasks", `${appUrl}/`);
|
|
97
|
+
} catch {
|
|
98
|
+
throw new Error("Tasks MCP app URL is invalid; run `impel setup` to repair it");
|
|
99
|
+
}
|
|
100
|
+
if (
|
|
101
|
+
!["http:", "https:"].includes(endpoint.protocol)
|
|
102
|
+
|| endpoint.username
|
|
103
|
+
|| endpoint.password
|
|
104
|
+
) {
|
|
105
|
+
throw new Error("Tasks MCP app URL is invalid; run `impel setup` to repair it");
|
|
106
|
+
}
|
|
107
|
+
return { credential: config.pat, endpoint: endpoint.href };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function safeTasksErrorToken(value) {
|
|
111
|
+
if (
|
|
112
|
+
typeof value !== "string"
|
|
113
|
+
|| value.length === 0
|
|
114
|
+
|| value.length > TASKS_ERROR_TOKEN_MAX_LENGTH
|
|
115
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(value)
|
|
116
|
+
|| redactSecretText(value) !== value
|
|
117
|
+
) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function safeTasksErrorData(value, httpStatus) {
|
|
124
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
125
|
+
const data = {};
|
|
126
|
+
const schemaVersion = safeTasksErrorToken(value.schemaVersion);
|
|
127
|
+
const code = safeTasksErrorToken(value.code);
|
|
128
|
+
if (schemaVersion !== undefined) data.schemaVersion = schemaVersion;
|
|
129
|
+
if (code !== undefined) data.code = code;
|
|
130
|
+
if (Number.isSafeInteger(value.status) && value.status === httpStatus) {
|
|
131
|
+
data.status = value.status;
|
|
132
|
+
}
|
|
133
|
+
if (typeof value.retryable === "boolean") data.retryable = value.retryable;
|
|
134
|
+
return Object.keys(data).length > 0 ? data : undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function tasksJsonRpcHttpError(id, contentType, body, httpStatus) {
|
|
138
|
+
if (!isApplicationJson(contentType)) return null;
|
|
139
|
+
let response;
|
|
140
|
+
try {
|
|
141
|
+
response = JSON.parse(body);
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
!response
|
|
147
|
+
|| typeof response !== "object"
|
|
148
|
+
|| Array.isArray(response)
|
|
149
|
+
|| response.jsonrpc !== "2.0"
|
|
150
|
+
|| !response.error
|
|
151
|
+
|| typeof response.error !== "object"
|
|
152
|
+
|| Array.isArray(response.error)
|
|
153
|
+
|| !Number.isSafeInteger(response.error.code)
|
|
154
|
+
|| typeof response.error.message !== "string"
|
|
155
|
+
) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
const message = redactSecretText(response.error.message).trim();
|
|
159
|
+
if (!message) return null;
|
|
160
|
+
const error = {
|
|
161
|
+
code: response.error.code,
|
|
162
|
+
message: message.length <= TASKS_ERROR_MESSAGE_MAX_LENGTH
|
|
163
|
+
? message
|
|
164
|
+
: `${message.slice(0, TASKS_ERROR_MESSAGE_MAX_LENGTH - 1)}…`,
|
|
165
|
+
};
|
|
166
|
+
const data = safeTasksErrorData(response.error.data, httpStatus);
|
|
167
|
+
if (data) error.data = data;
|
|
168
|
+
return JSON.stringify({ jsonrpc: "2.0", id: id ?? null, error });
|
|
169
|
+
}
|
|
170
|
+
|
|
27
171
|
export async function cmdMcp(argv = []) {
|
|
28
|
-
const { flags } = parseFlags(argv, {
|
|
172
|
+
const { flags, positionals } = parseFlags(argv, {
|
|
173
|
+
target: { type: "string" },
|
|
174
|
+
tenant: { type: "string" },
|
|
175
|
+
});
|
|
176
|
+
const targetSpecified = Object.hasOwn(flags, "target");
|
|
177
|
+
if (targetSpecified && flags.target !== TASKS_TARGET) {
|
|
178
|
+
throw new Error("unsupported MCP target; expected `--target tasks`");
|
|
179
|
+
}
|
|
180
|
+
const tasksTarget = flags.target === TASKS_TARGET;
|
|
181
|
+
if (tasksTarget) {
|
|
182
|
+
const unsupportedFlags = Object.keys(flags).filter((name) => !["target", "tenant"].includes(name));
|
|
183
|
+
if (unsupportedFlags.length > 0 || positionals.length > 0) {
|
|
184
|
+
throw new Error("unsupported Tasks MCP arguments; use `--target tasks --tenant <tenant>`");
|
|
185
|
+
}
|
|
186
|
+
if (typeof flags.tenant !== "string" || !flags.tenant.trim()) {
|
|
187
|
+
throw new Error("Tasks MCP requires a fixed `--tenant <tenant>` argument");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
29
190
|
const config = loadConfig();
|
|
30
191
|
if (!config?.pat) {
|
|
31
192
|
throw new Error("not authenticated; run `impel setup` (or `impel auth`) first");
|
|
32
193
|
}
|
|
33
|
-
const gatewayUrl = config.gatewayUrl || resolveDefaultGateway();
|
|
34
194
|
const tenantId = flags.tenant
|
|
35
195
|
? normalizeTenantId(flags.tenant)
|
|
36
196
|
: (await ensureTenantSelection(config)).tenantId;
|
|
37
|
-
const
|
|
38
|
-
const
|
|
197
|
+
const gatewayUrl = config.gatewayUrl || resolveDefaultGateway();
|
|
198
|
+
const credential = tasksTarget ? null : tenantCredential(config.pat, tenantId);
|
|
199
|
+
const endpoint = tasksTarget ? null : `${gatewayUrl}/mcp`;
|
|
39
200
|
const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
40
201
|
let sessionId;
|
|
41
202
|
|
|
@@ -49,19 +210,30 @@ export async function cmdMcp(argv = []) {
|
|
|
49
210
|
process.stdout.write(`${rpcError(null, "Invalid JSON-RPC request.")}\n`);
|
|
50
211
|
continue;
|
|
51
212
|
}
|
|
213
|
+
const tasksRequest = tasksTarget
|
|
214
|
+
? tasksJsonRpcRequestId(message)
|
|
215
|
+
: null;
|
|
216
|
+
if (tasksTarget && !tasksRequest.valid) {
|
|
217
|
+
process.stdout.write(`${rpcError(tasksRequest.id, "Invalid JSON-RPC request.")}\n`);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
52
220
|
|
|
53
221
|
try {
|
|
222
|
+
const tasksConnection = tasksTarget ? loadTasksMcpConnection() : null;
|
|
54
223
|
const controller = new AbortController();
|
|
55
224
|
const timeout = setTimeout(() => controller.abort(), 70_000);
|
|
56
225
|
let response;
|
|
57
226
|
try {
|
|
58
|
-
response = await fetch(endpoint, {
|
|
227
|
+
response = await fetch(tasksConnection?.endpoint || endpoint, {
|
|
59
228
|
method: "POST",
|
|
60
229
|
headers: {
|
|
61
|
-
Authorization: `Bearer ${
|
|
230
|
+
Authorization: `Bearer ${tasksConnection?.credential || credential}`,
|
|
62
231
|
"Content-Type": "application/json",
|
|
232
|
+
// Streamable HTTP requires clients to advertise both media types
|
|
233
|
+
// even when the server is configured to emit bounded JSON only.
|
|
63
234
|
Accept: "application/json, text/event-stream",
|
|
64
|
-
...(
|
|
235
|
+
...(tasksTarget ? { [TASKS_TENANT_HEADER]: tenantId } : {}),
|
|
236
|
+
...(!tasksTarget && sessionId ? { "Mcp-Session-Id": sessionId } : {}),
|
|
65
237
|
},
|
|
66
238
|
body: JSON.stringify(message),
|
|
67
239
|
signal: controller.signal,
|
|
@@ -69,26 +241,38 @@ export async function cmdMcp(argv = []) {
|
|
|
69
241
|
} finally {
|
|
70
242
|
clearTimeout(timeout);
|
|
71
243
|
}
|
|
72
|
-
sessionId = response.headers.get("mcp-session-id") || sessionId;
|
|
244
|
+
if (!tasksTarget) sessionId = response.headers.get("mcp-session-id") || sessionId;
|
|
73
245
|
const body = await response.text();
|
|
246
|
+
const contentType = response.headers.get("content-type")
|
|
247
|
+
|| (tasksTarget ? "" : "application/json");
|
|
74
248
|
if (!response.ok) {
|
|
249
|
+
const tasksError = tasksTarget
|
|
250
|
+
? tasksJsonRpcHttpError(tasksRequest.id, contentType, body, response.status)
|
|
251
|
+
: null;
|
|
75
252
|
process.stdout.write(
|
|
76
|
-
`${rpcError(
|
|
253
|
+
`${tasksError || rpcError(
|
|
254
|
+
tasksTarget ? tasksRequest.id : message.id,
|
|
255
|
+
tasksTarget
|
|
256
|
+
? tasksHttpError(response.status)
|
|
257
|
+
: `Impel MCP gateway returned HTTP ${response.status}.`
|
|
258
|
+
)}\n`
|
|
77
259
|
);
|
|
78
260
|
continue;
|
|
79
261
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
body
|
|
83
|
-
)
|
|
262
|
+
const outputs = tasksTarget
|
|
263
|
+
? jsonResponseLines(contentType, body)
|
|
264
|
+
: responseLines(contentType, body);
|
|
265
|
+
for (const output of outputs) {
|
|
84
266
|
process.stdout.write(`${output}\n`);
|
|
85
267
|
}
|
|
86
268
|
} catch (error) {
|
|
87
269
|
const messageText =
|
|
88
270
|
error?.name === "AbortError"
|
|
89
|
-
? "Impel MCP gateway timed out."
|
|
90
|
-
:
|
|
91
|
-
|
|
271
|
+
? tasksTarget ? "Impel Tasks MCP timed out." : "Impel MCP gateway timed out."
|
|
272
|
+
: tasksTarget
|
|
273
|
+
? `Impel Tasks MCP request failed: ${redactSecretText(error?.message || error)}`
|
|
274
|
+
: `Impel MCP gateway request failed: ${redactSecretText(error?.message || error)}`;
|
|
275
|
+
process.stdout.write(`${rpcError(tasksTarget ? tasksRequest.id : message.id, messageText)}\n`);
|
|
92
276
|
}
|
|
93
277
|
}
|
|
94
278
|
}
|
package/src/commands/remote.js
CHANGED
|
@@ -61,6 +61,8 @@ Usage:
|
|
|
61
61
|
impel remote status [run-id] [--json]
|
|
62
62
|
impel remote attach [run-id] [--provider codex|claude] [--desktop]
|
|
63
63
|
impel remote dispatch [path] --provider codex|claude --session <id> [--fork]
|
|
64
|
+
impel remote handoff [path] --provider codex|claude --session <id> [--detach]
|
|
65
|
+
impel remote follow [run-id]
|
|
64
66
|
impel remote proxy <run-id> --port <port>
|
|
65
67
|
impel remote down [run-id] --yes
|
|
66
68
|
impel remote down --all --yes
|
|
@@ -77,6 +79,9 @@ Lifecycle options:
|
|
|
77
79
|
--timeout <seconds> Runner startup timeout from 30 through 900. Default: 300.
|
|
78
80
|
--json Emit machine-readable state where supported.
|
|
79
81
|
|
|
82
|
+
Handoff options:
|
|
83
|
+
--detach Start remote execution and return immediately.
|
|
84
|
+
|
|
80
85
|
Attach options:
|
|
81
86
|
--session <id> Resume a transferred provider session.
|
|
82
87
|
--fork Fork instead of continuing the transferred session.
|
|
@@ -85,8 +90,10 @@ Attach options:
|
|
|
85
90
|
The live UI uses native SSH: Codex Desktop starts remote codex app-server and can
|
|
86
91
|
hand off an existing chat and Git state. Claude Desktop can start an SSH session;
|
|
87
92
|
existing Claude sessions use dispatch/resume because Claude has no arbitrary-host
|
|
88
|
-
desktop handoff API.
|
|
89
|
-
|
|
93
|
+
desktop handoff API. Handoff starts a headless worker inside Fargate and streams
|
|
94
|
+
its structured events back; the local app is only the control/viewer process.
|
|
95
|
+
Credentials, ignored files, SSH agents, and environment variables are never
|
|
96
|
+
copied implicitly.
|
|
90
97
|
`;
|
|
91
98
|
|
|
92
99
|
class RemoteCommandError extends Error {}
|
|
@@ -124,6 +131,7 @@ function rejectMissingFlagValues(flags, spec, action) {
|
|
|
124
131
|
function lifecycleSpec() {
|
|
125
132
|
return {
|
|
126
133
|
env: { type: "string" },
|
|
134
|
+
detach: { type: "boolean" },
|
|
127
135
|
help: { type: "boolean" },
|
|
128
136
|
install: { type: "string" },
|
|
129
137
|
json: { type: "boolean" },
|
|
@@ -346,6 +354,47 @@ async function cmdUp(argv, { dispatch = false } = {}) {
|
|
|
346
354
|
return state;
|
|
347
355
|
}
|
|
348
356
|
|
|
357
|
+
async function stopAndCleanRun(config, state) {
|
|
358
|
+
const context = awsContext(state);
|
|
359
|
+
if (state.status !== "stopped" && state.aws?.taskArn) {
|
|
360
|
+
try { stopTask(context, state); } catch (error) {
|
|
361
|
+
const current = describeTask(context, state);
|
|
362
|
+
if (current.lastStatus !== "STOPPED") throw error;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
let revokeError = null;
|
|
366
|
+
if (!state.credential?.revokedAt) {
|
|
367
|
+
if (!config?.pat) {
|
|
368
|
+
revokeError = new Error("no local Impel credential is available to revoke the remote PAT");
|
|
369
|
+
} else {
|
|
370
|
+
try { state = await revokeRunCredential(config, state); } catch (error) { revokeError = error; }
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
removeSshAlias(state.runId);
|
|
374
|
+
removeRunSecrets(state.runId);
|
|
375
|
+
state = writeRunState({
|
|
376
|
+
...state,
|
|
377
|
+
status: "stopped",
|
|
378
|
+
stoppedAt: state.stoppedAt || new Date().toISOString(),
|
|
379
|
+
...(revokeError ? {
|
|
380
|
+
credential: {
|
|
381
|
+
...state.credential,
|
|
382
|
+
revokeError: redactSecretText(revokeError?.message || revokeError),
|
|
383
|
+
},
|
|
384
|
+
} : {}),
|
|
385
|
+
});
|
|
386
|
+
return { state, revokeError };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function printCleanupResult(state, revokeError) {
|
|
390
|
+
if (revokeError) {
|
|
391
|
+
console.warn(`Stopped remote run ${state.runId} and removed its SSH alias, but credential revocation must be retried: ${redactSecretText(revokeError?.message || revokeError)}`);
|
|
392
|
+
process.exitCode = 1;
|
|
393
|
+
} else {
|
|
394
|
+
console.log(`Stopped remote run ${state.runId}; revoked its credential and removed its SSH alias.`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
349
398
|
function syncTaskStatus(state) {
|
|
350
399
|
if (!state?.aws?.taskArn || ["failed", "stopped"].includes(state.status)) return state;
|
|
351
400
|
const context = awsContext(state);
|
|
@@ -456,6 +505,116 @@ async function cmdDispatch(argv) {
|
|
|
456
505
|
]);
|
|
457
506
|
}
|
|
458
507
|
|
|
508
|
+
function workerCommand(action, state) {
|
|
509
|
+
const command = ["impel-remote-worker", action];
|
|
510
|
+
if (action === "start") {
|
|
511
|
+
command.push(state.provider, state.session.id, state.repository.remoteProjectPath);
|
|
512
|
+
}
|
|
513
|
+
return command.map(shellQuote).join(" ");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function readWorkerStatus(state) {
|
|
517
|
+
const result = runCapture(
|
|
518
|
+
process.env.IMPEL_REMOTE_SSH_BIN || "ssh",
|
|
519
|
+
[state.alias, workerCommand("status", state)],
|
|
520
|
+
{ allowFailure: true },
|
|
521
|
+
);
|
|
522
|
+
if (result.status !== 0) return null;
|
|
523
|
+
try {
|
|
524
|
+
const value = JSON.parse(result.stdout.trim());
|
|
525
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
526
|
+
} catch {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function followRemoteWorker(state) {
|
|
532
|
+
console.log(`Remote execution ${state.runId} is running in Fargate. Streaming structured events:`);
|
|
533
|
+
console.log("");
|
|
534
|
+
runInteractive(
|
|
535
|
+
process.env.IMPEL_REMOTE_SSH_BIN || "ssh",
|
|
536
|
+
[state.alias, workerCommand("follow", state)],
|
|
537
|
+
{ allowFailure: true },
|
|
538
|
+
);
|
|
539
|
+
const worker = readWorkerStatus(state);
|
|
540
|
+
if (!worker || !["completed", "failed"].includes(worker.status)) {
|
|
541
|
+
state = writeRunState({
|
|
542
|
+
...state,
|
|
543
|
+
execution: { ...state.execution, status: "running", lastFollowEndedAt: new Date().toISOString() },
|
|
544
|
+
});
|
|
545
|
+
console.warn(`Remote execution is still running or its status could not be read. Reconnect with: impel remote follow ${state.runId}`);
|
|
546
|
+
return { state, completed: false };
|
|
547
|
+
}
|
|
548
|
+
const exitCode = Number.isInteger(worker.exitCode) ? worker.exitCode : 1;
|
|
549
|
+
state = writeRunState({
|
|
550
|
+
...state,
|
|
551
|
+
execution: {
|
|
552
|
+
...state.execution,
|
|
553
|
+
status: worker.status,
|
|
554
|
+
exitCode,
|
|
555
|
+
finishedAt: worker.finishedAt || new Date().toISOString(),
|
|
556
|
+
},
|
|
557
|
+
});
|
|
558
|
+
const cleaned = await stopAndCleanRun(loadConfig(), state);
|
|
559
|
+
printCleanupResult(cleaned.state, cleaned.revokeError);
|
|
560
|
+
if (exitCode !== 0) process.exitCode = exitCode;
|
|
561
|
+
return { state: cleaned.state, completed: true };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function cmdHandoff(argv) {
|
|
565
|
+
const spec = lifecycleSpec();
|
|
566
|
+
const { flags, positionals } = parseFlags(argv, spec);
|
|
567
|
+
rejectFlags(flags, new Set([
|
|
568
|
+
"detach", "env", "help", "install", "json", "profile", "provider", "region", "session", "setup", "stack", "timeout", "ttl",
|
|
569
|
+
]), "handoff");
|
|
570
|
+
rejectMissingFlagValues(flags, spec, "handoff");
|
|
571
|
+
if (flags.help) { console.log(HELP); return; }
|
|
572
|
+
if (positionals.length > 1) fail("impel remote handoff: expected at most one repository path");
|
|
573
|
+
if (!flags.session) fail("impel remote handoff: --session is required");
|
|
574
|
+
|
|
575
|
+
let state = await createRemoteRun({ ...flags, path: positionals[0] });
|
|
576
|
+
try {
|
|
577
|
+
runCapture(
|
|
578
|
+
process.env.IMPEL_REMOTE_SSH_BIN || "ssh",
|
|
579
|
+
[state.alias, workerCommand("start", state)],
|
|
580
|
+
);
|
|
581
|
+
state = writeRunState({
|
|
582
|
+
...state,
|
|
583
|
+
execution: {
|
|
584
|
+
mode: flags.detach ? "detached" : "follow",
|
|
585
|
+
status: "running",
|
|
586
|
+
startedAt: new Date().toISOString(),
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
} catch (error) {
|
|
590
|
+
const cleaned = await stopAndCleanRun(loadConfig(), state);
|
|
591
|
+
printCleanupResult(cleaned.state, cleaned.revokeError);
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (flags.detach) {
|
|
596
|
+
printRun(state, flags.json === true);
|
|
597
|
+
if (!flags.json) {
|
|
598
|
+
console.log(`Worker: running remotely; follow with impel remote follow ${state.runId}`);
|
|
599
|
+
console.log("Results: the transferred provider session continues syncing through impel-sessions.");
|
|
600
|
+
}
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
await followRemoteWorker(state);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function cmdFollow(argv) {
|
|
607
|
+
const spec = { help: { type: "boolean" } };
|
|
608
|
+
const { flags, positionals } = parseFlags(argv, spec);
|
|
609
|
+
rejectFlags(flags, new Set(["help"]), "follow");
|
|
610
|
+
if (flags.help) { console.log(HELP); return; }
|
|
611
|
+
if (positionals.length > 1) fail("impel remote follow: expected at most one run id");
|
|
612
|
+
const state = syncTaskStatus(readRunState(resolveRunId(positionals[0])));
|
|
613
|
+
if (state.status !== "running") fail(`impel remote follow: run ${state.runId} is ${state.status}, not running`);
|
|
614
|
+
if (!state.execution) fail(`impel remote follow: run ${state.runId} has no headless execution`);
|
|
615
|
+
await followRemoteWorker(state);
|
|
616
|
+
}
|
|
617
|
+
|
|
459
618
|
async function cmdDown(argv) {
|
|
460
619
|
const { flags, positionals } = parseFlags(argv, {
|
|
461
620
|
all: { type: "boolean" },
|
|
@@ -472,40 +631,9 @@ async function cmdDown(argv) {
|
|
|
472
631
|
: [readRunState(resolveRunId(positionals[0], { includeStopped: true }))];
|
|
473
632
|
const config = loadConfig();
|
|
474
633
|
for (let state of states) {
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const current = describeTask(context, state);
|
|
479
|
-
if (current.lastStatus !== "STOPPED") throw error;
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
let revokeError = null;
|
|
483
|
-
if (!state.credential?.revokedAt) {
|
|
484
|
-
if (!config?.pat) {
|
|
485
|
-
revokeError = new Error("no local Impel credential is available to revoke the remote PAT");
|
|
486
|
-
} else {
|
|
487
|
-
try { state = await revokeRunCredential(config, state); } catch (error) { revokeError = error; }
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
removeSshAlias(state.runId);
|
|
491
|
-
removeRunSecrets(state.runId);
|
|
492
|
-
state = writeRunState({
|
|
493
|
-
...state,
|
|
494
|
-
status: "stopped",
|
|
495
|
-
stoppedAt: state.stoppedAt || new Date().toISOString(),
|
|
496
|
-
...(revokeError ? {
|
|
497
|
-
credential: {
|
|
498
|
-
...state.credential,
|
|
499
|
-
revokeError: redactSecretText(revokeError?.message || revokeError),
|
|
500
|
-
},
|
|
501
|
-
} : {}),
|
|
502
|
-
});
|
|
503
|
-
if (revokeError) {
|
|
504
|
-
console.warn(`Stopped remote run ${state.runId} and removed its SSH alias, but credential revocation must be retried: ${redactSecretText(revokeError?.message || revokeError)}`);
|
|
505
|
-
process.exitCode = 1;
|
|
506
|
-
} else {
|
|
507
|
-
console.log(`Stopped remote run ${state.runId}; revoked its credential and removed its SSH alias.`);
|
|
508
|
-
}
|
|
634
|
+
const cleaned = await stopAndCleanRun(config, state);
|
|
635
|
+
state = cleaned.state;
|
|
636
|
+
printCleanupResult(state, cleaned.revokeError);
|
|
509
637
|
}
|
|
510
638
|
}
|
|
511
639
|
|
|
@@ -543,6 +671,8 @@ export async function cmdRemote(argv) {
|
|
|
543
671
|
case "status": return await cmdStatus(rest);
|
|
544
672
|
case "attach": return await cmdAttach(rest);
|
|
545
673
|
case "dispatch": return await cmdDispatch(rest);
|
|
674
|
+
case "handoff": return await cmdHandoff(rest);
|
|
675
|
+
case "follow": return await cmdFollow(rest);
|
|
546
676
|
case "down": return await cmdDown(rest);
|
|
547
677
|
case "proxy": return cmdProxy(rest);
|
|
548
678
|
default: fail(`impel remote: unknown subcommand ${JSON.stringify(action)}`);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog opt-in for Impel MCP `answer_native_agent`.
|
|
3
|
+
*
|
|
4
|
+
* When `directAnswer` is true, managed Claude/Codex adapters call that one-shot
|
|
5
|
+
* tool (Impel auth → Eve `/eve/v1/answer`) and return forUser/answer instead of
|
|
6
|
+
* list → start → poll. Distinct from agent-internal retrieve shortcuts.
|
|
7
|
+
*
|
|
8
|
+
* Does not replace the verbatimRelay description / fork_turns override: when an
|
|
9
|
+
* agent also sets verbatimRelay, keep that marker and spawn guidance so parents
|
|
10
|
+
* still use fork_turns="none" and relay the adapter result verbatim.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
VERBATIM_FINAL_TEXT_CONSTRAINTS,
|
|
15
|
+
} from "./verbatimRelay.js";
|
|
16
|
+
|
|
17
|
+
export function usesDirectAnswer(agent) {
|
|
18
|
+
return agent?.directAnswer === true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function extractAnswerFinalText(payload) {
|
|
22
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
23
|
+
for (const key of ["forUser", "answer"]) {
|
|
24
|
+
const text = payload[key];
|
|
25
|
+
if (typeof text === "string" && text.trim()) return text;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function claudeAnswerHardCompletionGuidance() {
|
|
31
|
+
return (
|
|
32
|
+
`When it succeeds, return forUser if present, otherwise answer, exactly with ${VERBATIM_FINAL_TEXT_CONSTRAINTS}. ` +
|
|
33
|
+
"When it fails, return the tool error without inventing a replacement result. " +
|
|
34
|
+
"Never invent or independently synthesize a replacement result."
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function claudeAnswerSoftCompletionGuidance() {
|
|
39
|
+
return (
|
|
40
|
+
"When it succeeds, return forUser if present, otherwise answer, faithfully as the answer. " +
|
|
41
|
+
"When it fails, return the tool error without inventing a replacement result. " +
|
|
42
|
+
"Never invent or independently synthesize a replacement result."
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function adapterAnswerHardCompletionGuidance() {
|
|
47
|
+
return (
|
|
48
|
+
`After the orchestration completes, return its single text output verbatim with ${VERBATIM_FINAL_TEXT_CONSTRAINTS}. ` +
|
|
49
|
+
"A successful output is forUser (else answer) exactly. A failure output preserves the error payload."
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function adapterAnswerSoftCompletionGuidance() {
|
|
54
|
+
return (
|
|
55
|
+
"After the orchestration completes, return its single text output as the answer. " +
|
|
56
|
+
"A successful output is forUser (else answer). A failure output preserves the error payload. " +
|
|
57
|
+
"Never invent or independently synthesize a replacement result."
|
|
58
|
+
);
|
|
59
|
+
}
|
package/src/remote/transfer.js
CHANGED
|
@@ -40,6 +40,14 @@ function writePrivate(filePath, contents) {
|
|
|
40
40
|
try { fs.chmodSync(filePath, 0o600); } catch { /* Best effort on Windows. */ }
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
function localTarEnvironment() {
|
|
44
|
+
// macOS libarchive serializes extended attributes into AppleDouble `._*`
|
|
45
|
+
// entries unless copyfile metadata is explicitly disabled. Those synthetic
|
|
46
|
+
// files make a clean transferred repository appear dirty on Linux even when
|
|
47
|
+
// tar's --no-xattrs option is present.
|
|
48
|
+
return { ...process.env, COPYFILE_DISABLE: "1" };
|
|
49
|
+
}
|
|
50
|
+
|
|
43
51
|
export function inspectRepository(requestedPath = process.cwd()) {
|
|
44
52
|
const requested = path.resolve(requestedPath);
|
|
45
53
|
const candidate = fs.realpathSync(requested);
|
|
@@ -98,7 +106,7 @@ export function createRepositoryTransfer(state, repository) {
|
|
|
98
106
|
"--no-xattrs",
|
|
99
107
|
"--null",
|
|
100
108
|
"-T", paths.fileList,
|
|
101
|
-
], { cwd: repository.root });
|
|
109
|
+
], { cwd: repository.root, env: localTarEnvironment() });
|
|
102
110
|
return paths;
|
|
103
111
|
}
|
|
104
112
|
|
|
@@ -119,6 +127,18 @@ function joinNullBuffer(entries) {
|
|
|
119
127
|
return Buffer.concat(entries.flatMap((entry) => [entry, Buffer.from([0])]));
|
|
120
128
|
}
|
|
121
129
|
|
|
130
|
+
function claudeProjectStorageKey(projectPath) {
|
|
131
|
+
return String(projectPath).replace(/[^A-Za-z0-9]/gu, "-");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function checkpointRemoteRelativePath(provider, relativePath, remoteProjectPath) {
|
|
135
|
+
const normalized = String(relativePath).split(path.sep).join("/");
|
|
136
|
+
if (provider !== "claude") return normalized;
|
|
137
|
+
const segments = normalized.split("/");
|
|
138
|
+
if (segments[0] !== "projects" || segments.length < 3) return normalized;
|
|
139
|
+
return ["projects", claudeProjectStorageKey(remoteProjectPath), ...segments.slice(2)].join("/");
|
|
140
|
+
}
|
|
141
|
+
|
|
122
142
|
export async function waitForSsh(state, timeoutSeconds = 120) {
|
|
123
143
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
124
144
|
let lastError = "SSH did not accept a connection";
|
|
@@ -274,15 +294,46 @@ export function transferSessionCheckpoint(state, { provider, sessionId, tenantId
|
|
|
274
294
|
}
|
|
275
295
|
const checkpointList = path.join(runPaths(state.runId).root, `checkpoint-${provider}.list`);
|
|
276
296
|
const checkpointArchive = path.join(runPaths(state.runId).root, `checkpoint-${provider}.tar`);
|
|
297
|
+
const mappings = files.map((relative) => ({
|
|
298
|
+
source: relative.split(path.sep).join("/"),
|
|
299
|
+
destination: checkpointRemoteRelativePath(provider, relative, state.repository.remoteProjectPath),
|
|
300
|
+
}));
|
|
277
301
|
writePrivate(checkpointList, Buffer.from(`${files.join("\0")}\0`, "utf8"));
|
|
278
302
|
try {
|
|
279
303
|
runCapture(process.env.IMPEL_REMOTE_TAR_BIN || "tar", [
|
|
280
304
|
"-cf", checkpointArchive, "--no-xattrs", "--null", "-T", checkpointList,
|
|
281
|
-
], { cwd: localRoot });
|
|
305
|
+
], { cwd: localRoot, env: localTarEnvironment() });
|
|
282
306
|
sshCapture(state, `mkdir -p ${remoteRoot}`);
|
|
283
307
|
runInteractive(process.env.IMPEL_REMOTE_SSH_BIN || "ssh", sshArgs(state, `tar -xf - -C ${remoteRoot}`), {
|
|
284
308
|
inputFile: checkpointArchive,
|
|
285
309
|
});
|
|
310
|
+
if (provider === "claude") {
|
|
311
|
+
remoteNode(state, String.raw`
|
|
312
|
+
const fs = require("node:fs");
|
|
313
|
+
const path = require("node:path");
|
|
314
|
+
const payload = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
315
|
+
const root = path.resolve(payload.root);
|
|
316
|
+
for (const mapping of payload.mappings) {
|
|
317
|
+
const source = path.resolve(root, mapping.source);
|
|
318
|
+
const destination = path.resolve(root, mapping.destination);
|
|
319
|
+
if (!source.startsWith(root + path.sep) || !destination.startsWith(root + path.sep)) {
|
|
320
|
+
throw new Error("unsafe checkpoint path");
|
|
321
|
+
}
|
|
322
|
+
if (source === destination) continue;
|
|
323
|
+
if (!fs.statSync(source, { throwIfNoEntry: false })?.isFile()) {
|
|
324
|
+
throw new Error("checkpoint source is missing");
|
|
325
|
+
}
|
|
326
|
+
if (fs.existsSync(destination)) throw new Error("checkpoint destination already exists");
|
|
327
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
328
|
+
fs.renameSync(source, destination);
|
|
329
|
+
let emptyParent = path.dirname(source);
|
|
330
|
+
while (emptyParent !== root && emptyParent.startsWith(root + path.sep)) {
|
|
331
|
+
try { fs.rmdirSync(emptyParent); } catch { break; }
|
|
332
|
+
emptyParent = path.dirname(emptyParent);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
`, { root: remoteRoot, mappings });
|
|
336
|
+
}
|
|
286
337
|
} finally {
|
|
287
338
|
fs.rmSync(checkpointList, { force: true });
|
|
288
339
|
fs.rmSync(checkpointArchive, { force: true });
|
package/src/selfInvocation.js
CHANGED
|
@@ -65,6 +65,7 @@ export function impelCliInvocation(args = [], options = {}) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
export const IMPEL_MANAGED_MCP_ENV = brandedEnvironmentName("MANAGED_MCP");
|
|
68
|
+
export const IMPEL_TASKS_MCP_SERVER_NAME = `${RUNTIME_BRAND.cli.providerId}-tasks`;
|
|
68
69
|
|
|
69
70
|
export function impelMcpInvocation(args = [], options = {}) {
|
|
70
71
|
return {
|
|
@@ -73,3 +74,29 @@ export function impelMcpInvocation(args = [], options = {}) {
|
|
|
73
74
|
env: { [IMPEL_MANAGED_MCP_ENV]: "1" },
|
|
74
75
|
};
|
|
75
76
|
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The tenant-bound Tasks MCP process launched by managed Claude/Codex hosts.
|
|
80
|
+
* The PAT remains in Impel's private config and is read by the child process;
|
|
81
|
+
* it must never be baked into this invocation or a vendor profile.
|
|
82
|
+
*/
|
|
83
|
+
export function impelTasksMcpInvocation(tenantId, options = {}) {
|
|
84
|
+
if (typeof tenantId !== "string" || !tenantId.trim()) {
|
|
85
|
+
throw new Error("a tenant is required for the managed Tasks MCP invocation");
|
|
86
|
+
}
|
|
87
|
+
return impelMcpInvocation(["--target", "tasks", "--tenant", tenantId], options);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Recognize only the marker-bearing Tasks invocation that impel-cli owns. */
|
|
91
|
+
export function isImpelTasksMcpInvocation(value) {
|
|
92
|
+
if (!value || typeof value !== "object" || !Array.isArray(value.args)) return false;
|
|
93
|
+
const suffix = value.args.slice(-5);
|
|
94
|
+
return value.type === "stdio"
|
|
95
|
+
&& value.env?.[IMPEL_MANAGED_MCP_ENV] === "1"
|
|
96
|
+
&& suffix[0] === "mcp"
|
|
97
|
+
&& suffix[1] === "--target"
|
|
98
|
+
&& suffix[2] === "tasks"
|
|
99
|
+
&& suffix[3] === "--tenant"
|
|
100
|
+
&& typeof suffix[4] === "string"
|
|
101
|
+
&& suffix[4].length > 0;
|
|
102
|
+
}
|