@gajae-code/coding-agent 0.16.1 → 0.16.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.
Files changed (146) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/types/cli/args.d.ts +2 -2
  3. package/dist/types/cli/root-flags.d.ts +4 -2
  4. package/dist/types/cli.d.ts +1 -1
  5. package/dist/types/commands/launch.d.ts +1 -1
  6. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/model-resolver.d.ts +9 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/discovery/claude-plugins.d.ts +3 -1
  11. package/dist/types/discovery/helpers.d.ts +2 -0
  12. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
  13. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
  14. package/dist/types/extensibility/runtime-skill-discovery.d.ts +1 -1
  15. package/dist/types/extensibility/shared-events.d.ts +1 -1
  16. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  17. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  18. package/dist/types/modes/components/settings-selector.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/command.d.ts +35 -0
  20. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  21. package/dist/types/modes/components/status-line/types.d.ts +5 -0
  22. package/dist/types/modes/components/tool-status-header.d.ts +14 -0
  23. package/dist/types/modes/controllers/event-controller.d.ts +6 -0
  24. package/dist/types/modes/interactive-mode.d.ts +1 -0
  25. package/dist/types/modes/types.d.ts +1 -0
  26. package/dist/types/runtime-mcp/client.d.ts +9 -1
  27. package/dist/types/runtime-mcp/manager.d.ts +7 -1
  28. package/dist/types/runtime-mcp/pool.d.ts +22 -3
  29. package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
  30. package/dist/types/runtime-mcp/types.d.ts +22 -0
  31. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
  32. package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
  33. package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
  34. package/dist/types/sdk/cli/rows.d.ts +7 -0
  35. package/dist/types/sdk/host/events.d.ts +1 -0
  36. package/dist/types/sdk/host/host.d.ts +2 -0
  37. package/dist/types/sdk/host/session-runtime.d.ts +23 -0
  38. package/dist/types/sdk/session-api-key.d.ts +26 -0
  39. package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
  40. package/dist/types/session/agent-session.d.ts +9 -0
  41. package/dist/types/setup/paseo/announce.d.ts +111 -0
  42. package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
  43. package/dist/types/system-prompt.d.ts +2 -0
  44. package/dist/types/task/executor.d.ts +2 -1
  45. package/dist/types/tools/fetch.d.ts +7 -0
  46. package/dist/types/tools/index.d.ts +8 -2
  47. package/dist/types/tools/path-utils.d.ts +2 -0
  48. package/dist/types/tools/search.d.ts +6 -0
  49. package/dist/types/utils/external-editor.d.ts +5 -0
  50. package/package.json +7 -7
  51. package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
  52. package/scripts/generate-sdk-operation-inventory.ts +9 -0
  53. package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
  54. package/src/cli/args.ts +20 -9
  55. package/src/cli/root-flags.ts +5 -3
  56. package/src/config/autorouting-tier-map.ts +1 -0
  57. package/src/config/file-lock.ts +181 -17
  58. package/src/config/model-preset-registry.ts +5 -3
  59. package/src/config/model-profiles.ts +40 -0
  60. package/src/config/model-registry.ts +57 -11
  61. package/src/config/model-resolver.ts +48 -0
  62. package/src/config/settings-schema.ts +33 -1
  63. package/src/config/settings.ts +1 -0
  64. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
  65. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
  66. package/src/discovery/builtin.ts +13 -4
  67. package/src/discovery/claude-plugins.ts +12 -4
  68. package/src/discovery/helpers.ts +10 -2
  69. package/src/extensibility/gjc-plugins/compiler.ts +208 -58
  70. package/src/extensibility/gjc-plugins/installer.ts +13 -12
  71. package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
  72. package/src/extensibility/gjc-plugins/paths.ts +13 -0
  73. package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
  74. package/src/extensibility/runtime-skill-discovery.ts +77 -151
  75. package/src/extensibility/shared-events.ts +1 -1
  76. package/src/extensibility/skills.ts +44 -16
  77. package/src/gjc-runtime/repository-binding.ts +1 -1
  78. package/src/gjc-runtime/session-state-lock.ts +94 -6
  79. package/src/internal-urls/docs-index.generated.ts +10 -10
  80. package/src/main.ts +72 -34
  81. package/src/modes/DESIGN.md +54 -0
  82. package/src/modes/acp/acp-agent.ts +29 -14
  83. package/src/modes/components/assistant-message.ts +22 -9
  84. package/src/modes/components/model-selector.ts +166 -33
  85. package/src/modes/components/settings-selector.ts +1075 -235
  86. package/src/modes/components/status-line/command.ts +190 -0
  87. package/src/modes/components/status-line/index.ts +1 -0
  88. package/src/modes/components/status-line/segments.ts +32 -5
  89. package/src/modes/components/status-line/types.ts +5 -0
  90. package/src/modes/components/tool-status-header.ts +393 -21
  91. package/src/modes/controllers/command-controller.ts +2 -0
  92. package/src/modes/controllers/event-controller.ts +148 -5
  93. package/src/modes/controllers/selector-controller.ts +21 -0
  94. package/src/modes/interactive-mode.ts +47 -1
  95. package/src/modes/types.ts +1 -0
  96. package/src/modes/utils/ui-helpers.ts +1 -0
  97. package/src/prompts/system/system-prompt.md +8 -0
  98. package/src/prompts/tools/bash.md +1 -1
  99. package/src/prompts/tools/job.md +1 -1
  100. package/src/prompts/tools/search.md +2 -0
  101. package/src/runtime-mcp/client.ts +60 -17
  102. package/src/runtime-mcp/manager.ts +171 -14
  103. package/src/runtime-mcp/pool.test.ts +209 -0
  104. package/src/runtime-mcp/pool.ts +224 -23
  105. package/src/runtime-mcp/transports/stdio.ts +158 -24
  106. package/src/runtime-mcp/types.ts +24 -0
  107. package/src/sdk/acp/adapter.ts +1 -1
  108. package/src/sdk/broker/broker.ts +5 -4
  109. package/src/sdk/bus/chat-daemon-control.ts +1 -1
  110. package/src/sdk/bus/chat-daemon-runtime.ts +44 -1
  111. package/src/sdk/bus/slack-daemon.ts +92 -2
  112. package/src/sdk/bus/slack-live-provider.ts +12 -0
  113. package/src/sdk/bus/slack-provider.ts +5 -0
  114. package/src/sdk/cli/rows.ts +47 -2
  115. package/src/sdk/cli/session-cli.ts +47 -23
  116. package/src/sdk/host/events.ts +25 -2
  117. package/src/sdk/host/host.ts +4 -1
  118. package/src/sdk/host/session-runtime.test.ts +36 -25
  119. package/src/sdk/host/session-runtime.ts +279 -43
  120. package/src/sdk/protocol/operation-inventory.generated.json +66 -0
  121. package/src/sdk/router/session-router.ts +60 -37
  122. package/src/sdk/session-api-key.ts +94 -0
  123. package/src/sdk/session.ts +73 -30
  124. package/src/sdk/transport/serve-cli.ts +3 -3
  125. package/src/session/agent-session.ts +123 -38
  126. package/src/session/contribution-prep.ts +214 -21
  127. package/src/setup/paseo/announce.ts +450 -0
  128. package/src/setup/paseo/setup-deps.ts +15 -3
  129. package/src/slash-commands/builtin-registry.ts +1 -0
  130. package/src/system-prompt.ts +4 -0
  131. package/src/task/executor.ts +2 -1
  132. package/src/task/index.ts +17 -4
  133. package/src/tools/bash.ts +50 -21
  134. package/src/tools/cron.ts +1 -1
  135. package/src/tools/fetch.ts +29 -1
  136. package/src/tools/index.ts +8 -2
  137. package/src/tools/job.ts +8 -2
  138. package/src/tools/monitor.ts +8 -2
  139. package/src/tools/path-utils.ts +7 -0
  140. package/src/tools/search.ts +116 -58
  141. package/src/tools/skill.ts +22 -2
  142. package/src/tools/subagent.ts +3 -1
  143. package/src/tools/tool-catalog.generated.ts +9 -2
  144. package/src/utils/external-editor.ts +16 -2
  145. package/src/web/insane/bridge.ts +113 -23
  146. package/src/web/search/providers/perplexity.ts +102 -41
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Announce a live interactive session to Paseo so it appears there as a
3
+ * controllable ACP agent.
4
+ *
5
+ * `gjc setup paseo` registers GJC as an ACP provider, but Paseo only ever knows
6
+ * about the sessions it started itself. Everything else is already in place: an
7
+ * interactive `gjc` hosts a broker-registered SDK endpoint, and GJC's own ACP
8
+ * surface resolves a *live* index entry straight to that running host -- a
9
+ * `session/load` attaches to it instead of resuming a second copy. The one
10
+ * missing step is telling the Paseo daemon that the session exists, which is
11
+ * exactly what `paseo import` does.
12
+ *
13
+ * Two hard constraints shape this module:
14
+ *
15
+ * - The Paseo CLI blocks indefinitely when its daemon is not running (measured:
16
+ * no output and no exit after two minutes). It is therefore NEVER spawned
17
+ * before a cheap socket probe proves something is listening.
18
+ * - ACP `session/load` only attaches to a session the broker index reports as
19
+ * live; for anything else it falls back to `session.resume`, which would start
20
+ * a *second* host for a session that is actually running. Liveness is
21
+ * confirmed before the import, never assumed.
22
+ *
23
+ * Everything here is best-effort: no Paseo, a stopped daemon, or a failed import
24
+ * must never slow down or break an interactive launch. It is also opt-in: the
25
+ * caller checks `paseo.autoImport` before any of this runs, so an installation
26
+ * that never asked for it pays nothing at all.
27
+ */
28
+ import * as fs from "node:fs/promises";
29
+ import * as net from "node:net";
30
+ import * as path from "node:path";
31
+ import { $which, logger } from "@gajae-code/utils";
32
+ import { AgentDirSessionLifecycleClient } from "../../sdk/lifecycle/broker-client";
33
+ import { sessionListPageFromResponse, traverseSessionList } from "../../sdk/session-list";
34
+ import { PROVIDER_EXTENDS, PROVIDER_KEY, resolvePaseoHome } from "./setup-deps";
35
+
36
+ export { resolvePaseoHome } from "./setup-deps";
37
+
38
+ /** Cheap liveness probe of the daemon socket. Only gates the expensive CLI spawn. */
39
+ const DAEMON_PROBE_TIMEOUT_MS = 750;
40
+ /** Bound on the broker `session.list` traversal that proves our own liveness. */
41
+ const BROKER_QUERY_TIMEOUT_MS = 5_000;
42
+ /** `paseo import` spawns Electron, probes the provider, and replays history. */
43
+ const IMPORT_TIMEOUT_MS = 90_000;
44
+ /** Cadence of the retry loop while the daemon is simply not up yet. */
45
+ const RETRY_INTERVAL_MS = 30_000;
46
+ /** Retries stop after ten minutes so a launch never keeps a timer alive forever. */
47
+ const MAX_ATTEMPTS = 20;
48
+
49
+ /** Paseo's own default when nothing configures a listener. */
50
+ const DEFAULT_DAEMON_TARGET: PaseoDaemonTarget = { kind: "tcp", host: "localhost", port: 6767 };
51
+
52
+ export type PaseoDaemonTarget =
53
+ | { readonly kind: "ipc"; readonly socketPath: string }
54
+ | { readonly kind: "tcp"; readonly host: string; readonly port: number };
55
+
56
+ /** Why an announcement did nothing. Each reason is a distinct, actionable state. */
57
+ export type PaseoAnnounceSkip =
58
+ | "no-paseo-config"
59
+ | "no-provider"
60
+ | "cli-missing"
61
+ | "daemon-unreachable"
62
+ | "unsupported-daemon-target"
63
+ | "daemon-auth-required"
64
+ | "session-not-live";
65
+
66
+ export type PaseoAnnounceOutcome =
67
+ | { readonly kind: "imported"; readonly providerKey: string }
68
+ | { readonly kind: "already-imported"; readonly providerKey: string }
69
+ | { readonly kind: "skipped"; readonly reason: PaseoAnnounceSkip }
70
+ | { readonly kind: "failed"; readonly detail: string };
71
+
72
+ export interface PaseoAnnounceDependencies {
73
+ /** `~/.paseo/config.json` (or the `$PASEO_HOME` equivalent). */
74
+ readonly configJson: string;
75
+ /** `~/.paseo` (or `$PASEO_HOME`). */
76
+ readonly paseoHome: string;
77
+ readonly env: NodeJS.ProcessEnv;
78
+ /** Parsed JSON, or `undefined` when the file is absent or unreadable. */
79
+ readJson(file: string): Promise<unknown>;
80
+ /** Absolute path of the `paseo` CLI, or `undefined` when it is not installed. */
81
+ resolveCli(): string | undefined;
82
+ probeDaemon(target: PaseoDaemonTarget): Promise<boolean>;
83
+ /** True only when the broker index reports this session id as live in `cwd`. */
84
+ isSessionLive(sessionId: string, cwd: string): Promise<boolean>;
85
+ runImport(input: {
86
+ readonly cli: string;
87
+ readonly providerKey: string;
88
+ readonly cwd: string;
89
+ readonly sessionId: string;
90
+ readonly signal?: AbortSignal;
91
+ }): Promise<PaseoAnnounceOutcome>;
92
+ }
93
+
94
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
95
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
96
+ }
97
+
98
+ /**
99
+ * Pick the Paseo provider entry to import under.
100
+ *
101
+ * `gjc setup paseo` writes `gjc`, and `--mpreset <name>` writes `gjc-<name>`, so
102
+ * the base key is preferred and a preset provider is only used when it is the
103
+ * only one installed. A disabled entry is never selected: Paseo would refuse to
104
+ * launch it.
105
+ */
106
+ export function selectProviderKey(config: unknown): string | undefined {
107
+ const providers = asRecord(asRecord(asRecord(config)?.agents)?.providers);
108
+ if (!providers) return undefined;
109
+ const usable = (key: string): boolean => {
110
+ const entry = asRecord(providers[key]);
111
+ if (entry === undefined || entry.enabled === false || entry.extends !== PROVIDER_EXTENDS) return false;
112
+ if (typeof entry.label !== "string" || !entry.label.startsWith("Gajae Code")) return false;
113
+ const command = entry.command;
114
+ return Array.isArray(command) && command.every(value => typeof value === "string") && command.includes("acp");
115
+ };
116
+ if (usable(PROVIDER_KEY)) return PROVIDER_KEY;
117
+ return Object.keys(providers)
118
+ .filter(key => key.startsWith(`${PROVIDER_KEY}-`) && usable(key))
119
+ .sort()[0];
120
+ }
121
+
122
+ /**
123
+ * Parse one Paseo listener spelling into a connectable target.
124
+ *
125
+ * Mirrors the spellings Paseo's own CLI accepts: `unix://`/`pipe://` prefixes, a
126
+ * bare absolute path, a bare port, `tcp://host:port`, and plain `host:port`.
127
+ */
128
+ export function parseDaemonListen(raw: string): PaseoDaemonTarget | undefined {
129
+ const trimmed = raw.trim();
130
+ if (!trimmed) return undefined;
131
+ if (trimmed.startsWith("unix://")) {
132
+ const socketPath = trimmed.slice("unix://".length).trim();
133
+ return socketPath ? { kind: "ipc", socketPath } : undefined;
134
+ }
135
+ if (trimmed.startsWith("pipe://") || trimmed.startsWith("\\\\.\\pipe\\")) {
136
+ const socketPath = trimmed.startsWith("pipe://") ? trimmed.slice("pipe://".length).trim() : trimmed;
137
+ return socketPath ? { kind: "ipc", socketPath } : undefined;
138
+ }
139
+ if (trimmed.startsWith("/")) return { kind: "ipc", socketPath: trimmed };
140
+ if (/^\d+$/.test(trimmed)) {
141
+ const port = Number(trimmed);
142
+ return Number.isSafeInteger(port) && port >= 1 && port <= 65_535
143
+ ? { kind: "tcp", host: "127.0.0.1", port }
144
+ : undefined;
145
+ }
146
+ const authority = trimmed.startsWith("tcp://") ? trimmed.slice("tcp://".length) : trimmed;
147
+ const withoutQuery = authority.split("?")[0] ?? "";
148
+ const separator = withoutQuery.lastIndexOf(":");
149
+ if (separator <= 0) return undefined;
150
+ const port = Number(withoutQuery.slice(separator + 1));
151
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) return undefined;
152
+ const host = withoutQuery.slice(0, separator).replace(/^\[|]$/g, "");
153
+ return host ? { kind: "tcp", host, port } : undefined;
154
+ }
155
+
156
+ /**
157
+ * Resolve where the daemon listens, in the same precedence Paseo's CLI uses:
158
+ * environment override, then the running daemon's own pid file, then the
159
+ * configured listener, then Paseo's default.
160
+ */
161
+ export async function resolveDaemonTarget(
162
+ config: unknown,
163
+ deps: Pick<PaseoAnnounceDependencies, "env" | "paseoHome" | "readJson">,
164
+ ): Promise<PaseoDaemonTarget> {
165
+ return (await resolveDaemonTargets(config, deps))[0] ?? DEFAULT_DAEMON_TARGET;
166
+ }
167
+
168
+ async function resolveDaemonTargets(
169
+ config: unknown,
170
+ deps: Pick<PaseoAnnounceDependencies, "env" | "paseoHome" | "readJson">,
171
+ ): Promise<PaseoDaemonTarget[]> {
172
+ const explicitHost = deps.env.PASEO_HOST?.trim();
173
+ if (explicitHost) {
174
+ const explicitTarget = parseDaemonListen(explicitHost);
175
+ return explicitTarget ? [explicitTarget] : [];
176
+ }
177
+
178
+ const pidFile = asRecord(await deps.readJson(path.join(deps.paseoHome, "paseo.pid")));
179
+ const pidListen = typeof pidFile?.listen === "string" ? pidFile.listen : pidFile?.sockPath;
180
+ const pidTarget = typeof pidListen === "string" ? parseDaemonListen(pidListen) : undefined;
181
+
182
+ const parsed = asRecord(config);
183
+ const configured = asRecord(parsed?.daemon)?.listen ?? parsed?.listen;
184
+ const configuredRaw = deps.env.PASEO_LISTEN?.trim() || (typeof configured === "string" ? configured : undefined);
185
+ const configuredTarget = configuredRaw ? parseDaemonListen(configuredRaw) : undefined;
186
+ const configuredIpc = configuredTarget?.kind === "ipc" ? configuredTarget : undefined;
187
+ const pidIpc = pidTarget?.kind === "ipc" ? pidTarget : undefined;
188
+ const configuredTcp =
189
+ configuredTarget?.kind === "tcp" && !(configuredTarget.host === "127.0.0.1" && configuredTarget.port === 6767)
190
+ ? configuredTarget
191
+ : undefined;
192
+ const directIpc = deps.env.PASEO_LISTEN?.trim();
193
+ const directIpcTarget = directIpc ? parseDaemonListen(directIpc) : undefined;
194
+ const targets = [
195
+ directIpcTarget?.kind === "ipc" ? directIpcTarget : undefined,
196
+ pidIpc,
197
+ configuredIpc,
198
+ configuredTcp,
199
+ ].filter((target): target is PaseoDaemonTarget => target !== undefined);
200
+ return targets.length > 0 ? [targets[0]!] : [DEFAULT_DAEMON_TARGET];
201
+ }
202
+
203
+ /**
204
+ * Turn a non-zero `paseo import` into an outcome.
205
+ *
206
+ * Two of Paseo's refusals are not failures:
207
+ *
208
+ * - an already-known session is exactly the state we wanted to reach;
209
+ * - a password-protected daemon (`Cannot connect to daemon ...: Password
210
+ * required`) is a configuration the user chose. The socket probe cannot see
211
+ * it -- the TCP connect succeeds -- so it can only be recognized here, and
212
+ * reporting it would put an error on screen at every single launch for
213
+ * something GJC must not fix on its own. Exporting `PASEO_PASSWORD` makes it
214
+ * work, since the CLI inherits this process's environment.
215
+ */
216
+ export function classifyImportFailure(providerKey: string, detail: string): PaseoAnnounceOutcome {
217
+ if (/already imported/i.test(detail)) return { kind: "already-imported", providerKey };
218
+ if (/cannot connect to daemon[^\n]*password required/i.test(detail)) {
219
+ return { kind: "skipped", reason: "daemon-auth-required" };
220
+ }
221
+ return { kind: "failed", detail: "paseo import failed; inspect the Paseo daemon log" };
222
+ }
223
+
224
+ /**
225
+ * Run the full announcement once.
226
+ *
227
+ * The order is load-bearing: every cheap filesystem check runs before the socket
228
+ * probe, and the socket probe runs before anything spawns the Paseo CLI.
229
+ */
230
+ export async function announceSessionToPaseo(
231
+ input: { readonly sessionId: string; readonly cwd: string },
232
+ deps: PaseoAnnounceDependencies,
233
+ signal?: AbortSignal,
234
+ ): Promise<PaseoAnnounceOutcome> {
235
+ if (signal?.aborted) return { kind: "failed", detail: "Paseo announcement cancelled" };
236
+ const config = await deps.readJson(deps.configJson);
237
+ if (signal?.aborted) return { kind: "failed", detail: "Paseo announcement cancelled" };
238
+ if (config === undefined) return { kind: "skipped", reason: "no-paseo-config" };
239
+
240
+ const providerKey = selectProviderKey(config);
241
+ if (!providerKey) return { kind: "skipped", reason: "no-provider" };
242
+
243
+ const cli = deps.resolveCli();
244
+ if (!cli) return { kind: "skipped", reason: "cli-missing" };
245
+
246
+ const targets = await resolveDaemonTargets(config, deps);
247
+ if (deps.env.PASEO_HOST?.trim() && targets.length === 0)
248
+ return { kind: "skipped", reason: "unsupported-daemon-target" };
249
+ let reachable = false;
250
+ for (const target of targets) {
251
+ if (await deps.probeDaemon(target)) {
252
+ reachable = true;
253
+ break;
254
+ }
255
+ }
256
+ if (!reachable) return { kind: "skipped", reason: "daemon-unreachable" };
257
+ if (signal?.aborted) return { kind: "failed", detail: "Paseo announcement cancelled" };
258
+
259
+ // Importing a session the broker does not yet report as live would make ACP
260
+ // `session/load` resume a copy instead of attaching to the running host.
261
+ if (!(await deps.isSessionLive(input.sessionId, input.cwd))) return { kind: "skipped", reason: "session-not-live" };
262
+
263
+ return await deps.runImport({ cli, providerKey, cwd: input.cwd, sessionId: input.sessionId, signal });
264
+ }
265
+
266
+ /** Only a daemon that is not up yet, or a registration that has not landed yet, is worth retrying. */
267
+ export function isRetryableOutcome(outcome: PaseoAnnounceOutcome): boolean {
268
+ return (
269
+ outcome.kind === "skipped" && (outcome.reason === "daemon-unreachable" || outcome.reason === "session-not-live")
270
+ );
271
+ }
272
+
273
+ export interface PaseoAnnouncementHandle {
274
+ cancel(): void;
275
+ }
276
+
277
+ /**
278
+ * Announce the session, retrying only while Paseo is not ready to hear it.
279
+ *
280
+ * The user routinely opens Paseo after their terminal, so a single attempt at
281
+ * launch would silently never land. Retries are socket probes, not CLI spawns,
282
+ * and every timer is unref'd so the loop can never hold the process open.
283
+ */
284
+ export function startPaseoAnnouncement(
285
+ input: {
286
+ readonly sessionId: string;
287
+ readonly cwd: string;
288
+ isEnabled?: () => boolean;
289
+ onOutcome?: (outcome: PaseoAnnounceOutcome) => void;
290
+ },
291
+ deps: PaseoAnnounceDependencies,
292
+ ): PaseoAnnouncementHandle {
293
+ let cancelled = false;
294
+ let timer: ReturnType<typeof setTimeout> | undefined;
295
+ const controller = new AbortController();
296
+
297
+ const attempt = async (remaining: number): Promise<void> => {
298
+ if (cancelled || input.isEnabled?.() === false) return;
299
+ let outcome: PaseoAnnounceOutcome;
300
+ try {
301
+ outcome = await announceSessionToPaseo(input, deps, controller.signal);
302
+ } catch (error) {
303
+ outcome = { kind: "failed", detail: error instanceof Error ? error.message : String(error) };
304
+ }
305
+ if (cancelled || input.isEnabled?.() === false) return;
306
+ if (isRetryableOutcome(outcome) && remaining > 1) {
307
+ // Logged per attempt: a retried outcome is otherwise completely silent,
308
+ // which is the exact state a user reports as "it never showed up".
309
+ logger.debug("Paseo session announcement retrying", { outcome, remaining: remaining - 1 });
310
+ timer = setTimeout(() => void attempt(remaining - 1), RETRY_INTERVAL_MS);
311
+ timer.unref?.();
312
+ return;
313
+ }
314
+ logger.debug("Paseo session announcement settled", { outcome });
315
+ try {
316
+ input.onOutcome?.(outcome);
317
+ } catch (error) {
318
+ logger.debug("Paseo session announcement outcome handler failed", {
319
+ error: error instanceof Error ? error.message : String(error),
320
+ });
321
+ }
322
+ };
323
+
324
+ void attempt(MAX_ATTEMPTS);
325
+ return {
326
+ cancel(): void {
327
+ cancelled = true;
328
+ controller.abort();
329
+ if (timer) clearTimeout(timer);
330
+ },
331
+ };
332
+ }
333
+
334
+ async function readJson(file: string): Promise<unknown> {
335
+ try {
336
+ return JSON.parse(await Bun.file(file).text());
337
+ } catch {
338
+ return undefined;
339
+ }
340
+ }
341
+
342
+ function probeDaemon(target: PaseoDaemonTarget): Promise<boolean> {
343
+ return new Promise(resolve => {
344
+ let settled = false;
345
+ const settle = (reachable: boolean): void => {
346
+ if (settled) return;
347
+ settled = true;
348
+ socket.destroy();
349
+ resolve(reachable);
350
+ };
351
+ const socket =
352
+ target.kind === "ipc"
353
+ ? net.connect({ path: target.socketPath })
354
+ : net.connect({ host: target.host, port: target.port });
355
+ socket.setTimeout(DAEMON_PROBE_TIMEOUT_MS);
356
+ socket.once("connect", () => settle(true));
357
+ socket.once("timeout", () => settle(false));
358
+ socket.once("error", () => settle(false));
359
+ });
360
+ }
361
+
362
+ function isSessionLive(agentDir: string): (sessionId: string, cwd: string) => Promise<boolean> {
363
+ return async (sessionId, cwd) => {
364
+ const canonicalCwd = await fs.realpath(cwd).catch(() => path.resolve(cwd));
365
+ const client = new AgentDirSessionLifecycleClient(agentDir);
366
+ const pages = await traverseSessionList(
367
+ {},
368
+ pageInput => client.global("session.list", pageInput, { timeoutMs: BROKER_QUERY_TIMEOUT_MS }),
369
+ response => sessionListPageFromResponse(response),
370
+ );
371
+ for (const { sessions } of pages) {
372
+ if (sessionListContainsLiveSessionForTests(sessions, sessionId, canonicalCwd)) return true;
373
+ }
374
+ return false;
375
+ };
376
+ }
377
+
378
+ export function sessionListContainsLiveSessionForTests(
379
+ sessions: readonly unknown[],
380
+ sessionId: string,
381
+ cwd: string,
382
+ ): boolean {
383
+ for (const value of sessions) {
384
+ const session = asRecord(value);
385
+ if (session?.sessionId !== sessionId || session.live !== true) continue;
386
+ const locatorCwd = asRecord(session.locator)?.cwd;
387
+ if (typeof locatorCwd === "string" && path.resolve(locatorCwd) === path.resolve(cwd)) return true;
388
+ }
389
+ return false;
390
+ }
391
+
392
+ /**
393
+ * Run `paseo import` with the session's own environment.
394
+ *
395
+ * GJC never reads a Paseo credential and has no setting of its own for one. The
396
+ * CLI authenticates exactly as it would if the user ran it by hand: if
397
+ * `PASEO_PASSWORD` is exported it is inherited, and if it is not, a
398
+ * password-protected daemon refuses and the announcement becomes a quiet skip.
399
+ */
400
+ function runImport(env: NodeJS.ProcessEnv) {
401
+ return async (input: {
402
+ readonly cli: string;
403
+ readonly providerKey: string;
404
+ readonly cwd: string;
405
+ readonly sessionId: string;
406
+ readonly signal?: AbortSignal;
407
+ }): Promise<PaseoAnnounceOutcome> => {
408
+ const timeoutController = new AbortController();
409
+ const signal = input.signal
410
+ ? AbortSignal.any([input.signal, timeoutController.signal])
411
+ : timeoutController.signal;
412
+ const timer = setTimeout(() => timeoutController.abort(), IMPORT_TIMEOUT_MS);
413
+ try {
414
+ const child = Bun.spawn(
415
+ [input.cli, "import", "--provider", input.providerKey, "--cwd", input.cwd, input.sessionId],
416
+ { stdout: "pipe", stderr: "pipe", signal, env },
417
+ );
418
+ const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
419
+ if (input.signal?.aborted) return { kind: "failed", detail: "Paseo announcement cancelled" };
420
+ if (timeoutController.signal.aborted)
421
+ return { kind: "failed", detail: `paseo import timed out after ${IMPORT_TIMEOUT_MS}ms` };
422
+ if (exitCode === 0) return { kind: "imported", providerKey: input.providerKey };
423
+ return classifyImportFailure(input.providerKey, stderr.trim() || `paseo import exited with ${exitCode}`);
424
+ } catch (error) {
425
+ return { kind: "failed", detail: error instanceof Error ? error.message : String(error) };
426
+ } finally {
427
+ clearTimeout(timer);
428
+ }
429
+ };
430
+ }
431
+
432
+ export function createDefaultPaseoAnnounceDependencies(
433
+ agentDir: string,
434
+ env: NodeJS.ProcessEnv = process.env,
435
+ ): PaseoAnnounceDependencies {
436
+ const paseoHome = resolvePaseoHome(env);
437
+ return {
438
+ configJson: path.join(paseoHome, "config.json"),
439
+ paseoHome,
440
+ env,
441
+ readJson,
442
+ // Resolve against the injected environment, not the ambient one: the env is a
443
+ // dependency here, and a lookup that ignores it would resolve a different
444
+ // executable than every other step in this module reasons about.
445
+ resolveCli: () => $which("paseo", env.PATH === undefined ? undefined : { PATH: env.PATH }) ?? undefined,
446
+ probeDaemon,
447
+ isSessionLive: isSessionLive(agentDir),
448
+ runImport: runImport(env),
449
+ };
450
+ }
@@ -76,8 +76,20 @@ export interface PaseoSetupDependencies {
76
76
  now(): Date;
77
77
  }
78
78
 
79
- export function createDefaultPaseoPaths(agentDir: string = getAgentDir(), home: string = os.homedir()): PaseoPaths {
80
- const paseoHome = path.join(home, ".paseo");
79
+ export function resolvePaseoHome(env: NodeJS.ProcessEnv = process.env, home: string = os.homedir()): string {
80
+ const configured = env.PASEO_HOME?.trim();
81
+ if (!configured) return path.join(home, ".paseo");
82
+ const expanded =
83
+ configured === "~" ? home : configured.startsWith("~/") ? path.join(home, configured.slice(2)) : configured;
84
+ return path.resolve(expanded);
85
+ }
86
+
87
+ export function createDefaultPaseoPaths(
88
+ agentDir: string = getAgentDir(),
89
+ home: string = os.homedir(),
90
+ env: NodeJS.ProcessEnv = {},
91
+ ): PaseoPaths {
92
+ const paseoHome = resolvePaseoHome(env, home);
81
93
  return {
82
94
  configJson: path.join(paseoHome, "config.json"),
83
95
  orchestrationPreferences: path.join(paseoHome, "orchestration-preferences.json"),
@@ -168,7 +180,7 @@ export function parseProviderLs(stdout: string): PaseoLsOutcome {
168
180
 
169
181
  export function createDefaultPaseoSetupDependencies(): PaseoSetupDependencies {
170
182
  return {
171
- paths: createDefaultPaseoPaths(),
183
+ paths: createDefaultPaseoPaths(getAgentDir(), os.homedir(), process.env),
172
184
  runProviderLs,
173
185
  now: () => new Date(),
174
186
  };
@@ -127,6 +127,7 @@ function toSlashCommandRuntime(runtime: TuiSlashCommandRuntime): SlashCommandRun
127
127
  reloadPlugins: async () => {
128
128
  const projectPath = await resolveActiveProjectRegistryPath(ctx.sessionManager.getCwd());
129
129
  clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
130
+ await ctx.session.reloadSkills();
130
131
  await ctx.refreshSlashCommandState();
131
132
  await ctx.session.refreshSshTool({ activateIfAvailable: true });
132
133
  },
@@ -368,6 +368,8 @@ export interface BuildSystemPromptOptions {
368
368
  pluginAppendices?: string;
369
369
  /** Repeat full tool descriptions in system prompt. Default: false */
370
370
  repeatToolDescriptions?: boolean;
371
+ /** Language guidance for internal technical reasoning. Default: off. */
372
+ reasoningLanguage?: "off" | "english";
371
373
  /** Skills settings for discovery. */
372
374
  skillsSettings?: SkillsSettings;
373
375
  /** Working directory. Default: getProjectDir() */
@@ -542,6 +544,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
542
544
  appendSystemPrompt,
543
545
  pluginAppendices,
544
546
  repeatToolDescriptions = false,
547
+ reasoningLanguage = "off",
545
548
  toolNames: providedToolNames,
546
549
  cwd,
547
550
  contextFiles: providedContextFiles,
@@ -710,6 +713,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
710
713
  tools: toolNames,
711
714
  toolInfo,
712
715
  repeatToolDescriptions,
716
+ reasoningLanguageEnglish: reasoningLanguage === "english",
713
717
  toolRefs,
714
718
  environment,
715
719
  contextFiles: sanitizedContextFiles,
@@ -300,7 +300,8 @@ export interface ExecutorOptions {
300
300
  managedPersistence?: ManagedTaskPersistence;
301
301
  /**
302
302
  * The parent session's ENDPOINT-owned AsyncJobManager (resolved by the
303
- * TaskTool via forEndpoint(sessionId) ?? instance()). Model metadata and
303
+ * TaskTool via its opaque async endpoint accessor, then the process-global
304
+ * fallback). Model metadata and
304
305
  * live-handle state for THIS subagent are recorded in the SAME manager the
305
306
  * task job runs in — with concurrent top-level sessions the process-global
306
307
  * instance belongs to a different session and would surface this subagent
package/src/task/index.ts CHANGED
@@ -375,6 +375,14 @@ function createTaskModeError(text: string): AgentToolResult<TaskToolDetails> {
375
375
  };
376
376
  }
377
377
 
378
+ /** Remove only strict-provider blank placeholders for the optional caller-authored schema. */
379
+ function normalizeTaskAdmissionParams(params: TaskParams): TaskParams {
380
+ if (typeof params.schema !== "string" || params.schema.trim() !== "") return params;
381
+ const normalized = { ...params };
382
+ delete normalized.schema;
383
+ return normalized;
384
+ }
385
+
378
386
  function validateTaskModeParams(simpleMode: TaskSimpleMode, params: TaskParams): string | undefined {
379
387
  const { contextEnabled, customSchemaEnabled } = getTaskSimpleModeCapabilities(simpleMode);
380
388
  const disallowedFields: string[] = [];
@@ -888,7 +896,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
888
896
  * thread P1).
889
897
  */
890
898
  #resolveOwnedJobManager(): AsyncJobManager | undefined {
891
- const endpointId = this.session.getSessionId?.() ?? undefined;
899
+ const endpointId = this.session.getAsyncEndpointId?.() ?? this.session.getSessionId?.() ?? undefined;
892
900
  return (
893
901
  this.session.getAsyncJobManager?.() ?? AsyncJobManager.forEndpoint(endpointId) ?? AsyncJobManager.instance()
894
902
  );
@@ -900,7 +908,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
900
908
  signal?: AbortSignal,
901
909
  onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
902
910
  ): Promise<AgentToolResult<TaskToolDetails>> {
903
- const params = rawParams as TaskParams;
911
+ const params = normalizeTaskAdmissionParams(rawParams as TaskParams);
904
912
  const simpleMode = this.#getTaskSimpleMode();
905
913
  const validationError = validateTaskModeParams(simpleMode, params);
906
914
  if (validationError) {
@@ -1245,7 +1253,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1245
1253
  manager,
1246
1254
  resumeToolCallId ?? descriptor.toolCallId,
1247
1255
  resumeJobId,
1248
- this.session.getSessionId?.() ?? undefined,
1256
+ this.session.getAsyncEndpointId?.() ?? this.session.getSessionId?.() ?? undefined,
1249
1257
  );
1250
1258
  return resumeJobId;
1251
1259
  };
@@ -1477,7 +1485,12 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1477
1485
  },
1478
1486
  },
1479
1487
  );
1480
- registerOwnedIfLineaged(manager, _toolCallId, jobId, this.session.getSessionId?.() ?? undefined);
1488
+ registerOwnedIfLineaged(
1489
+ manager,
1490
+ _toolCallId,
1491
+ jobId,
1492
+ this.session.getAsyncEndpointId?.() ?? this.session.getSessionId?.() ?? undefined,
1493
+ );
1481
1494
  startedJobs.push({ jobId, taskId: taskItem.id });
1482
1495
  if (typeof manager.registerResumeDescriptor === "function") {
1483
1496
  manager.registerResumeDescriptor(