jeopi-cli 16.4.2 → 16.4.3
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/CHANGELOG.md +74 -0
- package/dist/cli.js +3179 -3140
- package/dist/types/advisor/config.d.ts +4 -3
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/update-cli.d.ts +4 -1
- package/dist/types/cli/usage-error.d.ts +4 -0
- package/dist/types/commit/agentic/agent.d.ts +1 -0
- package/dist/types/config/model-resolver.d.ts +7 -1
- package/dist/types/config/settings-schema.d.ts +3 -3
- package/dist/types/eval/py/tool-bridge.d.ts +1 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -1
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/internal-urls/memory-protocol.d.ts +6 -6
- package/dist/types/internal-urls/types.d.ts +7 -0
- package/dist/types/irc/bus.d.ts +4 -0
- package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
- package/dist/types/modes/components/move-overlay.d.ts +1 -1
- package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
- package/dist/types/modes/components/welcome.d.ts +4 -0
- package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
- package/dist/types/modes/orchestrate.d.ts +1 -1
- package/dist/types/modes/ultrathink.d.ts +1 -1
- package/dist/types/modes/workflow.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +5 -0
- package/dist/types/session/auth-broker-config.d.ts +2 -2
- package/dist/types/tools/bash-skill-urls.d.ts +3 -2
- package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
- package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
- package/dist/types/tools/browser/launch.d.ts +1 -0
- package/dist/types/tools/browser/run-cancellation.d.ts +17 -2
- package/dist/types/tools/browser/run-output.d.ts +25 -0
- package/dist/types/tools/browser/tab-worker.d.ts +22 -0
- package/dist/types/utils/changelog.d.ts +35 -1
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +13 -0
- package/src/advisor/__tests__/config.test.ts +36 -0
- package/src/advisor/config.ts +11 -8
- package/src/autolearn/controller.ts +14 -2
- package/src/cli/args.ts +12 -0
- package/src/cli/flag-tables.ts +21 -7
- package/src/cli/update-cli.ts +63 -13
- package/src/cli/usage-error.ts +7 -0
- package/src/commands/acp.ts +11 -2
- package/src/commands/launch.ts +12 -3
- package/src/commit/agentic/agent.ts +4 -0
- package/src/commit/agentic/index.ts +46 -21
- package/src/config/model-resolver.ts +24 -6
- package/src/config/settings-schema.ts +3 -3
- package/src/edit/index.ts +10 -76
- package/src/edit/renderer.ts +13 -10
- package/src/eval/__tests__/agent-bridge.test.ts +19 -14
- package/src/eval/__tests__/bridge-timeout.test.ts +60 -0
- package/src/eval/executor-base.ts +84 -7
- package/src/eval/js/shared/rewrite-imports.ts +31 -13
- package/src/eval/py/tool-bridge.ts +13 -14
- package/src/exec/non-interactive-env.ts +0 -1
- package/src/export/ttsr.ts +0 -3
- package/src/extensibility/custom-tools/loader.ts +70 -37
- package/src/extensibility/custom-tools/types.ts +3 -0
- package/src/extensibility/extensions/runner.ts +3 -0
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/extensibility/plugins/manager.ts +21 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/internal-urls/memory-protocol.ts +13 -8
- package/src/internal-urls/skill-protocol.ts +1 -1
- package/src/internal-urls/types.ts +7 -0
- package/src/irc/bus.ts +48 -18
- package/src/lsp/client.ts +7 -1
- package/src/lsp/deferred-diagnostics.ts +66 -0
- package/src/main.ts +13 -13
- package/src/mcp/tool-bridge.ts +57 -6
- package/src/mcp/transports/stdio.test.ts +45 -1
- package/src/mcp/transports/stdio.ts +6 -3
- package/src/modes/acp/acp-agent.ts +73 -2
- package/src/modes/acp/acp-event-mapper.ts +5 -0
- package/src/modes/acp/acp-mode.ts +11 -0
- package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
- package/src/modes/components/advisor-config.ts +15 -7
- package/src/modes/components/move-overlay.ts +2 -3
- package/src/modes/components/plan-review-overlay.ts +7 -0
- package/src/modes/components/tips.txt +1 -1
- package/src/modes/components/welcome.ts +13 -14
- package/src/modes/controllers/command-controller.ts +8 -10
- package/src/modes/controllers/event-controller.ts +17 -30
- package/src/modes/controllers/input-controller.ts +9 -6
- package/src/modes/controllers/selector-controller.ts +3 -2
- package/src/modes/controllers/tan-command-controller.ts +40 -1
- package/src/modes/interactive-mode.ts +13 -0
- package/src/modes/magic-keyword-boundary.ts +23 -0
- package/src/modes/orchestrate.ts +6 -5
- package/src/modes/print-mode.ts +9 -0
- package/src/modes/ultrathink.ts +6 -5
- package/src/modes/workflow.ts +5 -4
- package/src/prompts/system/eager-task.md +2 -2
- package/src/prompts/system/tan-context-switch.md +17 -0
- package/src/prompts/tools/browser.md +5 -5
- package/src/sdk.ts +3 -0
- package/src/session/agent-session.ts +172 -25
- package/src/session/auth-broker-config.ts +4 -4
- package/src/session/snapcompact-inline.ts +3 -19
- package/src/session/streaming-output.ts +40 -12
- package/src/slash-commands/builtin-registry.ts +8 -8
- package/src/task/executor.ts +8 -0
- package/src/tools/bash-skill-urls.ts +7 -4
- package/src/tools/bash.ts +30 -22
- package/src/tools/browser/cmux/cmux-tab.ts +49 -50
- package/src/tools/browser/cmux/rpc.ts +50 -0
- package/src/tools/browser/launch.ts +8 -4
- package/src/tools/browser/run-cancellation.ts +44 -5
- package/src/tools/browser/run-output.ts +76 -0
- package/src/tools/browser/tab-worker.ts +280 -133
- package/src/tools/eval-render.ts +16 -14
- package/src/tools/glob.ts +20 -6
- package/src/tools/grep.ts +1 -0
- package/src/tools/image-gen.ts +3 -4
- package/src/tools/irc.ts +27 -13
- package/src/tools/read.ts +14 -7
- package/src/tools/write.ts +22 -5
- package/src/tools/yield.ts +29 -1
- package/src/utils/changelog.ts +106 -9
- package/src/utils/git.ts +0 -1
- package/src/utils/title-generator.ts +68 -7
- package/src/web/search/index.ts +14 -6
- package/src/web/search/providers/perplexity.ts +18 -2
package/src/task/executor.ts
CHANGED
|
@@ -2038,14 +2038,22 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
2038
2038
|
thinkingLevel: resolvedThinkingLevel,
|
|
2039
2039
|
explicitThinkingLevel,
|
|
2040
2040
|
authFallbackUsed,
|
|
2041
|
+
warning: modelResolutionWarning,
|
|
2041
2042
|
} = await awaitAbortable(
|
|
2042
2043
|
resolveModelOverrideWithAuthFallback(
|
|
2043
2044
|
modelPatterns,
|
|
2044
2045
|
options.parentActiveModelPattern,
|
|
2045
2046
|
modelRegistry,
|
|
2046
2047
|
settings,
|
|
2048
|
+
id,
|
|
2047
2049
|
),
|
|
2048
2050
|
);
|
|
2051
|
+
if (modelResolutionWarning) {
|
|
2052
|
+
logger.warn("Subagent model resolution warning", {
|
|
2053
|
+
warning: modelResolutionWarning,
|
|
2054
|
+
requested: modelPatterns,
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2049
2057
|
if (authFallbackUsed && model) {
|
|
2050
2058
|
logger.warn("Subagent model has no working credentials; falling back to parent session model", {
|
|
2051
2059
|
requested: modelPatterns,
|
|
@@ -3,7 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import type { Skill } from "../extensibility/skills";
|
|
4
4
|
import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
|
|
5
5
|
import { validateRelativePath } from "../internal-urls/skill-protocol";
|
|
6
|
-
import type { InternalResource } from "../internal-urls/types";
|
|
6
|
+
import type { InternalResource, ResolveContext } from "../internal-urls/types";
|
|
7
7
|
import { normalizeLocalScheme } from "./path-utils";
|
|
8
8
|
import { ToolError } from "./tool-errors";
|
|
9
9
|
|
|
@@ -19,7 +19,7 @@ type SupportedInternalScheme = (typeof SUPPORTED_INTERNAL_SCHEMES)[number];
|
|
|
19
19
|
|
|
20
20
|
interface InternalUrlResolver {
|
|
21
21
|
canHandle(input: string): boolean;
|
|
22
|
-
resolve(input: string): Promise<InternalResource>;
|
|
22
|
+
resolve(input: string, context?: ResolveContext): Promise<InternalResource>;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export interface InternalUrlExpansionOptions {
|
|
@@ -27,6 +27,7 @@ export interface InternalUrlExpansionOptions {
|
|
|
27
27
|
noEscape?: boolean;
|
|
28
28
|
internalRouter?: InternalUrlResolver;
|
|
29
29
|
localOptions?: LocalProtocolOptions;
|
|
30
|
+
cwd?: string;
|
|
30
31
|
ensureLocalParentDirs?: boolean;
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -66,7 +67,7 @@ export function resolveSkillUrlToPath(url: string, skills: readonly Skill[]): st
|
|
|
66
67
|
const hasRelativePath = rawPath !== "" && rawPath !== "/";
|
|
67
68
|
|
|
68
69
|
if (!hasRelativePath) {
|
|
69
|
-
return path.resolve(skill.
|
|
70
|
+
return path.resolve(skill.baseDir);
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
let relativePath: string;
|
|
@@ -151,6 +152,7 @@ async function resolveInternalUrlToPath(
|
|
|
151
152
|
internalRouter?: InternalUrlResolver,
|
|
152
153
|
localOptions?: LocalProtocolOptions,
|
|
153
154
|
ensureLocalParentDirs?: boolean,
|
|
155
|
+
cwd?: string,
|
|
154
156
|
): Promise<string> {
|
|
155
157
|
const url = normalizeLocalScheme(rawUrl);
|
|
156
158
|
const scheme = extractScheme(url);
|
|
@@ -184,7 +186,7 @@ async function resolveInternalUrlToPath(
|
|
|
184
186
|
|
|
185
187
|
let resource: InternalResource;
|
|
186
188
|
try {
|
|
187
|
-
resource = await internalRouter.resolve(url);
|
|
189
|
+
resource = await internalRouter.resolve(url, { cwd });
|
|
188
190
|
} catch (error) {
|
|
189
191
|
const message = error instanceof Error ? error.message : String(error);
|
|
190
192
|
throw new ToolError(`Failed to resolve ${scheme}:// URL in bash command: ${url}\n${message}`);
|
|
@@ -239,6 +241,7 @@ export async function expandInternalUrls(command: string, options: InternalUrlEx
|
|
|
239
241
|
options.internalRouter,
|
|
240
242
|
options.localOptions,
|
|
241
243
|
options.ensureLocalParentDirs,
|
|
244
|
+
options.cwd,
|
|
242
245
|
);
|
|
243
246
|
const replacement = options.noEscape ? resolvedPath : shellEscape(resolvedPath);
|
|
244
247
|
expanded = `${expanded.slice(0, index)}${replacement}${expanded.slice(index + token.length)}`;
|
package/src/tools/bash.ts
CHANGED
|
@@ -163,10 +163,9 @@ type ManagedBashJobCompletion =
|
|
|
163
163
|
|
|
164
164
|
interface ManagedBashJobHandle {
|
|
165
165
|
jobId: string;
|
|
166
|
-
label: string;
|
|
167
166
|
completion: Promise<ManagedBashJobCompletion>;
|
|
168
167
|
getLatestText: () => string;
|
|
169
|
-
|
|
168
|
+
stopUpdates: () => void;
|
|
170
169
|
}
|
|
171
170
|
|
|
172
171
|
function normalizeResultOutput(result: BashResult | BashInteractiveResult): string {
|
|
@@ -297,6 +296,10 @@ function formatExitCodeNotice(exitCode: number): string {
|
|
|
297
296
|
return `Command exited with code ${exitCode}`;
|
|
298
297
|
}
|
|
299
298
|
|
|
299
|
+
function formatBackgroundNotice(jobId: string): string {
|
|
300
|
+
return `Backgrounded as job ${jobId}; result will be delivered automatically.`;
|
|
301
|
+
}
|
|
302
|
+
|
|
300
303
|
/**
|
|
301
304
|
* Strip the trailing occurrence of `notice` (plus a single surrounding newline
|
|
302
305
|
* on each side) so the TUI can echo the value via a styled footer label
|
|
@@ -325,6 +328,11 @@ function stripExitCodeNotice(text: string, exitCode: number | undefined): string
|
|
|
325
328
|
return stripTrailingNotice(text, formatExitCodeNotice(exitCode));
|
|
326
329
|
}
|
|
327
330
|
|
|
331
|
+
function stripBackgroundNotice(text: string, async: BashToolDetails["async"] | undefined): string {
|
|
332
|
+
if (async?.state !== "running") return text;
|
|
333
|
+
return stripTrailingNotice(text, formatBackgroundNotice(async.jobId));
|
|
334
|
+
}
|
|
335
|
+
|
|
328
336
|
/**
|
|
329
337
|
* Bash tool implementation.
|
|
330
338
|
*
|
|
@@ -473,7 +481,6 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
|
|
473
481
|
|
|
474
482
|
#buildBackgroundStartResult(
|
|
475
483
|
jobId: string,
|
|
476
|
-
label: string,
|
|
477
484
|
previewText: string,
|
|
478
485
|
timeoutSec: number,
|
|
479
486
|
options: { requestedTimeoutSec?: number; notices?: readonly string[] } = {},
|
|
@@ -493,11 +500,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
|
|
493
500
|
if (options.notices?.length) {
|
|
494
501
|
lines.push(...options.notices, "");
|
|
495
502
|
}
|
|
496
|
-
lines.push(
|
|
497
|
-
lines.push("Result will be delivered automatically when complete.");
|
|
498
|
-
lines.push(
|
|
499
|
-
`You can use \`job\` to poll until complete, but prefer to continue with another task in the meanwhile if it's not blocking.`,
|
|
500
|
-
);
|
|
503
|
+
lines.push(formatBackgroundNotice(jobId));
|
|
501
504
|
return {
|
|
502
505
|
content: [{ type: "text", text: lines.join("\n") }],
|
|
503
506
|
details,
|
|
@@ -518,7 +521,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
|
|
518
521
|
|
|
519
522
|
resolvedEnv?: Record<string, string>;
|
|
520
523
|
onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
|
|
521
|
-
|
|
524
|
+
forwardUpdates: boolean;
|
|
522
525
|
}): ManagedBashJobHandle {
|
|
523
526
|
const manager = this.session.asyncJobManager;
|
|
524
527
|
if (!manager) {
|
|
@@ -527,7 +530,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
|
|
527
530
|
|
|
528
531
|
const label = options.command.length > 120 ? `${options.command.slice(0, 117)}...` : options.command;
|
|
529
532
|
let latestText = "";
|
|
530
|
-
let
|
|
533
|
+
let forwardUpdates = options.forwardUpdates;
|
|
531
534
|
const completion = Promise.withResolvers<ManagedBashJobCompletion>();
|
|
532
535
|
|
|
533
536
|
const jobId = manager.register(
|
|
@@ -583,11 +586,12 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
|
|
583
586
|
},
|
|
584
587
|
{
|
|
585
588
|
ownerId: this.session.getAgentId?.() ?? undefined,
|
|
586
|
-
onProgress: async
|
|
589
|
+
onProgress: async text => {
|
|
587
590
|
latestText = text;
|
|
591
|
+
if (!forwardUpdates) return;
|
|
588
592
|
await options.onUpdate?.({
|
|
589
593
|
content: [{ type: "text", text }],
|
|
590
|
-
details:
|
|
594
|
+
details: {},
|
|
591
595
|
});
|
|
592
596
|
},
|
|
593
597
|
},
|
|
@@ -595,11 +599,10 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
|
|
595
599
|
|
|
596
600
|
return {
|
|
597
601
|
jobId,
|
|
598
|
-
label,
|
|
599
602
|
completion: completion.promise,
|
|
600
603
|
getLatestText: () => latestText,
|
|
601
|
-
|
|
602
|
-
|
|
604
|
+
stopUpdates: () => {
|
|
605
|
+
forwardUpdates = false;
|
|
603
606
|
},
|
|
604
607
|
};
|
|
605
608
|
}
|
|
@@ -701,6 +704,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
|
|
701
704
|
const internalUrlOptions: InternalUrlExpansionOptions = {
|
|
702
705
|
skills: this.session.skills ?? [],
|
|
703
706
|
internalRouter: InternalUrlRouter.instance(),
|
|
707
|
+
cwd: this.session.cwd,
|
|
704
708
|
localOptions: {
|
|
705
709
|
getArtifactsDir: this.session.getArtifactsDir,
|
|
706
710
|
getSessionId: this.session.getSessionId,
|
|
@@ -769,9 +773,9 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
|
|
769
773
|
|
|
770
774
|
resolvedEnv,
|
|
771
775
|
onUpdate,
|
|
772
|
-
|
|
776
|
+
forwardUpdates: false,
|
|
773
777
|
});
|
|
774
|
-
return this.#buildBackgroundStartResult(job.jobId,
|
|
778
|
+
return this.#buildBackgroundStartResult(job.jobId, "", timeoutSec, {
|
|
775
779
|
requestedTimeoutSec,
|
|
776
780
|
notices: pendingNotices,
|
|
777
781
|
});
|
|
@@ -807,10 +811,10 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
|
|
807
811
|
|
|
808
812
|
resolvedEnv,
|
|
809
813
|
onUpdate,
|
|
810
|
-
startBackgrounded,
|
|
814
|
+
forwardUpdates: !startBackgrounded,
|
|
811
815
|
});
|
|
812
816
|
if (startBackgrounded) {
|
|
813
|
-
return this.#buildBackgroundStartResult(job.jobId,
|
|
817
|
+
return this.#buildBackgroundStartResult(job.jobId, "", timeoutSec, {
|
|
814
818
|
requestedTimeoutSec,
|
|
815
819
|
notices: pendingNotices,
|
|
816
820
|
});
|
|
@@ -830,9 +834,9 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
|
|
830
834
|
autoBgManager.cancel(job.jobId);
|
|
831
835
|
throw new ToolAbortError(job.getLatestText() || "Command aborted");
|
|
832
836
|
}
|
|
833
|
-
job.
|
|
837
|
+
job.stopUpdates();
|
|
834
838
|
autoBgManager.resumeDeliveries([job.jobId]);
|
|
835
|
-
return this.#buildBackgroundStartResult(job.jobId, job.
|
|
839
|
+
return this.#buildBackgroundStartResult(job.jobId, job.getLatestText(), timeoutSec, {
|
|
836
840
|
requestedTimeoutSec,
|
|
837
841
|
notices: pendingNotices,
|
|
838
842
|
});
|
|
@@ -1249,7 +1253,8 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
|
|
|
1249
1253
|
) {
|
|
1250
1254
|
return cachedLines;
|
|
1251
1255
|
}
|
|
1252
|
-
const
|
|
1256
|
+
const withoutBackground = stripBackgroundNotice(rawOutput, details?.async);
|
|
1257
|
+
const strippedOutput = stripOutputNotice(withoutBackground, details?.meta);
|
|
1253
1258
|
const withoutExit = stripExitCodeNotice(strippedOutput, details?.exitCode);
|
|
1254
1259
|
const withoutWall = stripWallTimeNotice(withoutExit, details?.wallTimeMs);
|
|
1255
1260
|
const rawOutputArtifact = stripRawOutputArtifactNotice(withoutWall);
|
|
@@ -1262,6 +1267,9 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
|
|
|
1262
1267
|
const requestedTimeoutSeconds = details?.requestedTimeoutSeconds;
|
|
1263
1268
|
const wallTimeMs = details?.wallTimeMs;
|
|
1264
1269
|
const statsParts: string[] = [];
|
|
1270
|
+
if (details?.async?.state === "running") {
|
|
1271
|
+
statsParts.push(`Backgrounded: ${details.async.jobId}`);
|
|
1272
|
+
}
|
|
1265
1273
|
if (wallTimeMs !== undefined) {
|
|
1266
1274
|
statsParts.push(`Wall: ${formatWallTimeSeconds(wallTimeMs)}s`);
|
|
1267
1275
|
}
|
|
@@ -3,7 +3,6 @@ import * as os from "node:os";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { logger, Snowflake, untilAborted } from "jeopi-utils";
|
|
5
5
|
import { JsRuntime, type RuntimeHooks } from "../../../eval/js/shared/runtime";
|
|
6
|
-
import type { JsDisplayOutput } from "../../../eval/js/shared/types";
|
|
7
6
|
import { callSessionTool } from "../../../eval/js/tool-bridge";
|
|
8
7
|
import { resizeImage } from "../../../utils/image-resize";
|
|
9
8
|
import type { ToolSession } from "../../index";
|
|
@@ -13,7 +12,8 @@ import { ToolAbortError, ToolError, throwIfAborted } from "../../tool-errors";
|
|
|
13
12
|
import { type AriaSnapshotOptions, buildAriaSnapshotScript } from "../aria/aria-snapshot";
|
|
14
13
|
import { DEFAULT_VIEWPORT } from "../launch";
|
|
15
14
|
import { extractReadableFromHtml, type ReadableFormat } from "../readable";
|
|
16
|
-
import { bindBrowserRunFacade, waitForBrowserRun } from "../run-cancellation";
|
|
15
|
+
import { bindBrowserRunFacade, type WaitPredicateOptions, waitForBrowserRun } from "../run-cancellation";
|
|
16
|
+
import { cloneSafe, RunOutput } from "../run-output";
|
|
17
17
|
import type { Observation, ReadyInfo, RunResultOk, ScreenshotResult, SessionSnapshot } from "../tab-protocol";
|
|
18
18
|
import {
|
|
19
19
|
type CmuxEvalResult,
|
|
@@ -24,7 +24,8 @@ import {
|
|
|
24
24
|
cmuxSnapshotToObservation,
|
|
25
25
|
GEOMETRY_SCRIPT,
|
|
26
26
|
mapWaitUntil,
|
|
27
|
-
|
|
27
|
+
serializeEvalWithEnvelope,
|
|
28
|
+
unwrapEvalEnvelope,
|
|
28
29
|
} from "./rpc";
|
|
29
30
|
import type { CmuxSocketClient } from "./socket-client";
|
|
30
31
|
|
|
@@ -43,7 +44,7 @@ interface ObserveOptions {
|
|
|
43
44
|
|
|
44
45
|
interface RunContext {
|
|
45
46
|
session: SessionSnapshot;
|
|
46
|
-
|
|
47
|
+
output: RunOutput;
|
|
47
48
|
screenshots: ScreenshotResult[];
|
|
48
49
|
signal: AbortSignal;
|
|
49
50
|
timeoutMs: number;
|
|
@@ -445,10 +446,14 @@ export class CmuxTab {
|
|
|
445
446
|
fn: string | ((...args: TArgs) => TResult | Promise<TResult>),
|
|
446
447
|
...args: TArgs
|
|
447
448
|
): Promise<TResult> {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
return
|
|
449
|
+
// A script that throws inside the daemon comes back as a bare
|
|
450
|
+
// `js_error: A JavaScript exception occurred` with no message or stack.
|
|
451
|
+
// Catch page-side instead so the exception is diagnosable, and turn the
|
|
452
|
+
// daemon's other blind spot — Promise return values it cannot
|
|
453
|
+
// serialize — into an actionable error instead of "unsupported type".
|
|
454
|
+
const script = serializeEvalWithEnvelope(fn as string | ((...args: unknown[]) => unknown), args);
|
|
455
|
+
const result = (await this.#request("browser.eval", { script })) as CmuxEvalResult;
|
|
456
|
+
return unwrapEvalEnvelope<TResult>(result.value, "tab.evaluate()");
|
|
452
457
|
}
|
|
453
458
|
|
|
454
459
|
async scrollIntoView(selector: string): Promise<void> {
|
|
@@ -479,10 +484,22 @@ export class CmuxTab {
|
|
|
479
484
|
|
|
480
485
|
async screenshot(opts: ScreenshotOptions = {}): Promise<ScreenshotResult> {
|
|
481
486
|
const context = this.#requireRunContext("tab.screenshot()");
|
|
487
|
+
// The cmux daemon's `browser.screenshot` captures the surface viewport
|
|
488
|
+
// only — it has no element-clip or full-page mode, and Bun.Image cannot
|
|
489
|
+
// crop locally. Degrade transparently instead of silently mislabeling
|
|
490
|
+
// the capture: scroll the element into view, then TELL the model the
|
|
491
|
+
// image is the full viewport (reports showed selector captures being
|
|
492
|
+
// consumed as element crops).
|
|
493
|
+
const captureNotes: string[] = [];
|
|
482
494
|
if (opts.selector) {
|
|
483
495
|
await this.scrollIntoView(opts.selector);
|
|
496
|
+
captureNotes.push(
|
|
497
|
+
`selector ${JSON.stringify(opts.selector)} was scrolled into view, but this surface cannot clip to an element — the image is the full viewport`,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
if (opts.fullPage) {
|
|
501
|
+
captureNotes.push("fullPage is unavailable on this surface — the image is the viewport only");
|
|
484
502
|
}
|
|
485
|
-
void opts.fullPage;
|
|
486
503
|
const result = await this.#captureScreenshotPng(context.timeoutMs);
|
|
487
504
|
const buffer = Buffer.from(result.png_base64, "base64");
|
|
488
505
|
const captureMime = "image/png";
|
|
@@ -528,8 +545,11 @@ export class CmuxTab {
|
|
|
528
545
|
dest,
|
|
529
546
|
resized,
|
|
530
547
|
});
|
|
531
|
-
|
|
532
|
-
|
|
548
|
+
if (captureNotes.length > 0) {
|
|
549
|
+
lines.push(`[cmux surface: ${captureNotes.join("; ")}]`);
|
|
550
|
+
}
|
|
551
|
+
context.output.push({ type: "text", text: lines.join("\n") });
|
|
552
|
+
context.output.push({ type: "image", data: resized.data, mimeType: resized.mimeType });
|
|
533
553
|
}
|
|
534
554
|
return info;
|
|
535
555
|
}
|
|
@@ -724,7 +744,12 @@ export class CmuxTab {
|
|
|
724
744
|
const callable = (0, eval)("(" + source + ")");
|
|
725
745
|
return callable(element, ...args);
|
|
726
746
|
})()`;
|
|
727
|
-
|
|
747
|
+
// Envelope so a stale selector or a throwing callback reports its actual
|
|
748
|
+
// error instead of the daemon's generic js_error (see tab.evaluate()).
|
|
749
|
+
const result = (await this.#request("browser.eval", {
|
|
750
|
+
script: serializeEvalWithEnvelope(script, []),
|
|
751
|
+
})) as CmuxEvalResult;
|
|
752
|
+
return unwrapEvalEnvelope<TResult>(result.value, "elementHandle.evaluate()");
|
|
728
753
|
}
|
|
729
754
|
|
|
730
755
|
async pageContent(): Promise<string> {
|
|
@@ -1097,6 +1122,10 @@ class CmuxElementHandle {
|
|
|
1097
1122
|
await this.#tab.fill(this.#selector, value);
|
|
1098
1123
|
}
|
|
1099
1124
|
|
|
1125
|
+
async press(key: string): Promise<void> {
|
|
1126
|
+
await this.#tab.press(key, { selector: this.#selector });
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1100
1129
|
async focus(): Promise<void> {
|
|
1101
1130
|
await this.#tab.focus(this.#selector);
|
|
1102
1131
|
}
|
|
@@ -1284,10 +1313,10 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
|
|
|
1284
1313
|
const signal = AbortSignal.any(
|
|
1285
1314
|
opts.signal ? [timeoutSignal, opts.signal, runAc.signal] : [timeoutSignal, runAc.signal],
|
|
1286
1315
|
);
|
|
1287
|
-
const
|
|
1316
|
+
const output = new RunOutput();
|
|
1288
1317
|
const screenshots: ScreenshotResult[] = [];
|
|
1289
1318
|
const runId = crypto.randomUUID();
|
|
1290
|
-
tab.setRunContext({ session: opts.snapshot,
|
|
1319
|
+
tab.setRunContext({ session: opts.snapshot, output, screenshots, signal, timeoutMs: opts.timeoutMs });
|
|
1291
1320
|
const runtime = tab.ensureRuntime(opts.snapshot);
|
|
1292
1321
|
runtime.setCwd(opts.snapshot.cwd);
|
|
1293
1322
|
const runTab = bindBrowserRunFacade(tab, signal);
|
|
@@ -1298,7 +1327,8 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
|
|
|
1298
1327
|
assert: (cond: unknown, text?: string): void => {
|
|
1299
1328
|
if (!cond) throw new ToolError(text ?? "Assertion failed");
|
|
1300
1329
|
},
|
|
1301
|
-
wait: (
|
|
1330
|
+
wait: (msOrPredicate: number | (() => unknown), opts?: WaitPredicateOptions): Promise<unknown> =>
|
|
1331
|
+
waitForBrowserRun(msOrPredicate, signal, opts),
|
|
1302
1332
|
});
|
|
1303
1333
|
|
|
1304
1334
|
const { promise: cancelRejection, reject } = Promise.withResolvers<never>();
|
|
@@ -1316,11 +1346,12 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
|
|
|
1316
1346
|
const hooks: RuntimeHooks = {
|
|
1317
1347
|
onText: chunk => {
|
|
1318
1348
|
throwIfAborted(signal);
|
|
1349
|
+
output.pushText(chunk);
|
|
1319
1350
|
logger.debug(chunk.replace(/\n$/, ""));
|
|
1320
1351
|
},
|
|
1321
|
-
onDisplay:
|
|
1352
|
+
onDisplay: displayed => {
|
|
1322
1353
|
throwIfAborted(signal);
|
|
1323
|
-
pushDisplay(
|
|
1354
|
+
output.pushDisplay(displayed);
|
|
1324
1355
|
},
|
|
1325
1356
|
callTool: (name, args) => {
|
|
1326
1357
|
throwIfAborted(signal);
|
|
@@ -1333,7 +1364,7 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
|
|
|
1333
1364
|
runtime.run(opts.code, `cmux-run-${runId}.js`, hooks, { runId, cwd: opts.snapshot.cwd }),
|
|
1334
1365
|
cancelRejection,
|
|
1335
1366
|
]);
|
|
1336
|
-
return { displays, returnValue: cloneSafe(returnValue), screenshots };
|
|
1367
|
+
return { displays: output.finish(), returnValue: cloneSafe(returnValue), screenshots };
|
|
1337
1368
|
} finally {
|
|
1338
1369
|
signal.removeEventListener("abort", onAbort);
|
|
1339
1370
|
runAc.abort(new ToolAbortError("Browser run ended"));
|
|
@@ -1341,38 +1372,6 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
|
|
|
1341
1372
|
}
|
|
1342
1373
|
}
|
|
1343
1374
|
|
|
1344
|
-
function pushDisplay(displays: RunResultOk["displays"], output: JsDisplayOutput): void {
|
|
1345
|
-
if (output.type === "image") {
|
|
1346
|
-
displays.push({ type: "image", data: output.data, mimeType: output.mimeType });
|
|
1347
|
-
return;
|
|
1348
|
-
}
|
|
1349
|
-
if (output.type === "json") {
|
|
1350
|
-
displays.push({ type: "text", text: safeJsonStringify(output.data) });
|
|
1351
|
-
return;
|
|
1352
|
-
}
|
|
1353
|
-
displays.push({ type: "text", text: safeJsonStringify(output.event) });
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
function safeJsonStringify(value: unknown): string {
|
|
1357
|
-
try {
|
|
1358
|
-
return JSON.stringify(value, null, 2);
|
|
1359
|
-
} catch {
|
|
1360
|
-
return String(value);
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
function cloneSafe(value: unknown): unknown {
|
|
1365
|
-
if (value === undefined) return undefined;
|
|
1366
|
-
try {
|
|
1367
|
-
structuredClone(value);
|
|
1368
|
-
return value;
|
|
1369
|
-
} catch {}
|
|
1370
|
-
try {
|
|
1371
|
-
return JSON.parse(JSON.stringify(value)) as unknown;
|
|
1372
|
-
} catch {}
|
|
1373
|
-
return String(value);
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
1375
|
function numberFrom(value: unknown, fallback: number): number {
|
|
1377
1376
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
1378
1377
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ToolError } from "../../tool-errors";
|
|
1
2
|
import type { Observation, ObservationEntry } from "../tab-protocol";
|
|
2
3
|
|
|
3
4
|
export interface CmuxKind {
|
|
@@ -120,6 +121,55 @@ export function serializeEval(fn: string | ((...args: unknown[]) => unknown), ar
|
|
|
120
121
|
return `(${fn.toString()})(${args.map(arg => JSON.stringify(arg)).join(",")})`;
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Like {@link serializeEval}, but wraps the expression in a page-side
|
|
126
|
+
* try/catch envelope so a throwing script surfaces its message + stack
|
|
127
|
+
* instead of the daemon's opaque `js_error: A JavaScript exception occurred`,
|
|
128
|
+
* and a Promise return (which the daemon cannot serialize) is flagged
|
|
129
|
+
* explicitly rather than failing as "unsupported type".
|
|
130
|
+
*
|
|
131
|
+
* String scripts run through indirect eval to keep global-scope semantics;
|
|
132
|
+
* function sources are already expressions and are invoked directly.
|
|
133
|
+
* `undefined` results come back as `null` (JSON cannot carry `undefined`).
|
|
134
|
+
* Decode with {@link unwrapEvalEnvelope}.
|
|
135
|
+
*/
|
|
136
|
+
export function serializeEvalWithEnvelope(fn: string | ((...args: unknown[]) => unknown), args: unknown[]): string {
|
|
137
|
+
const inner = serializeEval(fn, args);
|
|
138
|
+
const expr = typeof fn === "string" ? `(0, eval)(${JSON.stringify(inner)})` : inner;
|
|
139
|
+
return `(() => {
|
|
140
|
+
try {
|
|
141
|
+
const __v = (${expr});
|
|
142
|
+
if (__v && typeof __v.then === "function") return { __jeopiPromise: true };
|
|
143
|
+
return { __jeopiOk: __v === undefined ? null : __v };
|
|
144
|
+
} catch (e) {
|
|
145
|
+
return { __jeopiErr: (e && (e.stack || e.message)) || String(e) };
|
|
146
|
+
}
|
|
147
|
+
})()`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Decode a {@link serializeEvalWithEnvelope} result: rethrow page-side
|
|
152
|
+
* exceptions as rich {@link ToolError}s, reject unserializable Promise
|
|
153
|
+
* returns with an actionable message, and pass through values from daemons
|
|
154
|
+
* that did not run the wrapper.
|
|
155
|
+
*/
|
|
156
|
+
export function unwrapEvalEnvelope<TResult>(value: unknown, label: string): TResult {
|
|
157
|
+
if (value && typeof value === "object") {
|
|
158
|
+
if ("__jeopiErr" in value && typeof value.__jeopiErr === "string") {
|
|
159
|
+
throw new ToolError(`${label} threw a JavaScript exception:\n${value.__jeopiErr}`);
|
|
160
|
+
}
|
|
161
|
+
if ("__jeopiPromise" in value && value.__jeopiPromise === true) {
|
|
162
|
+
throw new ToolError(
|
|
163
|
+
`${label} returned a Promise, but this surface evaluates synchronously and cannot await it — return a plain value (poll with waitForFunction for async state instead)`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if ("__jeopiOk" in value) {
|
|
167
|
+
return value.__jeopiOk as TResult;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return value as TResult;
|
|
171
|
+
}
|
|
172
|
+
|
|
123
173
|
export function mapWaitUntil(waitUntil: string | undefined): "interactive" | "complete" {
|
|
124
174
|
return waitUntil === "domcontentloaded" ? "interactive" : "complete";
|
|
125
175
|
}
|
|
@@ -99,12 +99,16 @@ async function loadBrowsers(): Promise<typeof BrowsersNs> {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
|
-
*
|
|
103
|
-
* Skipped when a system Chromium (NixOS)
|
|
104
|
-
* The browser is cached under
|
|
102
|
+
* Resolve the Chromium executable puppeteer will launch, lazily downloading it
|
|
103
|
+
* on first use via @puppeteer/browsers. Skipped when a system Chromium (NixOS)
|
|
104
|
+
* or PUPPETEER_EXECUTABLE_PATH is set. The browser is cached under
|
|
105
|
+
* ~/.jeopi/puppeteer (getPuppeteerDir). Returns undefined when platform
|
|
106
|
+
* detection fails (puppeteer default resolution takes over). Exported so
|
|
107
|
+
* real-browser tests can probe launchability and skip on hosts missing
|
|
108
|
+
* Chrome's system libraries.
|
|
105
109
|
*/
|
|
106
110
|
let chromiumExecutablePromise: Promise<string | undefined> | undefined;
|
|
107
|
-
async function ensureChromiumExecutable(): Promise<string | undefined> {
|
|
111
|
+
export async function ensureChromiumExecutable(): Promise<string | undefined> {
|
|
108
112
|
const sysChrome = resolveSystemChromium();
|
|
109
113
|
if (sysChrome) return sysChrome;
|
|
110
114
|
const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
|
|
@@ -1,11 +1,50 @@
|
|
|
1
1
|
import { untilAborted } from "jeopi-utils";
|
|
2
|
-
import { throwIfAborted } from "../tool-errors";
|
|
2
|
+
import { ToolError, throwIfAborted } from "../tool-errors";
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
/** Options for the predicate form of the run-scoped `wait()` helper. */
|
|
5
|
+
export interface WaitPredicateOptions {
|
|
6
|
+
/** Max time to poll before failing, in ms (default 30_000). */
|
|
7
|
+
timeout?: number;
|
|
8
|
+
/** Poll interval in ms (default 100, floor 10). */
|
|
9
|
+
interval?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Run-scoped `wait()` helper for evaluated browser code, honoring the owning run's
|
|
14
|
+
* cancellation signal.
|
|
15
|
+
*
|
|
16
|
+
* - `wait(ms)` sleeps for `ms` milliseconds.
|
|
17
|
+
* - `wait(fn, { timeout?, interval? })` polls `fn` (sync or async) until it returns a
|
|
18
|
+
* truthy value and resolves with that value; throws a named `ToolError` on timeout
|
|
19
|
+
* instead of stalling into the whole-cell deadline. Predicate errors propagate.
|
|
20
|
+
*/
|
|
21
|
+
export async function waitForBrowserRun(
|
|
22
|
+
msOrPredicate: number | (() => unknown),
|
|
23
|
+
signal: AbortSignal,
|
|
24
|
+
opts?: WaitPredicateOptions,
|
|
25
|
+
): Promise<unknown> {
|
|
8
26
|
throwIfAborted(signal);
|
|
27
|
+
if (typeof msOrPredicate === "number") {
|
|
28
|
+
await untilAborted(signal, async () => await Bun.sleep(msOrPredicate));
|
|
29
|
+
throwIfAborted(signal);
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
if (typeof msOrPredicate !== "function") {
|
|
33
|
+
throw new ToolError("wait(...) expects milliseconds (number) or a predicate function to poll");
|
|
34
|
+
}
|
|
35
|
+
const timeout =
|
|
36
|
+
opts?.timeout !== undefined && Number.isFinite(opts.timeout) && opts.timeout > 0 ? opts.timeout : 30_000;
|
|
37
|
+
const interval = Math.max(opts?.interval ?? 100, 10);
|
|
38
|
+
const deadline = Date.now() + timeout;
|
|
39
|
+
for (;;) {
|
|
40
|
+
const value = await untilAborted(signal, async () => await msOrPredicate());
|
|
41
|
+
throwIfAborted(signal);
|
|
42
|
+
if (value) return value;
|
|
43
|
+
if (Date.now() + interval > deadline) {
|
|
44
|
+
throw new ToolError(`wait(predicate) timed out after ${timeout}ms — predicate never returned truthy`);
|
|
45
|
+
}
|
|
46
|
+
await untilAborted(signal, async () => await Bun.sleep(interval));
|
|
47
|
+
}
|
|
9
48
|
}
|
|
10
49
|
|
|
11
50
|
/** Binds a long-lived browser facade to one evaluated run's abort signal. */
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ImageContent, TextContent } from "jeopi-ai";
|
|
2
|
+
import type { JsDisplayOutput } from "../../eval/js/shared/types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Accumulates a browser run's result entries: explicit `display()` payloads,
|
|
6
|
+
* screenshot captions/images, and buffered stream text (`console.*`, `print`,
|
|
7
|
+
* `display()` of strings/primitives — `JsRuntime.displayValue` emits those via
|
|
8
|
+
* `onText`). Stream text is buffered and flushed as one entry before the next
|
|
9
|
+
* display/screenshot (and on `finish()`) so it reaches the tool result in
|
|
10
|
+
* order instead of vanishing into the debug log.
|
|
11
|
+
*/
|
|
12
|
+
export class RunOutput {
|
|
13
|
+
readonly #displays: Array<TextContent | ImageContent> = [];
|
|
14
|
+
#textBuffer = "";
|
|
15
|
+
|
|
16
|
+
/** Buffer a stream-text chunk; it joins the entries at the next push or on finish(). */
|
|
17
|
+
pushText(chunk: string): void {
|
|
18
|
+
this.#textBuffer += chunk;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Append a `display()` payload (image/json/status), flushing buffered text first. */
|
|
22
|
+
pushDisplay(output: JsDisplayOutput): void {
|
|
23
|
+
if (output.type === "image") {
|
|
24
|
+
this.push({ type: "image", data: output.data, mimeType: output.mimeType });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (output.type === "json") {
|
|
28
|
+
this.push({ type: "text", text: safeJsonStringify(output.data) });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// status — surface as compact JSON so helper side effects (read/write/env) appear in
|
|
32
|
+
// the cell result alongside explicit display() output.
|
|
33
|
+
this.push({ type: "text", text: safeJsonStringify(output.event) });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Append a pre-built entry (e.g. a screenshot caption/image), flushing buffered text first. */
|
|
37
|
+
push(entry: TextContent | ImageContent): void {
|
|
38
|
+
this.#flush();
|
|
39
|
+
this.#displays.push(entry);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Flush any remaining stream text and return the ordered entries. */
|
|
43
|
+
finish(): Array<TextContent | ImageContent> {
|
|
44
|
+
this.#flush();
|
|
45
|
+
return this.#displays;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#flush(): void {
|
|
49
|
+
if (!this.#textBuffer) return;
|
|
50
|
+
// Entries are newline-joined at render; drop the stream's trailing newline.
|
|
51
|
+
this.#displays.push({ type: "text", text: this.#textBuffer.replace(/\n$/, "") });
|
|
52
|
+
this.#textBuffer = "";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** JSON.stringify that never throws (cycles/BigInt → String(value)). */
|
|
57
|
+
export function safeJsonStringify(value: unknown): string {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.stringify(value, null, 2);
|
|
60
|
+
} catch {
|
|
61
|
+
return String(value);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Pass a return value across the run boundary: structured-cloneable as-is, else JSON round-trip, else String. */
|
|
66
|
+
export function cloneSafe(value: unknown): unknown {
|
|
67
|
+
if (value === undefined) return undefined;
|
|
68
|
+
try {
|
|
69
|
+
structuredClone(value);
|
|
70
|
+
return value;
|
|
71
|
+
} catch {}
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(JSON.stringify(value)) as unknown;
|
|
74
|
+
} catch {}
|
|
75
|
+
return String(value);
|
|
76
|
+
}
|