@mmerterden/multi-agent-toolkit-mcp 3.0.0 → 3.1.1
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 +87 -0
- package/README.md +2 -2
- package/README.tr.md +2 -2
- package/index.js +51 -4
- package/package.json +3 -3
- package/tools/launch-time/index.js +53 -0
- package/tools/offload/index.js +201 -0
package/CHANGELOG.md
CHANGED
|
@@ -15,8 +15,95 @@ Releases before this file exists are recorded in the git tags and commit history
|
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
+
## 3.1.1
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `android_launch_time` reported `cold_start: true` on every call. It force-stops
|
|
23
|
+
the package first, which kills the process but leaves the page cache warm, so
|
|
24
|
+
the start it measures is cold only some of the time. Android 10 replaced
|
|
25
|
+
`ThisTime` with `LaunchState` (COLD, WARM, HOT, UNKNOWN), which is the
|
|
26
|
+
platform's own verdict on the start it just performed, and the tool was
|
|
27
|
+
ignoring it while asserting the answer itself. The result now carries
|
|
28
|
+
`launch_state` and derives `cold_start` from it. Below Android 10 nothing
|
|
29
|
+
reports a launch state, so `cold_start` is `null` there rather than a claim
|
|
30
|
+
nothing checked. A failed launch now surfaces its `Error:` line instead of
|
|
31
|
+
returning null timings that read like a measurement.
|
|
32
|
+
- The parser moved to `tools/launch-time/` with a suite covering the Android 10+
|
|
33
|
+
and pre-10 output shapes, a warm start, a failed launch and non-string input.
|
|
34
|
+
It could not be tested where it was: `index.js` connects its transport at
|
|
35
|
+
import time, so nothing in it is reachable from a test.
|
|
36
|
+
|
|
18
37
|
## Unreleased
|
|
19
38
|
|
|
39
|
+
## 3.1.0
|
|
40
|
+
|
|
41
|
+
One new tool and the offload path behind it. Minor rather than patch because the
|
|
42
|
+
tool surface grew: pipeline-side minimums that need `agent_query_output` should
|
|
43
|
+
declare `>= 3.1.0`.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- **`agent_query_output` (tool 84) and large-result offload.** A UI tree, a
|
|
48
|
+
logcat window or an xcresult dump can be tens of thousands of lines. The
|
|
49
|
+
server met that with two lossy caps: `spawnCollect` dropped the OLDEST chunks
|
|
50
|
+
at `SPAWN_OUTPUT_CAP`, and error text was cut at 600 chars. For a build log or
|
|
51
|
+
a test run the interesting part is the END, so "truncated" often meant the
|
|
52
|
+
failure was gone; `ios_get_ui_tree` could write a file, but only when the
|
|
53
|
+
caller thought to pass `path`.
|
|
54
|
+
|
|
55
|
+
Prose payloads over 24 KB are now written whole to
|
|
56
|
+
`~/.claude/logs/multi-agent-toolkit/<tool>-<timestamp>.txt`, and the tool
|
|
57
|
+
returns a head + tail window with the line count and the path.
|
|
58
|
+
`agent_query_output {pattern, path?, context_lines?, max_matches?,
|
|
59
|
+
ignore_case?}` searches that file and returns matching lines with numbered
|
|
60
|
+
context, so a follow-up question does not mean re-running an expensive tool -
|
|
61
|
+
and for a UI dump a second run is not even the same evidence.
|
|
62
|
+
|
|
63
|
+
Only prose is offloaded: a tool that declares an `outputSchema` answers with
|
|
64
|
+
JSON the host parses as `structuredContent`, and a summary would break that
|
|
65
|
+
parse. A write failure falls back to returning the payload whole, because
|
|
66
|
+
spending context is recoverable and dropping the tail is not. Bad regex,
|
|
67
|
+
missing file and nothing-offloaded are reported as `ERROR:` strings, never
|
|
68
|
+
thrown.
|
|
69
|
+
|
|
70
|
+
Pattern source: the multi-agent pipeline's own Phase 4 Step 1.9 (cap the
|
|
71
|
+
diff, write the full copy to `.review-diff.txt`, leave a marker in the
|
|
72
|
+
prompt), which is the same shape yamadashy/repomix uses for packed output.
|
|
73
|
+
Logic lives in `tools/offload/` with 17 unit tests covering the threshold,
|
|
74
|
+
byte-for-byte preservation, tail retention, retrieval of content the inline
|
|
75
|
+
window omitted, the match cap, and every failure mode.
|
|
76
|
+
|
|
77
|
+
### Fixed (same release, found by reviewing the above)
|
|
78
|
+
|
|
79
|
+
- **The failure path kept none of this.** `run()` / `runAsync()` cut a failed
|
|
80
|
+
command's output to `ERROR_MAX_CHARS` (600) at the source, and the CallTool
|
|
81
|
+
handler returns on `isFailure` before the offload branch - so the case the
|
|
82
|
+
offload was justified by (xcodebuild puts the failing assertion and the
|
|
83
|
+
compiler error at the END) was untouched, and only successful large outputs
|
|
84
|
+
were saved. `truncateError` now offloads the full text and returns a
|
|
85
|
+
character-window head + tail with the path; the old head-only cut remains
|
|
86
|
+
only as the fallback when the write fails.
|
|
87
|
+
- **`agent_query_output` accepted any absolute path.** It resolved
|
|
88
|
+
`args.path` unvalidated, which made a tool whose purpose is "read back what
|
|
89
|
+
this server saved" into an arbitrary-file reader - the opposite direction from
|
|
90
|
+
the 2.26.0 hardening. A caller-supplied path is now confined to the offload
|
|
91
|
+
directory, and confinement is checked BEFORE existence so the refusal cannot
|
|
92
|
+
be used as an existence oracle for paths outside it. The path this server
|
|
93
|
+
recorded itself needs no check: it wrote it.
|
|
94
|
+
- **The offload directory had no retention owner.** It grew without bound at
|
|
95
|
+
24 KB per entry; the pipeline's `prune-logs` targets a different path and an
|
|
96
|
+
MCP-only user has no pipeline at all. A successful write now prunes the
|
|
97
|
+
directory to the newest 50 files and drops anything older than 7 days, with
|
|
98
|
+
non-`.txt` files and unreadable directories left alone. The file just written
|
|
99
|
+
is never pruned regardless of the retention numbers - review round 1 found
|
|
100
|
+
that a `keepFiles` of 0 deleted it while the returned text still promised its
|
|
101
|
+
path, which is the payload loss this module exists to prevent.
|
|
102
|
+
- **`agent_query_output` carried no `readOnlyHint`.** It was absent from
|
|
103
|
+
`READ_ONLY_TOOLS`, and the test asserting its contract said "read-only" in its
|
|
104
|
+
name while checking only the input schema. Both fixed; the annotation is now
|
|
105
|
+
asserted.
|
|
106
|
+
|
|
20
107
|
## 2.26.0
|
|
21
108
|
|
|
22
109
|
Security-hardening release from a multi-agent refactor audit. No tool added or
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
🇹🇷 Türkçe: [README.tr.md](./README.tr.md)
|
|
9
9
|
|
|
10
|
-
**
|
|
10
|
+
**84 tools** for iOS Simulator, Android Emulator, and headless web control. MCP server that lets your AI coding assistant see, interact with, and audit your mobile apps - plus drive browsers, run an 18-rule App Store compliance audit, and orchestrate multi-step batch flows.
|
|
11
11
|
|
|
12
12
|
Distributed on the **public npm registry** - `npx @mmerterden/multi-agent-toolkit-mcp` resolves with no auth, no token, no `~/.npmrc` setup.
|
|
13
13
|
|
|
@@ -22,7 +22,7 @@ That distinction is worth keeping straight. This line once called five hosts "th
|
|
|
22
22
|
- **Store Compliance** (5 tools) - App Store / Play Store readiness; **18-rule deep `ios_app_store_audit`** cross-references Apple ITMS error codes + App Store Review Guidelines (privacy manifest, required-reason API, Info.plist, code signing, entitlements, embedded SDK, IPv6, debug-tool leak, ...)
|
|
23
23
|
- **Web Automation** (8 tools) - Playwright-powered: goto, click (CSS selectors), type, eval JS, wait for selector, extract text, screenshot. Chromium / WebKit / Firefox engines. **Requires `playwright` peer dependency.**
|
|
24
24
|
- **Design Audit** (6 tools) - mock-mode vs Figma conformance: scenario inventory, mock detection, mock launch, live UI geometry, pixel/geometry/typography compare, and the HTML/PDF report with its coverage gate
|
|
25
|
-
- **Autonomous Agent DSL** (
|
|
25
|
+
- **Autonomous Agent DSL** (2 tools) - `agent_run_steps` executes a batch array of {tool, args, continue_on_error?, wait_ms?} steps in one MCP round trip. Ideal for scripted login flows, form fills, multi-step QA paths. `agent_query_output` searches the full output of an earlier call that was too large to return inline, so a follow-up question does not mean re-running an expensive tool.
|
|
26
26
|
|
|
27
27
|
## Quick Start
|
|
28
28
|
|
package/README.tr.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
🇬🇧 English: [README.md](./README.md)
|
|
9
9
|
|
|
10
|
-
iOS Simulator, Android Emulator ve headless web kontrolü için **
|
|
10
|
+
iOS Simulator, Android Emulator ve headless web kontrolü için **84 araç**. AI kodlama asistanının mobil uygulamalarını görmesini, onlarla etkileşime girmesini ve denetlemesini sağlayan bir MCP sunucusu - ayrıca tarayıcıları sürer, 18-kurallık bir App Store uyumluluk denetimi çalıştırır ve çok-adımlı batch akışlarını orkestre eder.
|
|
11
11
|
|
|
12
12
|
**Public npm registry** üzerinden dağıtılır - `npx @mmerterden/multi-agent-toolkit-mcp`, auth'suz, token'sız, `~/.npmrc` ayarı gerekmeden çözülür.
|
|
13
13
|
|
|
@@ -22,7 +22,7 @@ Bu ayrımı net tutmakta fayda var. Bu satır bir zamanlar beş host'u "multi-ag
|
|
|
22
22
|
- **Store Compliance** (5 araç) - App Store / Play Store hazırlığı; **18-kurallık derin `ios_app_store_audit`**, Apple ITMS hata kodları + App Store Review Guidelines'a çapraz referans verir (privacy manifest, required-reason API, Info.plist, code signing, entitlements, gömülü SDK, IPv6, debug-tool sızıntısı, ...)
|
|
23
23
|
- **Web Automation** (8 araç) - Playwright-destekli: goto, click (CSS selector'lar), type, JS eval, selector bekleme, metin çıkarma, screenshot. Chromium / WebKit / Firefox motorları. **`playwright` peer dependency'si gerektirir.**
|
|
24
24
|
- **Design Audit** (6 araç) - mock-mode vs Figma uygunluğu: scenario envanteri, mock tespiti, mock launch, canlı UI geometrisi, piksel/geometri/tipografi karşılaştırması, ve coverage kapısıyla birlikte HTML/PDF rapor
|
|
25
|
-
- **Autonomous Agent DSL** (
|
|
25
|
+
- **Autonomous Agent DSL** (2 araç) - `agent_run_steps`, tek bir MCP round trip'inde {tool, args, continue_on_error?, wait_ms?} adımlarından oluşan bir batch dizisini çalıştırır. Scriptlenmiş login akışları, form doldurma, çok-adımlı QA yolları için ideal. `agent_query_output`, satır içi dönemeyecek kadar büyük olan önceki bir çağrının tam çıktısını arar; böylece bir takip sorusu pahalı aracı yeniden koşturmak anlamına gelmez.
|
|
26
26
|
|
|
27
27
|
## Hızlı Başlangıç
|
|
28
28
|
|
package/index.js
CHANGED
|
@@ -28,8 +28,14 @@ import {
|
|
|
28
28
|
validateApp,
|
|
29
29
|
} from "./tools/ios-testflight/index.js";
|
|
30
30
|
import { DESIGN_TOOLS, handleDesign } from "./tools/design-check/index.js";
|
|
31
|
+
import { parseLaunchOutput } from "./tools/launch-time/index.js";
|
|
31
32
|
import { interactiveElements } from "./tools/ui-inspect/index.js";
|
|
32
33
|
import { selectCrashReports } from "./tools/crash-logs/index.js";
|
|
34
|
+
import {
|
|
35
|
+
offloadLargeText,
|
|
36
|
+
queryOffloadedOutput,
|
|
37
|
+
offloadedErrorSummary,
|
|
38
|
+
} from "./tools/offload/index.js";
|
|
33
39
|
|
|
34
40
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
35
41
|
const SCREENSHOT_DIR = join(process.env.TMPDIR || "/tmp", "mobile-dev-mcp");
|
|
@@ -47,9 +53,16 @@ const ERROR_PREFIX = "ERROR: ";
|
|
|
47
53
|
// head of the message.
|
|
48
54
|
const ERROR_MAX_CHARS = 600;
|
|
49
55
|
|
|
56
|
+
// A long failure is the case where the dropped part matters most: xcodebuild
|
|
57
|
+
// puts the failing assertion and the compiler error at the END of its output, so
|
|
58
|
+
// cutting at ERROR_MAX_CHARS deleted the answer and kept the banner. The full
|
|
59
|
+
// text now goes to disk and the caller gets a head + tail window with the path.
|
|
60
|
+
// Only the fallback - when the write fails - is the old head-only truncation.
|
|
50
61
|
function truncateError(msg) {
|
|
51
62
|
const flat = String(msg).trim();
|
|
52
63
|
if (flat.length <= ERROR_MAX_CHARS) return flat;
|
|
64
|
+
const { offloaded, path } = offloadLargeText("error", flat, { minChars: ERROR_MAX_CHARS });
|
|
65
|
+
if (offloaded) return offloadedErrorSummary(flat, path);
|
|
53
66
|
return `${flat.slice(0, ERROR_MAX_CHARS)}\n... [${flat.length - ERROR_MAX_CHARS} more chars truncated]`;
|
|
54
67
|
}
|
|
55
68
|
|
|
@@ -817,7 +830,7 @@ const ANDROID_TOOLS = [
|
|
|
817
830
|
{ name: "android_open_url", description: "Open URL or deep link on Android", inputSchema: { type: "object", properties: { url: { type: "string" }, device_id: { type: "string" } }, required: ["url"] } },
|
|
818
831
|
{ name: "android_clear_app_data", description: "Clear all data for Android app", inputSchema: { type: "object", properties: { package_name: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
|
|
819
832
|
{ name: "android_accessibility_audit", description: "Audit Android app accessibility: missing contentDescription, small touch targets (<48dp), missing resource-id. Use scope to filter by resource-id prefix.", inputSchema: { type: "object", properties: { device_id: { type: "string" }, scope: { type: "string", description: "Filter: only audit elements whose resource-id contains this prefix (e.g. 'login_', 'com.example:id/login_'). Omit to audit all." } } } },
|
|
820
|
-
{ name: "android_launch_time", description: "Measure Android app
|
|
833
|
+
{ name: "android_launch_time", description: "Measure Android app launch time: force-stops the package, starts it with am start -W, and reports TotalTime/WaitTime in ms plus the platform's own LaunchState (COLD/WARM/HOT). Below Android 10 there is no LaunchState and cold_start is null rather than assumed.", inputSchema: { type: "object", properties: { package_name: { type: "string" }, activity: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
|
|
821
834
|
{ name: "android_apk_audit", description: "Audit APK/AAB for Play Store compliance: debug flag, target SDK, permissions, signing, ProGuard", inputSchema: { type: "object", properties: { apk_path: { type: "string", description: "Path to .apk file" } }, required: ["apk_path"] } },
|
|
822
835
|
{ name: "android_list_crashes", description: "Dump the Android crash log buffer (`adb logcat -b crash -d`), tail-bounded. Empty output means no crashes since the buffer was last cleared.", inputSchema: { type: "object", properties: { lines: { type: "number", description: "Max lines returned, from the end (default 200)" }, device_id: { type: "string" } } } },
|
|
823
836
|
{ name: "android_set_orientation", description: "Rotate the Android screen to portrait or landscape. Disables accelerometer rotation and pins user_rotation, so the device stays put until rotation is re-enabled. Accounts for the device's natural orientation (detected via wm size), so landscape-natural tablets rotate correctly too. No iOS counterpart: simctl exposes no rotation lever.", inputSchema: { type: "object", properties: { orientation: { type: "string", enum: ["portrait", "landscape"] }, device_id: { type: "string" } }, required: ["orientation"] } },
|
|
@@ -1008,9 +1021,19 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1008
1021
|
run(`adb ${df} shell am force-stop ${sanitizeId(args.package_name)}`);
|
|
1009
1022
|
const activity = sanitizeId(args.activity || `${args.package_name}/.MainActivity`);
|
|
1010
1023
|
const result = run(`adb ${df} shell am start -W -n ${activity} 2>&1`);
|
|
1011
|
-
const
|
|
1012
|
-
|
|
1013
|
-
|
|
1024
|
+
const parsed = parseLaunchOutput(result);
|
|
1025
|
+
return JSON.stringify({
|
|
1026
|
+
package: args.package_name,
|
|
1027
|
+
// The platform's own verdict, not ours: force-stop kills the process but
|
|
1028
|
+
// leaves the page cache warm, so this is a cold start only sometimes.
|
|
1029
|
+
// null means the device is below Android 10 and never reported one.
|
|
1030
|
+
launch_state: parsed.launchState,
|
|
1031
|
+
cold_start: parsed.coldStart,
|
|
1032
|
+
total_time_ms: parsed.totalTimeMs,
|
|
1033
|
+
wait_time_ms: parsed.waitTimeMs,
|
|
1034
|
+
error: parsed.error,
|
|
1035
|
+
raw: result,
|
|
1036
|
+
}, null, 2);
|
|
1014
1037
|
}
|
|
1015
1038
|
case "android_apk_audit": {
|
|
1016
1039
|
const p = args.apk_path;
|
|
@@ -1197,6 +1220,20 @@ const AGENT_TOOLS = [
|
|
|
1197
1220
|
required: ["steps"],
|
|
1198
1221
|
},
|
|
1199
1222
|
},
|
|
1223
|
+
{
|
|
1224
|
+
name: "agent_query_output",
|
|
1225
|
+
description: "Search the full output of a previous tool call that was too large to return inline. When a tool's payload is offloaded, its text ends with the path of the saved file; this reads that file (the most recent one by default) and returns only the matching lines with context. Use it instead of re-running an expensive tool - re-running a UI dump gives a different tree, so a second call is not the same evidence.",
|
|
1226
|
+
inputSchema: {
|
|
1227
|
+
type: "object",
|
|
1228
|
+
properties: {
|
|
1229
|
+
pattern: { type: "string", description: "JavaScript regular expression, matched per line. Omit to get the file's head." },
|
|
1230
|
+
path: { type: "string", description: "Which offloaded file to read. Defaults to the most recent one this server wrote." },
|
|
1231
|
+
context_lines: { type: "number", description: "Lines of context around each match. Default 2." },
|
|
1232
|
+
max_matches: { type: "number", description: "Stop after this many matches. Default 50." },
|
|
1233
|
+
ignore_case: { type: "boolean", description: "Case-insensitive match. Default false." },
|
|
1234
|
+
},
|
|
1235
|
+
},
|
|
1236
|
+
},
|
|
1200
1237
|
];
|
|
1201
1238
|
|
|
1202
1239
|
// design_* is dispatched here too: the tool description offers it, and a design
|
|
@@ -1221,6 +1258,7 @@ async function dispatchStep(tool, stepArgs) {
|
|
|
1221
1258
|
}
|
|
1222
1259
|
|
|
1223
1260
|
async function handleAgent(name, args) {
|
|
1261
|
+
if (name === "agent_query_output") return queryOffloadedOutput(args, ERROR_PREFIX);
|
|
1224
1262
|
if (name !== "agent_run_steps") return null;
|
|
1225
1263
|
const steps = Array.isArray(args.steps) ? args.steps : [];
|
|
1226
1264
|
const stopOnError = args.stop_on_first_error !== false;
|
|
@@ -1341,6 +1379,7 @@ function validateArgs(name, args) {
|
|
|
1341
1379
|
// for deciding what may run unattended, so the classification lives here in one
|
|
1342
1380
|
// auditable place rather than inline on every tool literal.
|
|
1343
1381
|
const READ_ONLY_TOOLS = new Set([
|
|
1382
|
+
"agent_query_output",
|
|
1344
1383
|
"ios_list_devices", "ios_screenshot", "ios_list_apps", "ios_get_ui_tree", "ios_get_app_container",
|
|
1345
1384
|
"ios_accessibility_audit", "ios_archive_audit", "ios_app_store_audit", "ios_xcresult", "ios_visual_diff",
|
|
1346
1385
|
"ios_list_crashes",
|
|
@@ -1700,6 +1739,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
|
1700
1739
|
if (isFailure(result)) {
|
|
1701
1740
|
return { content: [{ type: "text", text: String(result) }], isError: true };
|
|
1702
1741
|
}
|
|
1742
|
+
// Prose payloads only: a tool with an outputSchema answers with JSON the
|
|
1743
|
+
// caller parses, and a head+tail summary would break that parse. The query
|
|
1744
|
+
// tool is exempt for the obvious reason.
|
|
1745
|
+
if (!OUTPUT_SCHEMAS[name] && name !== "agent_query_output") {
|
|
1746
|
+
const { text, offloaded } = offloadLargeText(name, String(result));
|
|
1747
|
+
if (offloaded) return { content: [{ type: "text", text }] };
|
|
1748
|
+
return withStructured(name, text);
|
|
1749
|
+
}
|
|
1703
1750
|
return withStructured(name, String(result));
|
|
1704
1751
|
} catch (e) {
|
|
1705
1752
|
return {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmerterden/multi-agent-toolkit-mcp",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "MCP server for iOS Simulator, Android Emulator and headless web control.
|
|
3
|
+
"version": "3.1.1",
|
|
4
|
+
"description": "MCP server for iOS Simulator, Android Emulator and headless web control. 84 tools: device automation (tap/swipe/type), accessibility audits, visual diff, crash logs, App Store / Play Store pre-submission compliance. Runs standalone over stdio with any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"start": "node index.js",
|
|
13
|
-
"test": "node --test tools/design-check/__tests__/design-check.test.mjs tools/design-check/__tests__/plan-determinism.test.mjs tools/ios-app-store-audit/__tests__/app-store-audit.test.mjs tools/ios-testflight/__tests__/testflight.test.mjs tools/ui-inspect/__tests__/ui-inspect.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
|
|
13
|
+
"test": "node --test tools/design-check/__tests__/design-check.test.mjs tools/design-check/__tests__/plan-determinism.test.mjs tools/ios-app-store-audit/__tests__/app-store-audit.test.mjs tools/ios-testflight/__tests__/testflight.test.mjs tools/ui-inspect/__tests__/ui-inspect.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs tools/launch-time/__tests__/launch-time.test.mjs tools/offload/__tests__/offload.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
|
|
14
14
|
"gates": "bash scripts/gates.sh"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* launch-time - parse `am start -W` output.
|
|
3
|
+
*
|
|
4
|
+
* Lives here rather than inline in index.js because index.js connects its
|
|
5
|
+
* transport at import time and cannot be loaded by a test.
|
|
6
|
+
*
|
|
7
|
+
* The handler force-stops the package first, which kills the process but leaves
|
|
8
|
+
* the page cache warm, so it produces a cold start only some of the time. The
|
|
9
|
+
* tool used to report `cold_start: true` unconditionally, which is an assertion
|
|
10
|
+
* about something the platform already measures: Android 10 replaced `ThisTime`
|
|
11
|
+
* with `LaunchState`, one of COLD, WARM, HOT or UNKNOWN, and that is the
|
|
12
|
+
* system's own verdict on the start it just performed. Below Android 10 there is
|
|
13
|
+
* no LaunchState line, and the honest answer there is "unknown" rather than a
|
|
14
|
+
* claim nothing checked.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const LAUNCH_STATES = new Set(["COLD", "WARM", "HOT", "UNKNOWN"]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} raw - stdout+stderr of `am start -W -n <activity>`
|
|
21
|
+
* @returns {{status: string|null, launchState: string|null, coldStart: boolean|null,
|
|
22
|
+
* activity: string|null, totalTimeMs: number|null, waitTimeMs: number|null,
|
|
23
|
+
* error: string|null}}
|
|
24
|
+
*/
|
|
25
|
+
export function parseLaunchOutput(raw) {
|
|
26
|
+
const text = typeof raw === "string" ? raw : "";
|
|
27
|
+
const field = (name) => text.match(new RegExp(`^\\s*${name}:\\s*(.+?)\\s*$`, "m"))?.[1] ?? null;
|
|
28
|
+
const intField = (name) => {
|
|
29
|
+
const v = text.match(new RegExp(`^\\s*${name}:\\s*(\\d+)\\s*$`, "m"))?.[1];
|
|
30
|
+
return v === undefined ? null : parseInt(v, 10);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const rawState = field("LaunchState");
|
|
34
|
+
const launchState = rawState && LAUNCH_STATES.has(rawState.toUpperCase())
|
|
35
|
+
? rawState.toUpperCase()
|
|
36
|
+
: null;
|
|
37
|
+
|
|
38
|
+
// `am start` reports a failure on a line of its own and still exits 0, so a
|
|
39
|
+
// missing TotalTime with an Error line is a failed launch, not a parse miss.
|
|
40
|
+
const errorLine = text.match(/^\s*Error:\s*(.+?)\s*$/m)?.[1] ?? null;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
status: field("Status"),
|
|
44
|
+
launchState,
|
|
45
|
+
// COLD is the only state that is a cold start. Below Android 10 nothing
|
|
46
|
+
// reports it, so null means "not measured", never "no".
|
|
47
|
+
coldStart: launchState === null ? null : launchState === "COLD",
|
|
48
|
+
activity: field("Activity"),
|
|
49
|
+
totalTimeMs: intField("TotalTime"),
|
|
50
|
+
waitTimeMs: intField("WaitTime"),
|
|
51
|
+
error: errorLine,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Large-result offload.
|
|
2
|
+
//
|
|
3
|
+
// A UI tree, a logcat window or an xcresult dump can be tens of thousands of
|
|
4
|
+
// lines. Returning it whole spends the caller's whole context; truncating it in
|
|
5
|
+
// place - which is what this server used to do, dropping the oldest chunks at
|
|
6
|
+
// SPAWN_OUTPUT_CAP and cutting error text at 600 chars - throws the tail away,
|
|
7
|
+
// and the tail is where the assertion failure and the crash frame live.
|
|
8
|
+
//
|
|
9
|
+
// So: write the payload to a file, return a head + tail window plus the path,
|
|
10
|
+
// and let the caller pull what it needs with agent_query_output. The pipeline
|
|
11
|
+
// already solved the same problem this way in Phase 4 Step 1.9 (diff cap, full
|
|
12
|
+
// diff to `.review-diff.txt`, marker in the prompt); this is that pattern.
|
|
13
|
+
//
|
|
14
|
+
// Only prose payloads are offloaded by the caller. A tool that declares an
|
|
15
|
+
// outputSchema answers with JSON the host parses as structuredContent, and
|
|
16
|
+
// replacing that with a summary would break the parse.
|
|
17
|
+
|
|
18
|
+
import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from "fs";
|
|
19
|
+
import { join, resolve, sep } from "path";
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
|
|
22
|
+
export const OFFLOAD_DIR = join(homedir(), ".claude", "logs", "multi-agent-toolkit");
|
|
23
|
+
export const OFFLOAD_MIN_CHARS = 24 * 1024;
|
|
24
|
+
const HEAD_LINES = 40;
|
|
25
|
+
const TAIL_LINES = 20;
|
|
26
|
+
|
|
27
|
+
// Retention lives with the writer, not with an external cleaner. An MCP-only
|
|
28
|
+
// user has no pipeline installed, so "some other tool prunes it" would mean
|
|
29
|
+
// nobody does, and these files are 24 KB and up. Newest-N plus an age cut, both
|
|
30
|
+
// applied after a successful write, cost one readdir.
|
|
31
|
+
export const OFFLOAD_KEEP_FILES = 50;
|
|
32
|
+
export const OFFLOAD_KEEP_DAYS = 7;
|
|
33
|
+
|
|
34
|
+
// `keep` is the file the caller just wrote. It is never deleted, whatever the
|
|
35
|
+
// retention numbers say: the returned text promises that path to the caller, and
|
|
36
|
+
// this module exists precisely because losing the payload is the failure mode. A
|
|
37
|
+
// keepFiles of 0 must bound the directory, not break the answer.
|
|
38
|
+
export function pruneOffloadDir(dir, opts = {}) {
|
|
39
|
+
const keepFiles = opts.keepFiles ?? OFFLOAD_KEEP_FILES;
|
|
40
|
+
const keepDays = opts.keepDays ?? OFFLOAD_KEEP_DAYS;
|
|
41
|
+
const now = opts.now ?? Date.now();
|
|
42
|
+
const keep = typeof opts.keep === "string" ? resolve(opts.keep) : null;
|
|
43
|
+
const cutoff = now - keepDays * 24 * 60 * 60 * 1000;
|
|
44
|
+
let removed = 0;
|
|
45
|
+
try {
|
|
46
|
+
const entries = readdirSync(dir)
|
|
47
|
+
.filter((n) => n.endsWith(".txt"))
|
|
48
|
+
.map((n) => {
|
|
49
|
+
const full = join(dir, n);
|
|
50
|
+
try {
|
|
51
|
+
return { full, mtime: statSync(full).mtimeMs };
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < entries.length; i++) {
|
|
60
|
+
if (keep && resolve(entries[i].full) === keep) continue;
|
|
61
|
+
const tooOld = entries[i].mtime < cutoff;
|
|
62
|
+
const tooMany = i >= keepFiles;
|
|
63
|
+
if (!tooOld && !tooMany) continue;
|
|
64
|
+
try {
|
|
65
|
+
unlinkSync(entries[i].full);
|
|
66
|
+
removed++;
|
|
67
|
+
} catch {
|
|
68
|
+
// A file another process holds open is skipped, not fatal.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// No directory yet, or an unreadable one: nothing to prune.
|
|
73
|
+
}
|
|
74
|
+
return removed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let lastOffload = null;
|
|
78
|
+
|
|
79
|
+
export function lastOffloadRecord() {
|
|
80
|
+
return lastOffload;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function resetOffloadState() {
|
|
84
|
+
lastOffload = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Errors arrive as one or two long lines, so the line-based window degenerates:
|
|
88
|
+
// head and tail would both be the whole message. This builds the equivalent
|
|
89
|
+
// window in characters and is used by the failure path.
|
|
90
|
+
export function offloadedErrorSummary(text, path, headChars = 400, tailChars = 250) {
|
|
91
|
+
if (text.length <= headChars + tailChars) return text;
|
|
92
|
+
const omitted = text.length - headChars - tailChars;
|
|
93
|
+
return (
|
|
94
|
+
`${text.slice(0, headChars)}\n... [${omitted} chars omitted. Full output at ${path} - ` +
|
|
95
|
+
`read it with agent_query_output {pattern: "..."} ] ...\n${text.slice(-tailChars)}`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function offloadLargeText(tool, text, opts = {}) {
|
|
100
|
+
const dir = opts.dir ?? OFFLOAD_DIR;
|
|
101
|
+
const minChars = opts.minChars ?? OFFLOAD_MIN_CHARS;
|
|
102
|
+
const stamp = opts.stamp ?? new Date().toISOString().replace(/[:.]/g, "-");
|
|
103
|
+
if (text.length < minChars) return { text, offloaded: false };
|
|
104
|
+
|
|
105
|
+
let path;
|
|
106
|
+
try {
|
|
107
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
108
|
+
path = join(dir, `${tool}-${stamp}.txt`);
|
|
109
|
+
writeFileSync(path, text);
|
|
110
|
+
} catch {
|
|
111
|
+
// A write failure must not lose the payload: fall back to returning it
|
|
112
|
+
// whole. Spending context is recoverable; dropping the tail is not.
|
|
113
|
+
return { text, offloaded: false };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
pruneOffloadDir(dir, { ...opts, keep: path });
|
|
117
|
+
|
|
118
|
+
const lines = text.split("\n");
|
|
119
|
+
lastOffload = { tool, path, lines: lines.length, bytes: text.length };
|
|
120
|
+
const head = lines.slice(0, HEAD_LINES).join("\n");
|
|
121
|
+
const tail = lines.slice(-TAIL_LINES).join("\n");
|
|
122
|
+
const hidden = Math.max(0, lines.length - HEAD_LINES - TAIL_LINES);
|
|
123
|
+
return {
|
|
124
|
+
offloaded: true,
|
|
125
|
+
path,
|
|
126
|
+
text:
|
|
127
|
+
`${head}\n\n[... ${hidden} line(s) not shown. Full output (${lines.length} lines, ` +
|
|
128
|
+
`${text.length} bytes) saved to ${path} - search it with ` +
|
|
129
|
+
`agent_query_output {pattern: "..."} instead of re-running this tool ...]\n\n${tail}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A caller-supplied path is confined to the offload directory. The tool's whole
|
|
134
|
+
// purpose is "read back the file this server just wrote", and accepting any
|
|
135
|
+
// absolute path made it an arbitrary-file reader - which contradicts the 2.26.0
|
|
136
|
+
// hardening pass rather than extending it. The path this server itself recorded
|
|
137
|
+
// needs no check: it wrote it.
|
|
138
|
+
function pathAllowed(candidate, allowedDirs) {
|
|
139
|
+
const target = resolve(candidate);
|
|
140
|
+
return allowedDirs.some((dir) => {
|
|
141
|
+
const root = resolve(dir);
|
|
142
|
+
return target === root || target.startsWith(root + sep);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Reads back an offloaded payload. Returns matching lines with context rather
|
|
147
|
+
// than the file, so a 40k-line UI tree answers a question without becoming the
|
|
148
|
+
// answer. A bad regex is reported, never thrown: the caller can fix a pattern
|
|
149
|
+
// but cannot fix a crashed server.
|
|
150
|
+
export function queryOffloadedOutput(args = {}, errorPrefix = "ERROR: ", opts = {}) {
|
|
151
|
+
const allowedDirs = opts.allowedDirs ?? [OFFLOAD_DIR];
|
|
152
|
+
const asked = typeof args.path === "string" && args.path ? args.path : null;
|
|
153
|
+
if (asked && !pathAllowed(asked, allowedDirs)) {
|
|
154
|
+
return `${errorPrefix}path outside the offload directory is refused: ${asked}. This tool reads only what this server saved under ${allowedDirs.join(", ")}.`;
|
|
155
|
+
}
|
|
156
|
+
const path = asked ?? lastOffload?.path;
|
|
157
|
+
if (!path) {
|
|
158
|
+
return `${errorPrefix}no offloaded output to search: nothing has been saved in this session, and no path was given`;
|
|
159
|
+
}
|
|
160
|
+
if (!existsSync(path)) {
|
|
161
|
+
return `${errorPrefix}offloaded output not found at ${path}`;
|
|
162
|
+
}
|
|
163
|
+
let body;
|
|
164
|
+
try {
|
|
165
|
+
body = readFileSync(path, "utf8");
|
|
166
|
+
} catch (e) {
|
|
167
|
+
return `${errorPrefix}cannot read ${path}: ${e.message}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const lines = body.split("\n");
|
|
171
|
+
const contextLines = Number.isFinite(args.context_lines)
|
|
172
|
+
? Math.max(0, Math.trunc(args.context_lines))
|
|
173
|
+
: 2;
|
|
174
|
+
const maxMatches = Number.isFinite(args.max_matches) ? Math.max(1, Math.trunc(args.max_matches)) : 50;
|
|
175
|
+
|
|
176
|
+
if (typeof args.pattern !== "string" || args.pattern === "") {
|
|
177
|
+
return `${path} (${lines.length} lines)\n\n${lines.slice(0, 60).join("\n")}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let re;
|
|
181
|
+
try {
|
|
182
|
+
re = new RegExp(args.pattern, args.ignore_case ? "i" : "");
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return `${errorPrefix}invalid pattern: ${e.message}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const blocks = [];
|
|
188
|
+
let matches = 0;
|
|
189
|
+
for (let i = 0; i < lines.length && matches < maxMatches; i++) {
|
|
190
|
+
if (!re.test(lines[i])) continue;
|
|
191
|
+
matches++;
|
|
192
|
+
const from = Math.max(0, i - contextLines);
|
|
193
|
+
const to = Math.min(lines.length - 1, i + contextLines);
|
|
194
|
+
const chunk = [];
|
|
195
|
+
for (let j = from; j <= to; j++) chunk.push(`${j + 1}${j === i ? ":" : "-"} ${lines[j]}`);
|
|
196
|
+
blocks.push(chunk.join("\n"));
|
|
197
|
+
}
|
|
198
|
+
if (!matches) return `${path}: no line matches /${args.pattern}/ (${lines.length} lines searched)`;
|
|
199
|
+
const capped = matches >= maxMatches ? ` (stopped at max_matches=${maxMatches})` : "";
|
|
200
|
+
return `${path}: ${matches} match(es)${capped}\n\n${blocks.join("\n--\n")}`;
|
|
201
|
+
}
|