@f5-sales-demo/xcsh 21.35.2 → 21.35.4
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/package.json +8 -8
- package/src/config/auto-config.ts +7 -1
- package/src/herdr/interactions.ts +114 -20
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/modes/components/request-user-input.ts +20 -2
- package/src/remote-control/session.ts +25 -9
- package/src/session/agent-session.ts +4 -11
- package/src/session/user-interactions.ts +15 -2
- package/src/tools/request-user-input.ts +3 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "21.35.
|
|
4
|
+
"version": "21.35.4",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -63,13 +63,13 @@
|
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
65
|
"@agentclientprotocol/sdk": "1.4.0",
|
|
66
|
-
"@f5-sales-demo/pi-agent-core": "21.35.
|
|
67
|
-
"@f5-sales-demo/pi-ai": "21.35.
|
|
68
|
-
"@f5-sales-demo/pi-natives": "21.35.
|
|
69
|
-
"@f5-sales-demo/pi-resource-management": "21.35.
|
|
70
|
-
"@f5-sales-demo/pi-tui": "21.35.
|
|
71
|
-
"@f5-sales-demo/pi-utils": "21.35.
|
|
72
|
-
"@f5-sales-demo/xcsh-stats": "21.35.
|
|
66
|
+
"@f5-sales-demo/pi-agent-core": "21.35.4",
|
|
67
|
+
"@f5-sales-demo/pi-ai": "21.35.4",
|
|
68
|
+
"@f5-sales-demo/pi-natives": "21.35.4",
|
|
69
|
+
"@f5-sales-demo/pi-resource-management": "21.35.4",
|
|
70
|
+
"@f5-sales-demo/pi-tui": "21.35.4",
|
|
71
|
+
"@f5-sales-demo/pi-utils": "21.35.4",
|
|
72
|
+
"@f5-sales-demo/xcsh-stats": "21.35.4",
|
|
73
73
|
"@mozilla/readability": "^0.6",
|
|
74
74
|
"@sinclair/typebox": "0.34.52",
|
|
75
75
|
"@xterm/headless": "^6.0",
|
|
@@ -20,7 +20,7 @@ import { hardenAgentConfigFileSync, writeAgentConfigFileSync } from "./agent-con
|
|
|
20
20
|
import { DEFAULT_MODEL_ROLE } from "./settings-schema";
|
|
21
21
|
|
|
22
22
|
/** Current config schema version. Bump when the generated format changes. */
|
|
23
|
-
export const CURRENT_CONFIG_VERSION =
|
|
23
|
+
export const CURRENT_CONFIG_VERSION = 8;
|
|
24
24
|
const LITELLM_CONFIG_DIR_MODE = 0o700;
|
|
25
25
|
const LITELLM_MODELS_FILE_MODE = 0o600;
|
|
26
26
|
const GENERATED_LITELLM_MARKER = "# Auto-generated by xcsh for LiteLLM proxy";
|
|
@@ -61,6 +61,10 @@ export interface GenerateModelsYmlOptions {
|
|
|
61
61
|
/** Generate models.yml content for LiteLLM proxy. */
|
|
62
62
|
export function generateModelsYml(baseUrl: string, options?: GenerateModelsYmlOptions): string {
|
|
63
63
|
const apiBase = options?.apiBasePath ?? "/v1";
|
|
64
|
+
// LiteLLM's OpenAI-compatible chat route can live behind a custom API prefix
|
|
65
|
+
// (for example /api/v1). Astra with tools and reasoning requires the
|
|
66
|
+
// Responses API, which LiteLLM exposes on its dedicated OpenAI route.
|
|
67
|
+
const astraResponsesBaseUrl = `${baseUrl.replace(/\/+$/, "")}/openai/v1`;
|
|
64
68
|
// When a literal API key is provided, double-quote it to handle YAML-significant
|
|
65
69
|
// characters (: ! # { } [ ] ' " etc.). Escape backslashes and double quotes first.
|
|
66
70
|
const apiKeyValue = options?.apiKeyLiteral
|
|
@@ -99,6 +103,8 @@ export function generateModelsYml(baseUrl: string, options?: GenerateModelsYmlOp
|
|
|
99
103
|
" models:",
|
|
100
104
|
" - id: gpt-6-astra",
|
|
101
105
|
" name: GPT-6 Astra",
|
|
106
|
+
" api: openai-responses",
|
|
107
|
+
` baseUrl: "${astraResponsesBaseUrl}"`,
|
|
102
108
|
" picker:",
|
|
103
109
|
" groupId: litellm",
|
|
104
110
|
" groupLabel: LiteLLM",
|
|
@@ -2,6 +2,7 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
2
2
|
import type { ConversationPlan, PlanAction } from "../../../chat-ui/src/interactions/conversation-plan";
|
|
3
3
|
import type {
|
|
4
4
|
InteractionIdentity,
|
|
5
|
+
InteractionResolution,
|
|
5
6
|
UserInteraction,
|
|
6
7
|
UserInteractionEvent,
|
|
7
8
|
UserInteractions,
|
|
@@ -17,10 +18,10 @@ interface Binding {
|
|
|
17
18
|
pane_id: string;
|
|
18
19
|
producer: string;
|
|
19
20
|
generation: number;
|
|
20
|
-
|
|
21
|
-
interface Owner extends Binding {
|
|
21
|
+
/** Stable Herdr producer session; unlike the conversation thread, this cannot change in-process. */
|
|
22
22
|
session_id: string;
|
|
23
23
|
}
|
|
24
|
+
type Owner = Binding;
|
|
24
25
|
interface Target {
|
|
25
26
|
owner: Owner;
|
|
26
27
|
request_id: string;
|
|
@@ -30,6 +31,9 @@ interface Entry {
|
|
|
30
31
|
identity: InteractionIdentity;
|
|
31
32
|
report: Record<string, unknown>;
|
|
32
33
|
decide?: (action: PlanAction) => Promise<{ accepted: boolean }>;
|
|
34
|
+
questionRequests?: Map<string, string>;
|
|
35
|
+
pendingRequestIds?: Set<string>;
|
|
36
|
+
resolutions?: InteractionResolution[];
|
|
33
37
|
}
|
|
34
38
|
const record = (value: unknown): value is Record<string, unknown> =>
|
|
35
39
|
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -37,6 +41,7 @@ const record = (value: unknown): value is Record<string, unknown> =>
|
|
|
37
41
|
/** Additional observation never owns completion or disables the local interaction path. */
|
|
38
42
|
export class HerdrInteractionBridge {
|
|
39
43
|
#entries = new Map<string, Entry>();
|
|
44
|
+
#entryByInteraction = new Map<string, string>();
|
|
40
45
|
#reports: Record<string, unknown>[] = [];
|
|
41
46
|
#acks: Record<string, unknown>[] = [];
|
|
42
47
|
#decisions = new Map<string, { action: unknown; accepted: boolean }>();
|
|
@@ -65,46 +70,102 @@ export class HerdrInteractionBridge {
|
|
|
65
70
|
#observe(event: UserInteractionEvent): void {
|
|
66
71
|
if (event.type === "opened") this.#open(event.interaction, event.revision);
|
|
67
72
|
else {
|
|
68
|
-
const
|
|
73
|
+
const key = this.#entryByInteraction.get(event.interaction.id) ?? event.interaction.id;
|
|
74
|
+
const entry = this.#entries.get(key);
|
|
69
75
|
if (!entry) return;
|
|
70
|
-
entry.
|
|
71
|
-
|
|
76
|
+
if (entry.pendingRequestIds) {
|
|
77
|
+
entry.pendingRequestIds.delete(event.interaction.id);
|
|
78
|
+
entry.resolutions?.push(event.reason ?? "owner_lost");
|
|
79
|
+
if (entry.pendingRequestIds.size) return;
|
|
80
|
+
}
|
|
81
|
+
const resolutions = entry.resolutions ?? [event.reason ?? "owner_lost"];
|
|
82
|
+
const state = resolutions.every(reason => reason === "answered")
|
|
83
|
+
? "answered"
|
|
84
|
+
: (resolutions.find(reason => reason !== "answered") ?? "owner_lost");
|
|
85
|
+
entry.report = { ...entry.report, event_revision: event.revision, state };
|
|
86
|
+
this.#queueReport(entry.report);
|
|
72
87
|
}
|
|
73
88
|
}
|
|
74
89
|
#open(request: UserInteraction, revision: number): void {
|
|
75
90
|
const identity = request.identity;
|
|
76
91
|
if (!identity || (request.kind !== "request_user_input" && request.delivery !== "async")) return;
|
|
77
|
-
const
|
|
92
|
+
const batch = request.delivery === "async" ? request.asyncBatch : undefined;
|
|
93
|
+
const requestId = batch?.requestId ?? request.id;
|
|
94
|
+
const existing = this.#entries.get(requestId);
|
|
95
|
+
if (existing?.questionRequests && request.questionId) {
|
|
96
|
+
existing.questionRequests.set(request.questionId, request.id);
|
|
97
|
+
existing.pendingRequestIds?.add(request.id);
|
|
98
|
+
this.#entryByInteraction.set(request.id, requestId);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (this.#entries.size >= 64) {
|
|
102
|
+
this.onError(new Error("Herdr interaction bridge capacity exceeded"));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const target = { owner: { ...this.binding }, request_id: requestId };
|
|
106
|
+
const asyncQuestions = batch?.questions ?? [
|
|
107
|
+
{ title: request.title, ...(request.options ? { options: [...request.options] } : {}) },
|
|
108
|
+
];
|
|
109
|
+
const asyncQuestionIds = batch?.questionIds ?? [request.questionId ?? identity.itemId];
|
|
110
|
+
const asyncItem = batch?.item ?? {
|
|
111
|
+
id: identity.itemId,
|
|
112
|
+
type: "agentMessage" as const,
|
|
113
|
+
text: asyncQuestions
|
|
114
|
+
.map(question => [question.title, ...(question.options?.map(option => `- ${option}`) ?? [])].join("\n"))
|
|
115
|
+
.join("\n\n"),
|
|
116
|
+
phase: "final_answer" as const,
|
|
117
|
+
delivery: "async" as const,
|
|
118
|
+
questions: asyncQuestions,
|
|
119
|
+
};
|
|
78
120
|
const report = {
|
|
79
121
|
target,
|
|
80
122
|
thread_id: identity.threadId,
|
|
81
123
|
turn_id: identity.turnId,
|
|
82
124
|
item_id: identity.itemId,
|
|
83
|
-
question_ids: request.inputQuestions?.map(question => question.id) ??
|
|
125
|
+
question_ids: request.inputQuestions?.map(question => question.id) ?? asyncQuestionIds,
|
|
84
126
|
event_revision: revision,
|
|
85
127
|
kind: request.delivery === "async" ? "async" : "waiting",
|
|
86
128
|
state: "pending",
|
|
87
129
|
payload:
|
|
88
130
|
request.delivery === "async"
|
|
89
|
-
?
|
|
90
|
-
id: identity.itemId,
|
|
91
|
-
type: "agentMessage",
|
|
92
|
-
text: request.title,
|
|
93
|
-
phase: "final_answer",
|
|
94
|
-
delivery: "async",
|
|
95
|
-
questions: [{ title: request.title, ...(request.options ? { options: request.options } : {}) }],
|
|
96
|
-
}
|
|
131
|
+
? asyncItem
|
|
97
132
|
: { questions: request.inputQuestions, isBlocking: true, autoResolutionMs: null },
|
|
98
133
|
};
|
|
99
|
-
|
|
134
|
+
const entry: Entry = { target, identity: structuredClone(identity), report };
|
|
135
|
+
if (request.delivery === "async") {
|
|
136
|
+
entry.questionRequests = new Map([[request.questionId ?? identity.itemId, request.id]]);
|
|
137
|
+
entry.pendingRequestIds = new Set([request.id]);
|
|
138
|
+
entry.resolutions = [];
|
|
139
|
+
}
|
|
140
|
+
this.#entries.set(requestId, entry);
|
|
141
|
+
this.#entryByInteraction.set(request.id, requestId);
|
|
142
|
+
this.#queueReport(report);
|
|
143
|
+
}
|
|
144
|
+
#queueReport(report: Record<string, unknown>): void {
|
|
145
|
+
const target = JSON.stringify(report.target);
|
|
146
|
+
const state = report.state;
|
|
147
|
+
const index = this.#reports.findIndex(
|
|
148
|
+
queued =>
|
|
149
|
+
JSON.stringify(queued.target) === target &&
|
|
150
|
+
(state === "pending" ? queued.state === "pending" : queued.state !== "pending"),
|
|
151
|
+
);
|
|
152
|
+
if (index >= 0) {
|
|
153
|
+
this.#reports[index] = structuredClone(report);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
100
156
|
this.#reports.push(structuredClone(report));
|
|
157
|
+
if (this.#reports.length > 128) this.onError(new Error("Herdr interaction report queue capacity exceeded"));
|
|
101
158
|
}
|
|
102
159
|
plan(
|
|
103
160
|
plan: ConversationPlan,
|
|
104
161
|
identity: InteractionIdentity,
|
|
105
162
|
decide: (action: PlanAction) => Promise<{ accepted: boolean }>,
|
|
106
163
|
): void {
|
|
107
|
-
|
|
164
|
+
if (!this.#entries.has(plan.id) && this.#entries.size >= 64) {
|
|
165
|
+
this.onError(new Error("Herdr interaction bridge capacity exceeded"));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const target = { owner: { ...this.binding }, request_id: plan.id };
|
|
108
169
|
const report = {
|
|
109
170
|
target,
|
|
110
171
|
thread_id: identity.threadId,
|
|
@@ -117,13 +178,13 @@ export class HerdrInteractionBridge {
|
|
|
117
178
|
state: "pending",
|
|
118
179
|
};
|
|
119
180
|
this.#entries.set(plan.id, { target, identity, report, decide });
|
|
120
|
-
this.#
|
|
181
|
+
this.#queueReport(report);
|
|
121
182
|
}
|
|
122
183
|
resolvePlan(planId: string): void {
|
|
123
184
|
const entry = this.#entries.get(planId);
|
|
124
185
|
if (!entry) return;
|
|
125
186
|
entry.report = { ...entry.report, event_revision: Number(entry.report.event_revision) + 1, state: "answered" };
|
|
126
|
-
this.#
|
|
187
|
+
this.#queueReport(entry.report);
|
|
127
188
|
}
|
|
128
189
|
flush(): Promise<void> {
|
|
129
190
|
if (this.#closed) return Promise.resolve();
|
|
@@ -152,6 +213,10 @@ export class HerdrInteractionBridge {
|
|
|
152
213
|
});
|
|
153
214
|
if (response.type !== "agent_interaction") throw new Error("Herdr did not acknowledge the interaction report");
|
|
154
215
|
this.#reports.shift();
|
|
216
|
+
if (report.state !== "pending" && record(report.target)) {
|
|
217
|
+
const requestId = report.target.request_id;
|
|
218
|
+
if (typeof requestId === "string") this.#dropEntry(requestId);
|
|
219
|
+
}
|
|
155
220
|
}
|
|
156
221
|
const owners = new Map(
|
|
157
222
|
[...this.#entries.values()]
|
|
@@ -184,8 +249,23 @@ export class HerdrInteractionBridge {
|
|
|
184
249
|
typeof delivery.answer === "string" &&
|
|
185
250
|
["implement", "fresh", "stay"].includes(delivery.answer) &&
|
|
186
251
|
(await entry.decide(delivery.answer as PlanAction)).accepted;
|
|
187
|
-
this.#
|
|
252
|
+
this.#rememberDecision(receipt.response_id as string, delivery.answer, accepted);
|
|
188
253
|
}
|
|
254
|
+
} else if (entry.questionRequests) {
|
|
255
|
+
const answer = record(delivery.answer) ? delivery.answer : undefined;
|
|
256
|
+
const questionId = answer?.questionId;
|
|
257
|
+
const value = answer?.answer;
|
|
258
|
+
const interactionId =
|
|
259
|
+
typeof questionId === "string" ? entry.questionRequests.get(questionId) : undefined;
|
|
260
|
+
accepted =
|
|
261
|
+
typeof interactionId === "string" &&
|
|
262
|
+
typeof value === "string" &&
|
|
263
|
+
this.interactions.respondExternal(
|
|
264
|
+
interactionId,
|
|
265
|
+
receipt.response_id as string,
|
|
266
|
+
value,
|
|
267
|
+
entry.identity,
|
|
268
|
+
);
|
|
189
269
|
} else
|
|
190
270
|
accepted = this.interactions.respondExternal(
|
|
191
271
|
target.request_id,
|
|
@@ -204,6 +284,20 @@ export class HerdrInteractionBridge {
|
|
|
204
284
|
}
|
|
205
285
|
this.#failureReported = false;
|
|
206
286
|
}
|
|
287
|
+
#dropEntry(requestId: string): void {
|
|
288
|
+
const entry = this.#entries.get(requestId);
|
|
289
|
+
if (!entry) return;
|
|
290
|
+
for (const interactionId of entry.questionRequests?.values() ?? [])
|
|
291
|
+
this.#entryByInteraction.delete(interactionId);
|
|
292
|
+
this.#entries.delete(requestId);
|
|
293
|
+
}
|
|
294
|
+
#rememberDecision(responseId: string, action: unknown, accepted: boolean): void {
|
|
295
|
+
this.#decisions.set(responseId, { action, accepted });
|
|
296
|
+
for (const [oldestResponseId] of this.#decisions) {
|
|
297
|
+
if (this.#decisions.size <= 256) break;
|
|
298
|
+
this.#decisions.delete(oldestResponseId);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
207
301
|
async #flushAcknowledgements(): Promise<void> {
|
|
208
302
|
while (this.#acks.length) {
|
|
209
303
|
const ack = await this.client.request("agent.interaction.delivery.ack", this.#acks[0]);
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "21.35.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "21.35.4",
|
|
21
|
+
"commit": "f093838a50d8443de241a571ffd2971cf2c9ede4",
|
|
22
|
+
"shortCommit": "f093838",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v21.35.
|
|
25
|
-
"commitDate": "2026-09-
|
|
26
|
-
"buildDate": "2026-09-
|
|
24
|
+
"tag": "v21.35.4",
|
|
25
|
+
"commitDate": "2026-09-21T02:18:46+00:00",
|
|
26
|
+
"buildDate": "2026-09-21T03:22:24.221Z",
|
|
27
27
|
"dirty": true,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5-sales-demo/xcsh",
|
|
30
30
|
"repoSlug": "f5-sales-demo/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v21.35.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/f093838a50d8443de241a571ffd2971cf2c9ede4",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v21.35.4"
|
|
33
33
|
};
|
|
@@ -28,7 +28,7 @@ export const EMBEDDED_DOCS: Readonly<Record<string, string>> = {
|
|
|
28
28
|
"en/automate-extend/skills.mdx": "---\ntitle: \"Create and load skills\"\ndescription: \"Package task-specific instructions in `SKILL.md` files at user or project scope.\"\nsidebar:\n order: 5\n label: \"Create and load skills\"\n---\n\nA skill is a directory centered on `SKILL.md`. It tells an agent when and how to perform a bounded kind of work; it is not executable proof by itself.\n\n## Where do skills live?\n\nUse project scope under `.xcsh/skills/` for repository-specific instructions and user scope under the xcsh data directory for personal skills. Project instructions should be reviewable with the repository.\n\n## How do I test one?\n\nStart xcsh with `--skills <GLOB>` to narrow discovery, invoke a prompt that matches the trigger, and inspect the tools and output. Use `--no-skills` to confirm behavior without it.\n\nGive `SKILL.md` a narrow trigger, explicit prerequisites, ordered actions, and a verifiable stopping\ncondition. Keep executable helpers inside the skill directory and invoke them through their\ndocumented interface rather than embedding machine-specific paths. Test positive and negative\nprompts with only that skill selected, inspect which instructions were loaded, then remove the\ntemporary skill and confirm the trigger no longer matches.\n",
|
|
29
29
|
"en/configure-secure/environment-reference.mdx": "---\ntitle: \"Environment variable reference\"\ndescription: \"Look up the environment variables that select models, credentials, tenants, execution behavior, storage roots, and diagnostics.\"\nsidebar:\n order: 2\n label: \"Environment variables\"\n---\n\nEnvironment variables carry credentials and process-scoped overrides. Settings files and command flags keep their documented precedence; a similarly named variable is not automatically supported. Provider adapters read their own vendor key names, which the adapter defines rather than this list.\n\n## Which variables configure models, providers, and search?\n\n| Variable | Controls |\n| --- | --- |\n| `PI_SMOL_MODEL` | Default model for the lightweight role, equivalent to `--smol` |\n| `PI_SLOW_MODEL` | Default model for the reasoning role, equivalent to `--slow` |\n| `PI_PLAN_MODEL` | Default model for the planning role, equivalent to `--plan` |\n| `XCSH_LOCALE` | Interface locale, ahead of `PI_LOCALE` and `LANG` |\n\nA flag beats the variable: `--smol` overrides `PI_SMOL_MODEL` for that invocation. Model selection stays a model-registry decision, so a name these variables set must still resolve through `xcsh --list-models <PROVIDER>`.\n\n## Which variables address an F5 Distributed Cloud tenant?\n\n| Variable | Controls |\n| --- | --- |\n| `XCSH_API_URL` | Tenant application programming interface (API) endpoint when no named context is active |\n| `XCSH_API_TOKEN` | Tenant API credential for that endpoint |\n| `XCSH_TENANT` | Tenant identifier reported in context status |\n| `XCSH_CONTEXT_NAME` | Name of the context the session binds to |\n| `XCSH_NAMESPACE` | Namespace applied when a command does not pass `-n` |\n| `F5XC_NAMESPACE` | Namespace source recorded in resource operation records |\n\nTreat every value in this group as a secret. Supply them through the runtime environment or a named context, never through a committed file or a command argument that reaches shell history.\n\n## Which variables configure Python, shell, and tool execution?\n\n| Variable | Controls |\n| --- | --- |\n| `PI_PYTHON_GATEWAY_URL` | Endpoint the Python tool sends code to |\n| `PI_PYTHON_GATEWAY_TOKEN` | Credential for that gateway |\n| `PI_PYTHON_SKIP_CHECK` | Skips the gateway warm-up probe |\n| `PI_SHELL_PREFIX` | Command prefix wrapped around shell tool invocations |\n| `PI_BASH_NO_LOGIN` | Runs the shell without `-l` |\n| `PI_NO_PTY` | Disables pseudo-terminal (PTY) execution, equivalent to `--no-pty` |\n| `PI_EDIT_VARIANT` | Selects the edit tool implementation and rejects an unknown value |\n\nShell execution inherits the launched process environment, subject to the sandbox and command policy. Restrict dependencies, working directory, and credentials at the process boundary rather than in the prompt.\n\n## Which variables configure storage, runtime, and diagnostics?\n\n| Variable | Controls |\n| --- | --- |\n| `PI_CODING_AGENT_DIR` | Primary agent data root, including session storage |\n| `PI_CONFIG_DIR` | Configuration root directory name, `.xcsh` by default |\n| `PI_PACKAGE_DIR` | Packaged asset directory the runtime resolves against |\n| `PI_SESSION_FILE` | Session file a child process attaches to |\n| `PI_NATIVE_VARIANT` | Hardware variant the native addon loader prefers |\n| `PI_CACHE_RETENTION` | Cache retention policy, `short` by default |\n| `PI_NO_TITLE` | Disables title auto-generation, equivalent to `--no-title` |\n| `PI_NOTIFICATIONS` | Desktop notification behavior |\n| `PI_DEBUG_STARTUP` | Startup phase diagnostics |\n| `PI_TIMING` | Phase timing output |\n| `PI_TUI_DEBUG` | Terminal user interface (TUI) rendering diagnostics |\n\nProject configuration stays under `.xcsh/` even when `PI_CODING_AGENT_DIR` moves the user root. Never print a credential-bearing variable into a diagnostic receipt; name the variable and state whether it was set.\n\nSet a variable only in the process that needs it, then use the relevant read-only command or a\nno-tool prompt to confirm selection. Environment values can override files and may be inherited by\nsubprocesses; never print tokens while diagnosing precedence. To undo a test, unset the variable in\nthe same shell and start a new xcsh process because an existing process has already captured its\nenvironment.\n",
|
|
30
30
|
"en/configure-secure/index.mdx": "---\ntitle: \"Configure and secure\"\ndescription: \"Control settings, providers, credentials, networking, secrets, sandboxing, and runtime boundaries.\"\nsidebar:\n order: 0\n label: \"Overview\"\n---\n\nConfiguration controls provider routing, settings precedence, filesystem access, and credential handling.\n\n## What should I configure first?\n\n1. Inspect [settings precedence and file locations](/xcsh/en/configure-secure/settings-files/).\n2. Look up any [environment variable](/xcsh/en/configure-secure/environment-reference/) the deployment sets.\n3. Choose a [provider and model route](/xcsh/en/configure-secure/providers-routing/).\n4. Apply [secret handling and the filesystem boundary](/xcsh/en/configure-secure/secrets-obfuscation/).\n5. Set the [sandbox boundaries](/xcsh/en/configure-secure/sandbox-boundaries/) that enforce those decisions.\n\nStart with defaults; add project configuration only when collaborators need the same behavior.\n",
|
|
31
|
-
"en/configure-secure/providers-routing.mdx": "---\ntitle: \"Configure providers and model routing\"\ndescription: \"Select models directly or map default, smol, slow, and plan roles.\"\nsidebar:\n order: 3\n label: \"Providers and models\"\nhead:\n - tag: style\n content: |\n .sl-markdown-content .expressive-code { max-width: 100%; overflow-x: auto; }\n .sl-markdown-content table { display: block; max-width: 100%; overflow-x: auto; }\n---\n\nxcsh resolves a requested model against available providers and credentials. A provider-qualified model name is the most explicit route.\n\n## How do I confirm a route?\n\nRun `xcsh --list-models <PROVIDER>`, then pass one returned name to `--model`. Environment keys and supported subscription sessions authenticate providers; never store tokens in committed settings.\n\n## How do I select a model in a conversation?\n\nOpen `/model`. Tab and Shift+Tab browse providers; typing searches across them. The active conversation model and saved role badges are shown separately. Configured providers remain visible when discovery is empty, unavailable, or requires authentication. Use Ctrl+R to refresh the current provider or Ctrl+L to open login.\n\nChoose a model with Enter, then choose its scope:\n\n- **Use in this conversation** is preselected. It changes this session, including resume, without changing saved role assignments.\n- **Save as default** changes the current conversation and the default for future sessions.\n- **Assign to role** saves Default, Fast/SMOL, Thorough/SLOW, Plan, or another existing role. Roles other than Default leave the current conversation unchanged.\n\nChoose a reasoning level supported by that exact model and press Enter to confirm. Inherit displays the provider default; an existing supported reasoning selection is preselected. Escape backs out without applying an unfinished choice. Failed writes are reported before badges show success.\n\nClaude Fable 5 and 5.1 use adaptive thinking on every request. Their default is `high`; supported\nchoices are `low`, `medium`, `high`, `xhigh`, and `max`, with xcsh's `minimal` choice mapped to\nAnthropic `low`. `off` is rejected because the Fable API does not support disabling thinking.\nFable also does not accept temperature or forced tool selection, so xcsh omits temperature and\nnormalizes `any` or a named forced tool to `auto` while retaining strict tool schemas.\n\nWhen the authenticated ChatGPT subscription catalog includes GPT-6 Astra, `/model` presents it as an additional premium option under **ChatGPT Subscription**. Astra does not replace the existing Luna, Terra, Sol, or role assignments unless you explicitly assign it.\n\nA manual conversation choice remains in effect under automatic routing. Entering planning mode uses the Plan assignment; exiting restores the prior conversation model and reasoning. Explicit `--models` scopes still limit the picker.\n\nOpen `/login` to see providers that are configured, credential-backed, allowlisted, active, or freshly detected. Optional local runtimes do not appear merely because xcsh attempted an automatic probe. Choose `Add provider…` to search the complete built-in catalog.\n\nProvider rows use human status labels. Press Right on a row to inspect its credential source, last verification, sanitized failure reason, model visibility, and any grouped routes. `/logout` lists only providers with stored credentials that xcsh can remove; environment and configuration credentials are not presented as removable.\n\nProvider grouping in `models.yml` is also used by the management view:\n\n```yaml\nproviders:\n litellm:\n picker:\n groupId: litellm\n groupLabel: LiteLLM\n sectionLabel: OpenAI\n modelAllowlist:\n - gpt-6-astra\n - gpt-5.6-sol\n - gpt-5.6-terra\n - gpt-5.6-luna\n anthropic:\n picker:\n groupId: litellm\n groupLabel: LiteLLM\n sectionLabel: Anthropic\n modelAllowlist:\n - claude-fable-5-1\n - claude-fable-5\n - claude-opus-5\n - claude-sonnet-5\n - claude-haiku-4-5\n```\n\n`modelProviderAllowlist` is a settings key that limits normal `/model` visibility. An empty list means no picker restriction; it does not make every built-in provider relevant in `/login`. Explicit provider-qualified command-line selection and direct `/login <provider>` remain available.\n\n```bash\nxcsh config set modelProviderAllowlist '[\"litellm\", \"anthropic\"]'\nxcsh config set modelProviderOrder '[\"litellm\", \"anthropic\", \"google-vertex\"]'\n```\n\nFor command-line selection, qualify the provider whenever several usable providers expose the same model ID. A bare `--model` value succeeds only when one usable provider matches; otherwise xcsh prints the qualified choices and exits nonzero in noninteractive mode.\n\nThe two explicit Fable aliases are an exception to general fuzzy selection:\n\n```bash\nxcsh --model fable\nxcsh --model anthropic/fable\n```\n\nBoth select `anthropic/claude-fable-5-1` deterministically.\n\n## Where does LiteLLM fit?\n\nLiteLLM is an optional provider proxy installed or checked with `xcsh setup litellm`. Its base uniform resource locator (URL) and credential belong in the runtime environment. Successful text Responses support does not imply Realtime or audio support; test the exact endpoint your workflow uses.\n\nGPT-6 Astra has route-specific limits. xcsh-generated LiteLLM configuration exposes the model's full\n1,050,000-token context window (922,000 input plus up to 128,000 output).
|
|
31
|
+
"en/configure-secure/providers-routing.mdx": "---\ntitle: \"Configure providers and model routing\"\ndescription: \"Select models directly or map default, smol, slow, and plan roles.\"\nsidebar:\n order: 3\n label: \"Providers and models\"\nhead:\n - tag: style\n content: |\n .sl-markdown-content .expressive-code { max-width: 100%; overflow-x: auto; }\n .sl-markdown-content table { display: block; max-width: 100%; overflow-x: auto; }\n---\n\nxcsh resolves a requested model against available providers and credentials. A provider-qualified model name is the most explicit route.\n\n## How do I confirm a route?\n\nRun `xcsh --list-models <PROVIDER>`, then pass one returned name to `--model`. Environment keys and supported subscription sessions authenticate providers; never store tokens in committed settings.\n\n## How do I select a model in a conversation?\n\nOpen `/model`. Tab and Shift+Tab browse providers; typing searches across them. The active conversation model and saved role badges are shown separately. Configured providers remain visible when discovery is empty, unavailable, or requires authentication. Use Ctrl+R to refresh the current provider or Ctrl+L to open login.\n\nChoose a model with Enter, then choose its scope:\n\n- **Use in this conversation** is preselected. It changes this session, including resume, without changing saved role assignments.\n- **Save as default** changes the current conversation and the default for future sessions.\n- **Assign to role** saves Default, Fast/SMOL, Thorough/SLOW, Plan, or another existing role. Roles other than Default leave the current conversation unchanged.\n\nChoose a reasoning level supported by that exact model and press Enter to confirm. Inherit displays the provider default; an existing supported reasoning selection is preselected. Escape backs out without applying an unfinished choice. Failed writes are reported before badges show success.\n\nClaude Fable 5 and 5.1 use adaptive thinking on every request. Their default is `high`; supported\nchoices are `low`, `medium`, `high`, `xhigh`, and `max`, with xcsh's `minimal` choice mapped to\nAnthropic `low`. `off` is rejected because the Fable API does not support disabling thinking.\nFable also does not accept temperature or forced tool selection, so xcsh omits temperature and\nnormalizes `any` or a named forced tool to `auto` while retaining strict tool schemas.\n\nWhen the authenticated ChatGPT subscription catalog includes GPT-6 Astra, `/model` presents it as an additional premium option under **ChatGPT Subscription**. Astra does not replace the existing Luna, Terra, Sol, or role assignments unless you explicitly assign it.\n\nA manual conversation choice remains in effect under automatic routing. Entering planning mode uses the Plan assignment; exiting restores the prior conversation model and reasoning. Explicit `--models` scopes still limit the picker.\n\nOpen `/login` to see providers that are configured, credential-backed, allowlisted, active, or freshly detected. Optional local runtimes do not appear merely because xcsh attempted an automatic probe. Choose `Add provider…` to search the complete built-in catalog.\n\nProvider rows use human status labels. Press Right on a row to inspect its credential source, last verification, sanitized failure reason, model visibility, and any grouped routes. `/logout` lists only providers with stored credentials that xcsh can remove; environment and configuration credentials are not presented as removable.\n\nProvider grouping in `models.yml` is also used by the management view:\n\n```yaml\nproviders:\n litellm:\n picker:\n groupId: litellm\n groupLabel: LiteLLM\n sectionLabel: OpenAI\n modelAllowlist:\n - gpt-6-astra\n - gpt-5.6-sol\n - gpt-5.6-terra\n - gpt-5.6-luna\n anthropic:\n picker:\n groupId: litellm\n groupLabel: LiteLLM\n sectionLabel: Anthropic\n modelAllowlist:\n - claude-fable-5-1\n - claude-fable-5\n - claude-opus-5\n - claude-sonnet-5\n - claude-haiku-4-5\n```\n\n`modelProviderAllowlist` is a settings key that limits normal `/model` visibility. An empty list means no picker restriction; it does not make every built-in provider relevant in `/login`. Explicit provider-qualified command-line selection and direct `/login <provider>` remain available.\n\n```bash\nxcsh config set modelProviderAllowlist '[\"litellm\", \"anthropic\"]'\nxcsh config set modelProviderOrder '[\"litellm\", \"anthropic\", \"google-vertex\"]'\n```\n\nFor command-line selection, qualify the provider whenever several usable providers expose the same model ID. A bare `--model` value succeeds only when one usable provider matches; otherwise xcsh prints the qualified choices and exits nonzero in noninteractive mode.\n\nThe two explicit Fable aliases are an exception to general fuzzy selection:\n\n```bash\nxcsh --model fable\nxcsh --model anthropic/fable\n```\n\nBoth select `anthropic/claude-fable-5-1` deterministically.\n\n## Where does LiteLLM fit?\n\nLiteLLM is an optional provider proxy installed or checked with `xcsh setup litellm`. Its base uniform resource locator (URL) and credential belong in the runtime environment. Successful text Responses support does not imply Realtime or audio support; test the exact endpoint your workflow uses.\n\nGPT-6 Astra has route-specific limits. xcsh-generated LiteLLM configuration exposes the model's full\n1,050,000-token context window (922,000 input plus up to 128,000 output). xcsh uses the ChatGPT Codex\nsubscription catalog's 272,000-token effective context window and 128,000-token maximum output. That\ncatalog also advertises an 872,000-token maximum context window, which can be available when the Codex\ntransport expands its effective window. Both routes support image input and `low`, `medium`, `high`,\n`xhigh`, and `max` reasoning. Astra is selectable explicitly but is not assigned to an automatic role by\ndefault.\n\nGenerated LiteLLM configuration routes only Astra through LiteLLM's OpenAI Responses endpoint\n(`/openai/v1/responses`). This is required when Astra uses tools with reasoning: its Chat Completions route\nrejects that combination. Luna, Terra, and Sol keep the configured OpenAI-compatible Chat Completions base\npath. Astra also omits temperature because the Responses route rejects sampling temperature for that model.\n\n## How do I test licensed Vertex locally?\n\n`bun run dev` and local coding-agent binary builds load the licensed Vertex OAuth client pair automatically. Explicit `XCSH_VERTEX_OAUTH_CLIENT_ID` and `XCSH_VERTEX_OAUTH_CLIENT_SECRET` build inputs take precedence; supply both together.\n\nFor local UAT, the first launch can recover the embedded client pair from an installed official xcsh binary\nand retain it in `$XDG_CONFIG_HOME/xcsh/vertex-build.json` (default `~/.config/xcsh/vertex-build.json`). The\nfile is outside the checkout, shared by local sessions and worktrees for the same operating-system user, and\ncreated with owner-only permissions. Each worktree needs this development/build launcher to load it\nautomatically. Later launches reuse it even if the installed binary changes.\n`XCSH_VERTEX_OAUTH_CREDENTIALS_FILE` selects another private local file containing `clientId` and\n`clientSecret` fields.\n\nThe source-runtime preload keeps the pair in process memory; local compiled candidates embed it. Do not copy\nthis file or its values into source control, logs, or UAT reports. CI does not read or recover workstation\ncredentials: official release builds continue to receive the pair through GitHub secrets. Unit tests use\nfixture credentials; the live provider smoke script uses the same local preload as development UAT.\n\nRoute selection is complete only when the provider, account, and model all match the intended\nboundary. Use `/model` in the TUI or the explicit model flag shown by `xcsh --help`, send a no-tool\nidentity prompt, and inspect the session metadata. Switching a model affects subsequent turns; it\ndoes not rewrite earlier entries. Remove temporary route overrides and start a new session to verify\nthe default path independently.\n",
|
|
32
32
|
"en/configure-secure/sandbox-boundaries.mdx": "---\ntitle: \"Set sandbox boundaries\"\ndescription: \"Constrain filesystem, shell, native, browser, and extension effects at the layer that enforces them.\"\nsidebar:\n order: 5\n label: \"Sandbox boundaries\"\n---\n\nA model instruction is not a sandbox. Enforcement belongs to the tool, host process, container, operating system, or remote service that performs the action.\n\n## How do I restrict the exposed tool set?\n\nStart with `--no-tools` or an explicit `--tools` allow-list when a run needs only conversation. Disable Model Context Protocol (MCP) and extensions separately because they can register additional capabilities. Review custom tool and hook code with the same care as any locally executed module.\n\n## How do I constrain files and processes?\n\nFilesystem-aware tools resolve paths against their configured workspace boundary. The Bash runtime applies its sandbox and output limits before returning a result; large output may spill to an artifact instead of remaining inline. Containers add process and filesystem isolation, but mounted credentials and sockets still cross that boundary.\n\n## Where does remote authority differ from local authority?\n\nBrowser, editor, Office, and remote-service integrations enforce different permission models. Confirm the active host, account, context, namespace, and target immediately before a mutation. A successful read proves connectivity, not authorization for a write.\n\nSandboxing constrains the local tool implementation; it does not grant remote authority and it\ncannot retract a side effect already accepted by an external service. Begin with no tools, add the\nminimum file or process capability, and test an allowed path plus a denied path. Verify denial at\nthe tool boundary and inspect the filesystem or process table for absence of change. Restore the\nnarrower configuration after the test.\n",
|
|
33
33
|
"en/configure-secure/secrets-obfuscation.mdx": "---\ntitle: \"Resolve secrets safely\"\ndescription: \"Reference sensitive values without placing plaintext credentials in prompts, manifests, logs, or commits.\"\nsidebar:\n order: 4\n label: \"Resolve secrets safely\"\n---\n\nKeep provider keys and F5 Distributed Cloud contexts outside prompts, session exports, logs, and committed files. Pass secrets through supported environment or protected runtime files.\n\n## How does obfuscation work?\n\nWhen secret obfuscation is enabled, values declared in the protected `secrets.yml` source are resolved at runtime and replaced in observable text before it is retained or rendered. Environment values take their documented precedence over file-backed values.\n\nObfuscation reduces accidental disclosure in supported paths; it is not encryption for a compromised process or an excuse to place secrets in prompts.\n\n## How does the filesystem boundary work?\n\nxcsh discovers a task root and guards access outside it. Add an explicit path with `--allow-path`; use `--allow-home` only when the home directory is intentionally the task root. `--no-sandbox` removes that guard and should be reserved for a controlled diagnostic.\n\n## How do I inspect the boundary?\n\nRun `xcsh sandbox check`, then start a read-only session with the smallest required `--allow-path` set. A sandbox boundary limits file discovery; it does not replace operating-system permissions or tool review.\n\nRedaction reduces accidental disclosure in model and display paths; it is not encryption and does\nnot make a committed secret safe. Test with a synthetic value in an isolated process, inspect the\nprompt, tool result, logs, and exported session, then unset the value and remove the temporary rule.\nIf any surface retains the literal, stop before using a real credential and correct the boundary\nthat emitted it.\n",
|
|
34
34
|
"en/configure-secure/settings-files.mdx": "---\ntitle: \"Understand settings precedence\"\ndescription: \"Predict which user, project, environment, and command-line value xcsh resolves.\"\nsidebar:\n order: 1\n label: \"Settings precedence\"\n---\n\nProject settings apply to one repository; user settings apply across projects; command flags override both for one invocation. Use `xcsh config list --json` to inspect effective values.\n\n## How is configuration resolved?\n\nDiscovery collects fixed user, project, environment, and command-line sources, normalizes each through the schema-backed configuration wrapper, then resolves values by explicit priority. Capability-specific discovery uses the same roots but owns its own merge and deduplication rules. Native `.xcsh` providers participate through the documented provider interface, not an implicit recursive scan.\n\n## Where are settings stored?\n\nRun `xcsh config path` instead of assuming a platform path. Project data uses `.xcsh/`, including `settings.json`, `mcp.json`, contexts, skills, agents, extensions, tools, hooks, commands, and `XCSH.md`. Primary user data lives under the xcsh agent directory; `PI_CODING_AGENT_DIR` can override that location.\n\n## How do I change one value?\n\n```bash\nxcsh config get <KEY>\nxcsh config set <KEY> <VALUE>\nxcsh config reset <KEY>\n```\n\nUse environment variables for credentials and ephemeral process configuration. Do not copy an entire old settings file when only one key is needed.\n\nChange one key in the narrowest applicable scope and restart xcsh before judging precedence. Use `/settings` or a schema-backed read path to inspect the effective value; do not infer it from the file you edited. Project settings can affect collaborators, while user settings affect other worktrees on the same account. Revert the test key and confirm a new process resolves the prior value.\n",
|
|
@@ -3,6 +3,16 @@ import { INPUT_COPY, type InputQuestion, type InputResponse } from "../../../../
|
|
|
3
3
|
import { QuestionForm } from "../../../../chat-ui/src/interactions/question-form";
|
|
4
4
|
import { getEditorTheme } from "../theme/theme";
|
|
5
5
|
|
|
6
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
7
|
+
|
|
8
|
+
function maskedEditorLines(text: string, width: number): string[] {
|
|
9
|
+
const masked = text
|
|
10
|
+
.split("\n")
|
|
11
|
+
.map(line => [...graphemes.segment(line)].map(() => "*").join(""))
|
|
12
|
+
.join("\n");
|
|
13
|
+
return masked.split("\n").flatMap(line => wrapTextWithAnsi(line, Math.max(1, width)));
|
|
14
|
+
}
|
|
15
|
+
|
|
6
16
|
/** Uses the ordinary multiline composer; form state stays local until explicit submission. */
|
|
7
17
|
export class RequestUserInputComponent implements Component {
|
|
8
18
|
readonly form: QuestionForm;
|
|
@@ -63,7 +73,11 @@ export class RequestUserInputComponent implements Component {
|
|
|
63
73
|
add("");
|
|
64
74
|
add(this.form.options.length ? INPUT_COPY.notes : INPUT_COPY.answer);
|
|
65
75
|
this.#editor.setMaxHeight(Math.max(3, (this.tui.terminal.rows || 24) - lines.length - 3));
|
|
66
|
-
lines.push(
|
|
76
|
+
lines.push(
|
|
77
|
+
...(this.form.question.isSecret
|
|
78
|
+
? maskedEditorLines(this.#editor.getText(), Math.max(1, width))
|
|
79
|
+
: this.#editor.render(Math.max(1, width))),
|
|
80
|
+
);
|
|
67
81
|
}
|
|
68
82
|
add("");
|
|
69
83
|
add(
|
|
@@ -101,6 +115,10 @@ export class RequestUserInputComponent implements Component {
|
|
|
101
115
|
} else if (matchesKey(data, "tab")) {
|
|
102
116
|
this.form.toggleNotes();
|
|
103
117
|
this.#restore();
|
|
118
|
+
} else if (!this.form.notesVisible && data === " ") this.form.commitSelection();
|
|
119
|
+
else if (!this.form.notesVisible && (matchesKey(data, "backspace") || matchesKey(data, "delete"))) {
|
|
120
|
+
this.form.clearSelection();
|
|
121
|
+
this.#restore();
|
|
104
122
|
} else if (enter) this.#submit();
|
|
105
123
|
else if (
|
|
106
124
|
matchesKey(data, "ctrl+p") ||
|
|
@@ -120,7 +138,7 @@ export class RequestUserInputComponent implements Component {
|
|
|
120
138
|
else if (keys.matches(data, "tui.select.up")) this.form.moveOption(-1);
|
|
121
139
|
else if (keys.matches(data, "tui.select.down")) this.form.moveOption(1);
|
|
122
140
|
else if (/^[1-9]$/.test(data) && Number(data) <= this.form.options.length) {
|
|
123
|
-
this.form.
|
|
141
|
+
this.form.selectOption(Number(data) - 1);
|
|
124
142
|
this.#submit();
|
|
125
143
|
}
|
|
126
144
|
this.tui.requestRender();
|
|
@@ -161,6 +161,7 @@ export class RemoteSession {
|
|
|
161
161
|
#unsubscribeTransitions?: () => void;
|
|
162
162
|
#unsubscribeDispose?: () => void;
|
|
163
163
|
#unsubscribeTitle?: () => void;
|
|
164
|
+
#unsubscribeInteractionStatus?: () => void;
|
|
164
165
|
#interactions?: RemoteInteractions;
|
|
165
166
|
#effects = new Set<Promise<unknown>>();
|
|
166
167
|
#voiceHistoryOwner!: SessionVoiceHistory;
|
|
@@ -189,7 +190,7 @@ export class RemoteSession {
|
|
|
189
190
|
#startedAtMs = 0;
|
|
190
191
|
#lastProviderUsage?: Usage;
|
|
191
192
|
#usageEmitted = new Set<string>();
|
|
192
|
-
#threadStatus
|
|
193
|
+
#threadStatus = JSON.stringify({ type: "idle" });
|
|
193
194
|
get #durable(): boolean {
|
|
194
195
|
return typeof this.target.sessionManager.getBranch === "function";
|
|
195
196
|
}
|
|
@@ -278,6 +279,9 @@ export class RemoteSession {
|
|
|
278
279
|
void this.target.abort();
|
|
279
280
|
},
|
|
280
281
|
);
|
|
282
|
+
if (target.userInteractions)
|
|
283
|
+
this.#unsubscribeInteractionStatus = target.userInteractions.subscribe(() => this.#emitThreadStatus());
|
|
284
|
+
this.#threadStatus = JSON.stringify(this.#threadStatusValue());
|
|
281
285
|
this.#unsubscribeDispose = target.addBeforeDisposeHook?.(() => this.close());
|
|
282
286
|
this.#unsubscribeTitle = subscribeSessionTitle(target.sessionManager, title =>
|
|
283
287
|
this.#emit("thread/name/updated", { threadName: title }),
|
|
@@ -334,7 +338,7 @@ export class RemoteSession {
|
|
|
334
338
|
this.#hydrateActiveStream();
|
|
335
339
|
}
|
|
336
340
|
}
|
|
337
|
-
this.#threadStatus = this.#
|
|
341
|
+
this.#threadStatus = JSON.stringify(this.#threadStatusValue());
|
|
338
342
|
}
|
|
339
343
|
#historyToolContext(message: AgentMessage): HistoryToolContext | undefined {
|
|
340
344
|
if (message.role === "bashExecution" || message.role === "pythonExecution")
|
|
@@ -402,6 +406,7 @@ export class RemoteSession {
|
|
|
402
406
|
this.#unsubscribeTransitions?.();
|
|
403
407
|
this.#unsubscribeDispose?.();
|
|
404
408
|
this.#unsubscribeTitle?.();
|
|
409
|
+
this.#unsubscribeInteractionStatus?.();
|
|
405
410
|
for (const cancel of this.#cancelDelegations) cancel();
|
|
406
411
|
this.#unsubscribe();
|
|
407
412
|
this.#interactions?.close();
|
|
@@ -439,12 +444,22 @@ export class RemoteSession {
|
|
|
439
444
|
this.#updatedAt = Math.floor(Date.now() / 1000);
|
|
440
445
|
for (const listener of this.#listeners) listener({ method, params: { ...params, threadId: this.#threadId } });
|
|
441
446
|
}
|
|
442
|
-
#
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
447
|
+
#threadStatusValue(
|
|
448
|
+
runtimeActive = Boolean(this.#active) || this.target.isStreaming,
|
|
449
|
+
): { type: "active"; activeFlags: string[] } | { type: "idle" } {
|
|
450
|
+
const waiting =
|
|
451
|
+
this.target.userInteractions?.waitingOnUserInput === true ||
|
|
452
|
+
this.target.conversationPlans?.current?.status === "pending";
|
|
453
|
+
return runtimeActive || waiting
|
|
454
|
+
? { type: "active", activeFlags: waiting ? ["waitingOnUserInput"] : [] }
|
|
455
|
+
: { type: "idle" };
|
|
456
|
+
}
|
|
457
|
+
#emitThreadStatus(runtimeStatus?: "active" | "idle"): void {
|
|
458
|
+
const status = this.#threadStatusValue(runtimeStatus === undefined ? undefined : runtimeStatus === "active");
|
|
459
|
+
const serialized = JSON.stringify(status);
|
|
460
|
+
if (this.#threadStatus === serialized) return;
|
|
461
|
+
this.#threadStatus = serialized;
|
|
462
|
+
this.#emit("thread/status/changed", { status });
|
|
448
463
|
}
|
|
449
464
|
#usageBreakdown(usage: Usage) {
|
|
450
465
|
return {
|
|
@@ -653,7 +668,7 @@ export class RemoteSession {
|
|
|
653
668
|
createdAt: this.#createdAt,
|
|
654
669
|
updatedAt: this.#updatedAt,
|
|
655
670
|
recencyAt: this.#updatedAt,
|
|
656
|
-
status: this
|
|
671
|
+
status: this.#threadStatusValue(),
|
|
657
672
|
path: this.target.sessionFile ?? null,
|
|
658
673
|
cwd: this.target.sessionManager.getCwd(),
|
|
659
674
|
cliVersion: this.version,
|
|
@@ -1581,6 +1596,7 @@ export class RemoteSession {
|
|
|
1581
1596
|
}
|
|
1582
1597
|
if (event.type === "plan_available" || event.type === "plan_resolved") {
|
|
1583
1598
|
this.#emit("xcsh/interaction/plan", { contract: "xcsh.interaction.v1", event });
|
|
1599
|
+
this.#emitThreadStatus();
|
|
1584
1600
|
return;
|
|
1585
1601
|
}
|
|
1586
1602
|
if (event.type === "async_user_input") {
|
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
Snowflake,
|
|
66
66
|
setNativeKillTree,
|
|
67
67
|
} from "@f5-sales-demo/pi-utils";
|
|
68
|
+
import { createAsyncQuestionItem } from "../../../chat-ui/src/interactions/contract";
|
|
68
69
|
import {
|
|
69
70
|
type ConversationPlan,
|
|
70
71
|
ConversationPlans,
|
|
@@ -807,6 +808,7 @@ export class AgentSession {
|
|
|
807
808
|
pane_id: process.env.HERDR_PANE_ID,
|
|
808
809
|
producer: "xcsh",
|
|
809
810
|
generation: Number(herdrGeneration),
|
|
811
|
+
session_id: this.sessionId,
|
|
810
812
|
},
|
|
811
813
|
() => {
|
|
812
814
|
void this.#emitSessionEvent({
|
|
@@ -815,7 +817,7 @@ export class AgentSession {
|
|
|
815
817
|
message: "Herdr interaction delivery is unavailable; local questions remain available.",
|
|
816
818
|
});
|
|
817
819
|
},
|
|
818
|
-
process.env.HERDR_NATIVE_CAPABILITY,
|
|
820
|
+
process.env.HERDR_NATIVE_CAPABILITY ?? process.env.HERDR_INTERACTION_CAPABILITY,
|
|
819
821
|
);
|
|
820
822
|
this.addBeforeDisposeHook(() => this.#herdrInteractions!.close());
|
|
821
823
|
this.addDisposeHook(
|
|
@@ -929,16 +931,7 @@ export class AgentSession {
|
|
|
929
931
|
questions: import("../../../chat-ui/src/interactions/contract").AsyncInputQuestion[],
|
|
930
932
|
questionIds: string[],
|
|
931
933
|
): void {
|
|
932
|
-
const item =
|
|
933
|
-
id: itemId,
|
|
934
|
-
type: "agentMessage" as const,
|
|
935
|
-
text: questions
|
|
936
|
-
.map(question => [question.title, ...(question.options?.map(option => `- ${option}`) ?? [])].join("\n"))
|
|
937
|
-
.join("\n\n"),
|
|
938
|
-
phase: "final_answer" as const,
|
|
939
|
-
delivery: "async" as const,
|
|
940
|
-
questions: structuredClone(questions),
|
|
941
|
-
};
|
|
934
|
+
const item = createAsyncQuestionItem(itemId, questions);
|
|
942
935
|
this.sessionManager.appendCustomMessageEntry("async-user-input", item.text, true, { item, questionIds }, "agent");
|
|
943
936
|
void this.#emitSessionEvent({ type: "async_user_input", item, questionIds });
|
|
944
937
|
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { isDeepStrictEqual } from "node:util";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
type AsyncInputQuestion,
|
|
6
|
+
type AsyncQuestionItem,
|
|
7
|
+
type InputQuestion,
|
|
8
|
+
type InputResponse,
|
|
9
|
+
validInputResponse,
|
|
10
|
+
} from "../../../chat-ui/src/interactions/contract";
|
|
5
11
|
import { isInteractionFrame, isInteractionIdentity } from "../../../chat-ui/src/interactions/transport";
|
|
6
12
|
|
|
7
13
|
const toolCalls = new AsyncLocalStorage<string>();
|
|
@@ -20,6 +26,12 @@ export interface UserInteractionSpec {
|
|
|
20
26
|
options?: readonly string[];
|
|
21
27
|
toolCallId?: string;
|
|
22
28
|
isSecret?: boolean;
|
|
29
|
+
asyncBatch?: {
|
|
30
|
+
requestId: string;
|
|
31
|
+
questionIds: readonly string[];
|
|
32
|
+
questions: readonly AsyncInputQuestion[];
|
|
33
|
+
item: AsyncQuestionItem;
|
|
34
|
+
};
|
|
23
35
|
}
|
|
24
36
|
export interface UserInteraction extends UserInteractionSpec {
|
|
25
37
|
id: string;
|
|
@@ -210,13 +222,14 @@ export class UserInteractions {
|
|
|
210
222
|
}
|
|
211
223
|
requestAsyncBatch(
|
|
212
224
|
specs: readonly (UserInteractionSpec & { kind: "input"; delivery: "async" })[],
|
|
225
|
+
batch?: NonNullable<UserInteractionSpec["asyncBatch"]>,
|
|
213
226
|
): Promise<string | undefined>[] {
|
|
214
227
|
if (this.#closed || this.#cancelling) throw new Error("Session interaction owner unavailable");
|
|
215
228
|
if (!specs.length || this.#pending.size + specs.length > 32)
|
|
216
229
|
throw new Error("Too many pending user interactions");
|
|
217
230
|
this.#batchDepth++;
|
|
218
231
|
try {
|
|
219
|
-
return specs.map(spec => this.request(spec));
|
|
232
|
+
return specs.map(spec => this.request({ ...spec, ...(batch ? { asyncBatch: batch } : {}) }));
|
|
220
233
|
} finally {
|
|
221
234
|
this.#batchDepth--;
|
|
222
235
|
this.#flushNotifications();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AgentTool, AgentToolResult } from "@f5-sales-demo/pi-agent-core";
|
|
2
2
|
import { type Static, Type } from "@sinclair/typebox";
|
|
3
|
-
import type
|
|
3
|
+
import { type AsyncInputQuestion, createAsyncQuestionItem } from "../../../chat-ui/src/interactions/contract";
|
|
4
4
|
import asyncDescription from "../prompts/tools/request-user-input-async.md" with { type: "text" };
|
|
5
5
|
import type { ToolSession } from ".";
|
|
6
6
|
import { ToolAbortError, ToolError } from "./tool-errors";
|
|
@@ -114,6 +114,7 @@ export class RequestUserInputAsyncTool implements AgentTool<typeof requestUserIn
|
|
|
114
114
|
if (!owner) throw new ToolError("Session interaction owner unavailable");
|
|
115
115
|
const identity = this.session.getInteractionIdentity?.(callId);
|
|
116
116
|
const questionIds = args.questions.map((_, index) => `${callId}:${index}`);
|
|
117
|
+
const item = createAsyncQuestionItem(callId, args.questions as AsyncInputQuestion[]);
|
|
117
118
|
const pending = owner.requestAsyncBatch(
|
|
118
119
|
args.questions.map((question, index) => ({
|
|
119
120
|
kind: "input",
|
|
@@ -124,6 +125,7 @@ export class RequestUserInputAsyncTool implements AgentTool<typeof requestUserIn
|
|
|
124
125
|
toolCallId: callId,
|
|
125
126
|
identity,
|
|
126
127
|
})),
|
|
128
|
+
{ requestId: callId, questionIds, questions: item.questions, item },
|
|
127
129
|
);
|
|
128
130
|
for (const [index, result] of pending.entries()) {
|
|
129
131
|
void result
|