@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 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
- **83 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.
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** (1 tool) - `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.
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 **83 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.
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** (1 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.
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 cold launch time (TotalTime in ms)", inputSchema: { type: "object", properties: { package_name: { type: "string" }, activity: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
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 totalTime = result.match(/TotalTime:\s*(\d+)/)?.[1];
1012
- const waitTime = result.match(/WaitTime:\s*(\d+)/)?.[1];
1013
- return JSON.stringify({ package: args.package_name, cold_start: true, total_time_ms: totalTime ? parseInt(totalTime) : null, wait_time_ms: waitTime ? parseInt(waitTime) : null, raw: result }, null, 2);
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.0.0",
4
- "description": "MCP server for iOS Simulator, Android Emulator and headless web control. 83 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.",
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
+ }