@shanepadgett/tau-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +30 -0
  2. package/docs/extending-tau-agent.md +119 -0
  3. package/docs/subagents.md +57 -0
  4. package/docs/tui.md +107 -0
  5. package/extensions/appshot/README.md +11 -0
  6. package/extensions/appshot/capture.swift +191 -0
  7. package/extensions/appshot/index.ts +245 -0
  8. package/extensions/appshot/native-helper.ts +102 -0
  9. package/extensions/attention/README.md +23 -0
  10. package/extensions/attention/index.ts +71 -0
  11. package/extensions/auto-name/README.md +26 -0
  12. package/extensions/auto-name/index.ts +163 -0
  13. package/extensions/branch/README.md +22 -0
  14. package/extensions/branch/index.ts +145 -0
  15. package/extensions/branch/panel.ts +148 -0
  16. package/extensions/clear-screen/README.md +29 -0
  17. package/extensions/clear-screen/index.ts +75 -0
  18. package/extensions/commit/README.md +15 -0
  19. package/extensions/commit/commit-plan.ts +328 -0
  20. package/extensions/commit/git-change-set.ts +268 -0
  21. package/extensions/commit/index.ts +138 -0
  22. package/extensions/commit/review-ui.ts +745 -0
  23. package/extensions/explore/README.md +7 -0
  24. package/extensions/explore/autoread.ts +140 -0
  25. package/extensions/explore/find.ts +147 -0
  26. package/extensions/explore/grep.ts +749 -0
  27. package/extensions/explore/index.ts +17 -0
  28. package/extensions/explore/limits.ts +22 -0
  29. package/extensions/explore/ls.ts +113 -0
  30. package/extensions/explore/path-display.ts +41 -0
  31. package/extensions/explore/path-tree.ts +150 -0
  32. package/extensions/explore/read.ts +160 -0
  33. package/extensions/explore/result.ts +29 -0
  34. package/extensions/explore/traverse.ts +226 -0
  35. package/extensions/footer/README.md +23 -0
  36. package/extensions/footer/index.ts +534 -0
  37. package/extensions/footer/settings.ts +13 -0
  38. package/extensions/ideas/README.md +10 -0
  39. package/extensions/ideas/browser.ts +66 -0
  40. package/extensions/ideas/index.ts +31 -0
  41. package/extensions/ideas/store.ts +32 -0
  42. package/extensions/image-gen/README.md +11 -0
  43. package/extensions/image-gen/client.ts +186 -0
  44. package/extensions/image-gen/index.ts +152 -0
  45. package/extensions/manage-sessions/README.md +12 -0
  46. package/extensions/manage-sessions/index.ts +58 -0
  47. package/extensions/manage-sessions/manager-ui.ts +323 -0
  48. package/extensions/manage-sessions/sessions.ts +126 -0
  49. package/extensions/patch/README.md +48 -0
  50. package/extensions/patch/executor.ts +355 -0
  51. package/extensions/patch/index.ts +170 -0
  52. package/extensions/patch/matcher.ts +236 -0
  53. package/extensions/patch/parser.ts +347 -0
  54. package/extensions/patch/render.ts +247 -0
  55. package/extensions/patch/summary.ts +35 -0
  56. package/extensions/publish/README.md +11 -0
  57. package/extensions/publish/index.ts +225 -0
  58. package/extensions/qna/README.md +45 -0
  59. package/extensions/qna/additional-context-body.ts +51 -0
  60. package/extensions/qna/body-render.ts +6 -0
  61. package/extensions/qna/choice-question-body.ts +322 -0
  62. package/extensions/qna/index.ts +245 -0
  63. package/extensions/qna/inline-editor-row.ts +56 -0
  64. package/extensions/qna/input-question-body.ts +89 -0
  65. package/extensions/qna/model.ts +317 -0
  66. package/extensions/qna/panel.ts +240 -0
  67. package/extensions/qna/ui.ts +16 -0
  68. package/extensions/reference/README.md +47 -0
  69. package/extensions/reference/index.ts +76 -0
  70. package/extensions/reference/panel.ts +874 -0
  71. package/extensions/reference/settings.ts +29 -0
  72. package/extensions/run-summary/README.md +5 -0
  73. package/extensions/run-summary/index.ts +98 -0
  74. package/extensions/silent-command-runner/README.md +25 -0
  75. package/extensions/silent-command-runner/index.ts +468 -0
  76. package/extensions/silent-command-runner/settings.ts +65 -0
  77. package/extensions/soul/README.md +19 -0
  78. package/extensions/soul/index.ts +20 -0
  79. package/extensions/soul/prompt.ts +254 -0
  80. package/extensions/soul/settings.ts +13 -0
  81. package/extensions/stash/README.md +48 -0
  82. package/extensions/stash/browser.ts +51 -0
  83. package/extensions/stash/index.ts +55 -0
  84. package/extensions/stash/store.ts +36 -0
  85. package/extensions/subagent/README.md +34 -0
  86. package/extensions/subagent/agents/scout.md +85 -0
  87. package/extensions/subagent/agents/web-research.md +95 -0
  88. package/extensions/subagent/agents.ts +196 -0
  89. package/extensions/subagent/index.ts +214 -0
  90. package/extensions/subagent/render.ts +89 -0
  91. package/extensions/subagent/run.ts +325 -0
  92. package/extensions/tau/README.md +17 -0
  93. package/extensions/tau/index.ts +122 -0
  94. package/extensions/tau-help/README.md +3 -0
  95. package/extensions/tau-help/help.md +125 -0
  96. package/extensions/tau-help/index.ts +68 -0
  97. package/extensions/turn-budget/README.md +14 -0
  98. package/extensions/turn-budget/index.ts +150 -0
  99. package/extensions/turn-budget/settings.ts +35 -0
  100. package/extensions/web/README.md +13 -0
  101. package/extensions/web/codesearch.ts +79 -0
  102. package/extensions/web/exa.ts +101 -0
  103. package/extensions/web/html.ts +67 -0
  104. package/extensions/web/index.ts +13 -0
  105. package/extensions/web/limits.ts +11 -0
  106. package/extensions/web/tool-output.ts +54 -0
  107. package/extensions/web/webfetch.ts +177 -0
  108. package/extensions/web/websearch.ts +100 -0
  109. package/package.json +57 -0
  110. package/prompts/.gitkeep +1 -0
  111. package/prompts/cavemanify.md +25 -0
  112. package/prompts/implement.md +25 -0
  113. package/prompts/interview.md +48 -0
  114. package/prompts/plan-feature.md +30 -0
  115. package/prompts/plan-implementation.md +50 -0
  116. package/schemas/tau.schema.json +205 -0
  117. package/shared/agent-blocked.ts +12 -0
  118. package/shared/description.ts +28 -0
  119. package/shared/events.ts +135 -0
  120. package/shared/git.ts +41 -0
  121. package/shared/injected-context.ts +69 -0
  122. package/shared/jsonl-store.ts +108 -0
  123. package/shared/model-fallback/index.ts +278 -0
  124. package/shared/model-fallback/settings.ts +18 -0
  125. package/shared/model-fallback/types.ts +8 -0
  126. package/shared/ranges.ts +21 -0
  127. package/shared/settings/define.ts +15 -0
  128. package/shared/settings/files.ts +20 -0
  129. package/shared/settings/json.ts +41 -0
  130. package/shared/settings/load.ts +59 -0
  131. package/shared/settings/merge.ts +21 -0
  132. package/shared/settings/paths.ts +41 -0
  133. package/shared/settings/schema.ts +25 -0
  134. package/shared/settings/specs.ts +23 -0
  135. package/shared/text.ts +11 -0
  136. package/shared/tool-row-state.ts +43 -0
  137. package/skills/writing-preferences/SKILL.md +136 -0
  138. package/themes/.gitkeep +1 -0
package/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # @shanepadgett/tau-agent
2
+
3
+ Tau is a custom agentic harness built with Pi extensions: tools, commands, prompts, skills, and themes.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:@shanepadgett/tau-agent
9
+ # or from git (monorepo root)
10
+ pi install git:github.com/shanepadgett/tau-agent
11
+ # local
12
+ pi install ./path/to/tau-agent
13
+ pi install ./path/to/tau-agent/packages/agent
14
+ ```
15
+
16
+ ## Development
17
+
18
+ From the monorepo root:
19
+
20
+ ```bash
21
+ npm install --ignore-scripts
22
+ mise run check
23
+ pi -e .
24
+ ```
25
+
26
+ ## Docs
27
+
28
+ - [Extending Tau Agent](./docs/extending-tau-agent.md) — public events and integration
29
+ - [Subagents](./docs/subagents.md) — custom agent definitions
30
+ - [TUI](./docs/tui.md) — shared UI components
@@ -0,0 +1,119 @@
1
+ # Extending Tau Agent
2
+
3
+ Tau Agent is a Pi extension harness. External integration uses Pi's native `pi.events` bus.
4
+
5
+ The caller and Tau Agent must be loaded in the same Pi runtime. External callers use string channel names and documented payloads. They do not import Tau Agent internals.
6
+
7
+ Only events documented in this file are public. Extensions run trusted in-process; event emitters can ask Tau Agent to do work.
8
+
9
+ Related:
10
+
11
+ - [Custom subagents](./subagents.md)
12
+ - [TUI components](./tui.md)
13
+
14
+ ## `tau:autoread.requested`
15
+
16
+ Ask Tau Agent to read files and inject visible `tau.autoread` messages.
17
+
18
+ ```ts
19
+ pi.events.emit("tau:autoread.requested", {
20
+ source: "my-extension",
21
+ title: "Skill context",
22
+ cwd: ctx.cwd,
23
+ batchId,
24
+ files: [{ path: "skills/foo/SKILL.md" }],
25
+ });
26
+ ```
27
+
28
+ Fields:
29
+
30
+ - `source`: caller identifier shown in Tau metadata.
31
+ - `title`: optional display/context label.
32
+ - `cwd`: root used to resolve file paths.
33
+ - `batchId`: groups visible autoread messages.
34
+ - `files[].path`: file path relative to `cwd`.
35
+
36
+ Behavior:
37
+
38
+ - Tau reads each requested file.
39
+ - Tau injects visible `tau.autoread` messages.
40
+ - Missing or unreadable files produce visible failed autoread messages.
41
+ - `pi.events.emit(...)` does not return file contents and should not be treated as completion or ack.
42
+
43
+ ## `tau:footer-item`
44
+
45
+ Publish a bottom-right footer item in Tau's status footer.
46
+
47
+ ```ts
48
+ pi.events.emit("tau:footer-item", {
49
+ id: "my-extension.status",
50
+ text: "syncing",
51
+ priority: 10,
52
+ });
53
+ ```
54
+
55
+ Fields:
56
+
57
+ - `id`: stable item id. Re-emitting the same id replaces the previous item.
58
+ - `text`: optional display text. Omit or clear to remove the item (implementation may treat empty/undefined as hide).
59
+ - `priority`: optional sort priority (higher shows first when the footer ranks items).
60
+
61
+ ## `tau:agent.blocked`
62
+
63
+ Notify that Tau is blocked waiting on the user (confirmations, custom UI, attention).
64
+
65
+ ```ts
66
+ pi.events.emit("tau:agent.blocked", {
67
+ source: "my-extension",
68
+ title: "Needs input",
69
+ body: "Answer the open question to continue.",
70
+ });
71
+ ```
72
+
73
+ Fields:
74
+
75
+ - `source`: optional caller id.
76
+ - `title`: optional short title.
77
+ - `body`: optional detail text.
78
+
79
+ Tau's attention extension listens for this event. Other packages can listen too for custom notifications.
80
+
81
+ ## `tau:file-mutation.applied`
82
+
83
+ Emitted after Tau's `patch` tool applies file changes.
84
+
85
+ ```ts
86
+ pi.events.on("tau:file-mutation.applied", (data) => {
87
+ // data.source === "patch"
88
+ // data.status: "completed" | "partial" | "failed"
89
+ // data.changes: path, kind, line stats, optional move/snapshotRanges
90
+ });
91
+ ```
92
+
93
+ Fields:
94
+
95
+ - `source`: currently `"patch"`.
96
+ - `toolCallId`: tool call that produced the mutation.
97
+ - `cwd`: working directory for the tool call.
98
+ - `status`: overall result.
99
+ - `changes[]`: per-file change summary (`path`, `kind`, optional `move`, `linesAdded`, `linesRemoved`, optional `snapshotRanges`).
100
+
101
+ Use this to react after mutations (formatters, review hooks, status UI). Do not treat it as a request channel.
102
+
103
+ ## `tau:tool-row-state.set`
104
+
105
+ Set visual state on a Tau tool row (for example pruned).
106
+
107
+ ```ts
108
+ pi.events.emit("tau:tool-row-state.set", {
109
+ rowId: "some-row-id",
110
+ state: "pruned",
111
+ });
112
+ ```
113
+
114
+ Fields:
115
+
116
+ - `rowId`: tool row id.
117
+ - `state`: optional visual state. Omit to clear.
118
+
119
+ Most extenders do not need this; it is for coordinating tool-row rendering with Tau's explore/patch/subagent tooling.
@@ -0,0 +1,57 @@
1
+ # Custom subagents
2
+
3
+ Tau's `subagent` tool delegates one focused task to an isolated child Pi session. You can add your own agent definitions without writing TypeScript.
4
+
5
+ ## Where definitions live
6
+
7
+ | Scope | Path | Use when |
8
+ | --- | --- | --- |
9
+ | **User (global)** | `~/.pi/agent/tau/agents/*.md` | You want the agent in every project |
10
+ | **Project** | nearest trusted `.pi/tau/agents/*.md` | Repo-specific helpers |
11
+
12
+ Precedence: **project overrides user**, which overrides Tau's built-ins (`scout`, `web-research`). Duplicate names in one scope are invalid.
13
+
14
+ ## Definition format
15
+
16
+ Markdown with frontmatter:
17
+
18
+ ```markdown
19
+ ---
20
+ name: api-reader
21
+ description: Inspect API declarations and usage
22
+ tools:
23
+ - read
24
+ - grep
25
+ model: openai-codex/gpt-5.4-mini
26
+ thinking: medium
27
+ ---
28
+
29
+ Stay within the delegated task. Return exact paths and symbols.
30
+ ```
31
+
32
+ Required:
33
+
34
+ - `name`
35
+ - `description`
36
+ - `tools` (unique tool names; `subagent` cannot be delegated)
37
+
38
+ Optional:
39
+
40
+ - `model` as `provider/model`
41
+ - `thinking`: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`
42
+
43
+ Named tools and configured models must exist in the normally loaded child Pi environment (including tools from installed packages such as Tau).
44
+
45
+ ## Runtime rules
46
+
47
+ - Children use the parent's cwd and inherit model/thinking unless the definition overrides them.
48
+ - Children do not receive the parent conversation.
49
+ - At most four children run at once; extra calls wait in order.
50
+ - Returned text is capped (50 KB / 2,000 lines); full truncated output is saved to a private temp file.
51
+
52
+ ## Built-ins
53
+
54
+ - `scout` — local exploration with `read`, `grep`, `find`, `ls`
55
+ - `web-research` — `websearch`, `codesearch`, `webfetch`
56
+
57
+ Ask Tau to delegate, or let it call `subagent` with an agent name and task.
package/docs/tui.md ADDED
@@ -0,0 +1,107 @@
1
+ # TUI
2
+
3
+ Build TUI that mirrors Pi aesthetics.
4
+
5
+ ## Reach order
6
+
7
+ 1. Use Pi native UI when it fits:
8
+ - `ctx.ui.select()`
9
+ - `ctx.ui.confirm()`
10
+ - `ctx.ui.input()`
11
+ - `ctx.ui.editor()`
12
+ - `SelectList`
13
+ - `SettingsList`
14
+ - `BorderedLoader`
15
+ 2. Use Tau shared TUI from `@shanepadgett/tau-tui` when a tool needs a custom component flow:
16
+ - `ToolPanel`
17
+ - `Tabs`
18
+ - `SelectableList`
19
+ 3. If shared TUI lacks the needed behavior, decide if the missing piece should become a new shared component in `@shanepadgett/tau-tui`.
20
+ 4. Build feature-local custom UI only when reuse is unlikely.
21
+ 5. Custom components still use Pi TUI primitives from `@earendil-works/pi-tui`.
22
+
23
+ No one-off visual language. Make it look like Pi.
24
+
25
+ ## Shared components
26
+
27
+ ### `ToolPanel`
28
+
29
+ Baseline shell for focused custom flows. Use when Pi built-ins do not fit and the user needs a bordered panel with title, header, body, footer key hints, or acknowledgement.
30
+
31
+ For footer hints, child components expose hints, the parent combines the visible hints, and `ToolPanel` renders them.
32
+
33
+ ### `Tabs`
34
+
35
+ Use inside a `ToolPanel` when one focused flow has multiple related views. Keeps tab switching out of feature code.
36
+
37
+ ### `SelectableList`
38
+
39
+ Use inside a `ToolPanel` when the user needs cursor movement, single-select or multi-select behavior, optional inline filtering, and actions over current/selected/visible/older rows.
40
+
41
+ Filtering is configured with `filter: { searchText }`. Filtered single-select lists focus the filter immediately, so plain typing goes into the filter and action keys must use modified keys like `ctrl+n` or non-printable keys like `delete`. Filtered multi-select lists keep list focus until `/` focuses the filter; `Enter` applies the filter focus, and `Escape` clears it.
42
+
43
+ Shared components should stay generic. Feature behavior stays in the feature.
44
+
45
+ ## Composition shape
46
+
47
+ Keep composition small:
48
+
49
+ ```ts
50
+ import { SelectableList, Tabs, ToolPanel } from "@shanepadgett/tau-tui";
51
+
52
+ const list = new SelectableList(theme, listConfig);
53
+ const archiveList = new SelectableList(theme, archiveListConfig);
54
+
55
+ const tabs = new Tabs(
56
+ theme,
57
+ [
58
+ { id: "active", label: "Sessions", count: activeCount, body: list, getKeyHints: () => list.getKeyHints() },
59
+ { id: "archive", label: "Archive", count: archiveCount, body: archiveList },
60
+ ],
61
+ "active",
62
+ );
63
+
64
+ const panel = new ToolPanel(theme, {
65
+ title: "Manage sessions",
66
+ secondary: "scope: current",
67
+ body: tabs,
68
+ footer: { kind: "hints", hints: tabs.getKeyHints() },
69
+ });
70
+ ```
71
+
72
+ ## Key hints
73
+
74
+ Use `ToolKeyHint` helpers. Use `bindingHint` for one configurable Pi keybinding and `bindingsHint` for grouped bindings like `tui.select.up` + `tui.select.down` rendering as one `move` hint. Use `rawHint` only for fixed local keys.
75
+
76
+ Interactive shared components expose their own `getKeyHints()`. Composite components include visible child hints through explicit child providers such as `TabItem.getKeyHints`. Parents add only currently available domain/modal actions, then pass the final list to `ToolPanel`.
77
+
78
+ Do not render disabled actions as key hints. Put non-action text in `secondary`, `header`, `body`, or an acknowledgement footer message.
79
+
80
+ Do not hardcode key checks or rendered key labels for configurable actions. Keep key handling and key hints tied to the same binding.
81
+
82
+ ## Widgets
83
+
84
+ Use `ctx.ui.setWidget(...)` for persistent, glanceable UI near the editor.
85
+
86
+ Good fits:
87
+
88
+ - persistent tool list
89
+ - todo list
90
+ - progress/status block that should not take focus
91
+
92
+ Do not use a widget for a focused flow that needs input ownership. Use `ctx.ui.custom(...)` and a component.
93
+
94
+ ## Rules
95
+
96
+ - Every `render(width)` line must fit `width`.
97
+ - Use `truncateToWidth()` or `wrapTextWithAnsi()` for styled text.
98
+ - Plain text renderers should use Pi `Text` with `new Text("", 0, 0)` so tool rows keep native spacing and wrapping.
99
+ - Custom components must never return raw unbounded content. Split lines and run each line through `truncateToWidth()` or `wrapTextWithAnsi()` before returning it.
100
+ - Truncating styled text can insert resets that break row backgrounds. Prefer wrapping for styled text unless the component owns the whole line background.
101
+ - Custom components must implement `invalidate()`, even if it is a no-op.
102
+ - Use `theme` from the TUI callback. Do not import theme globals.
103
+ - Call `tui.requestRender()` after state changes.
104
+ - Keep feature behavior outside shared components.
105
+ - Keep storage and network outside TUI components.
106
+ - Prefer one composed panel over one giant component.
107
+ - If a helper has one caller and no useful name, inline it.
@@ -0,0 +1,11 @@
1
+ # Appshot
2
+
3
+ Appshot gives Tau direct discovery and capture of macOS application windows. Tau can list visible windows as compact TOON with their application identity, title, process ID, bounds, and stable window ID; capture an exact window as a PNG for visual inspection; and bring an application forward when needed. Captures preserve aspect ratio and fit within 1568×1568 pixels to keep image payloads bounded.
4
+
5
+ The extension provides three tools:
6
+
7
+ - `list_windows` discovers visible normal windows.
8
+ - `screenshot_window` captures and inspects one listed window.
9
+ - `activate_app` brings a listed application to the foreground when visual validation requires it.
10
+
11
+ Appshot requires macOS 14 or newer. On first use, macOS asks for Screen & System Audio Recording access. If access is denied, open **System Settings → Privacy & Security → Screen & System Audio Recording** and grant access to the application running Tau, plus `tau-appshot` if macOS lists it separately.
@@ -0,0 +1,191 @@
1
+ import AppKit
2
+ import CoreGraphics
3
+ import Darwin
4
+ import Foundation
5
+ import ImageIO
6
+ import ScreenCaptureKit
7
+ import UniformTypeIdentifiers
8
+
9
+ enum AppshotError: LocalizedError {
10
+ case invalidArguments
11
+ case unsupportedOS
12
+ case processNotFound(pid_t)
13
+ case activationFailed(pid_t)
14
+ case permissionDenied
15
+ case windowNotFound(CGWindowID)
16
+ case imageDestination
17
+ case imageWrite
18
+
19
+ var errorDescription: String? {
20
+ switch self {
21
+ case .invalidArguments:
22
+ return "Usage: tau-appshot <list|capture|activate> [arguments]"
23
+ case .unsupportedOS:
24
+ return "Appshot requires macOS 14 or newer"
25
+ case let .processNotFound(pid):
26
+ return "No running application found for PID \(pid)"
27
+ case let .activationFailed(pid):
28
+ return "Could not activate application PID \(pid)"
29
+ case .permissionDenied:
30
+ return "Screen recording permission is required. In System Settings > Privacy & Security > Screen & System Audio Recording, grant access to the application running Tau (and tau-appshot if listed), then try again"
31
+ case let .windowNotFound(windowID):
32
+ return "No visible normal window found for window ID \(windowID); call list_windows again"
33
+ case .imageDestination:
34
+ return "Could not create the PNG destination"
35
+ case .imageWrite:
36
+ return "Could not write the captured PNG"
37
+ }
38
+ }
39
+ }
40
+
41
+ @main
42
+ struct TauAppshot {
43
+ static func main() async {
44
+ do {
45
+ guard #available(macOS 14.0, *) else {
46
+ throw AppshotError.unsupportedOS
47
+ }
48
+ let application = NSApplication.shared
49
+ application.setActivationPolicy(.prohibited)
50
+ application.finishLaunching()
51
+
52
+ guard CommandLine.arguments.count >= 2 else {
53
+ throw AppshotError.invalidArguments
54
+ }
55
+ switch CommandLine.arguments[1] {
56
+ case "list":
57
+ try await listWindows()
58
+ case "capture":
59
+ try await captureWindow()
60
+ case "activate":
61
+ try await activateApplication()
62
+ default:
63
+ throw AppshotError.invalidArguments
64
+ }
65
+ } catch {
66
+ let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
67
+ FileHandle.standardError.write(Data("\(message)\n".utf8))
68
+ exit(EXIT_FAILURE)
69
+ }
70
+ }
71
+
72
+ static func requireScreenCapturePermission() throws {
73
+ guard CGPreflightScreenCaptureAccess() || CGRequestScreenCaptureAccess() else {
74
+ throw AppshotError.permissionDenied
75
+ }
76
+ }
77
+
78
+ @available(macOS 14.0, *)
79
+ static func listWindows() async throws {
80
+ guard CommandLine.arguments.count == 2 else {
81
+ throw AppshotError.invalidArguments
82
+ }
83
+ try requireScreenCapturePermission()
84
+ let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true)
85
+ let windows: [[String: Any]] = content.windows
86
+ .filter({ window in
87
+ window.windowLayer == 0
88
+ && window.frame.width > 0
89
+ && window.frame.height > 0
90
+ && window.owningApplication != nil
91
+ })
92
+ .sorted(by: { lhs, rhs in
93
+ let leftName = lhs.owningApplication?.applicationName ?? ""
94
+ let rightName = rhs.owningApplication?.applicationName ?? ""
95
+ if leftName != rightName {
96
+ return leftName.localizedCaseInsensitiveCompare(rightName) == .orderedAscending
97
+ }
98
+ return lhs.windowID < rhs.windowID
99
+ })
100
+ .map({ window in
101
+ let owner = window.owningApplication
102
+ return [
103
+ "window_id": Int(window.windowID),
104
+ "title": window.title ?? "",
105
+ "app_name": owner?.applicationName ?? "",
106
+ "bundle_id": owner?.bundleIdentifier ?? "",
107
+ "pid": Int(owner?.processID ?? 0),
108
+ "bounds": [
109
+ "x": window.frame.origin.x,
110
+ "y": window.frame.origin.y,
111
+ "width": window.frame.width,
112
+ "height": window.frame.height,
113
+ ],
114
+ ]
115
+ })
116
+ let data = try JSONSerialization.data(withJSONObject: windows, options: [.sortedKeys])
117
+ FileHandle.standardOutput.write(data)
118
+ FileHandle.standardOutput.write(Data("\n".utf8))
119
+ }
120
+
121
+ @available(macOS 14.0, *)
122
+ static func captureWindow() async throws {
123
+ guard CommandLine.arguments.count == 4,
124
+ let windowID = CGWindowID(CommandLine.arguments[2]),
125
+ windowID > 0
126
+ else {
127
+ throw AppshotError.invalidArguments
128
+ }
129
+ let outputPath = CommandLine.arguments[3]
130
+ try requireScreenCapturePermission()
131
+
132
+ let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true)
133
+ guard let window = content.windows.first(where: {
134
+ $0.windowID == windowID
135
+ && $0.windowLayer == 0
136
+ && $0.frame.width > 0
137
+ && $0.frame.height > 0
138
+ }) else {
139
+ throw AppshotError.windowNotFound(windowID)
140
+ }
141
+
142
+ let filter = SCContentFilter(desktopIndependentWindow: window)
143
+ let configuration = SCStreamConfiguration()
144
+ let pixelWidth = filter.contentRect.width * CGFloat(filter.pointPixelScale)
145
+ let pixelHeight = filter.contentRect.height * CGFloat(filter.pointPixelScale)
146
+ let scale = min(1, 1568 / max(pixelWidth, pixelHeight))
147
+ configuration.width = max(1, Int(floor(pixelWidth * scale)))
148
+ configuration.height = max(1, Int(floor(pixelHeight * scale)))
149
+ configuration.showsCursor = false
150
+ configuration.ignoreShadowsSingleWindow = true
151
+
152
+ let image = try await SCScreenshotManager.captureImage(
153
+ contentFilter: filter,
154
+ configuration: configuration
155
+ )
156
+ let outputURL = URL(fileURLWithPath: outputPath)
157
+ guard let destination = CGImageDestinationCreateWithURL(
158
+ outputURL as CFURL,
159
+ UTType.png.identifier as CFString,
160
+ 1,
161
+ nil
162
+ ) else {
163
+ throw AppshotError.imageDestination
164
+ }
165
+ CGImageDestinationAddImage(destination, image, nil)
166
+ guard CGImageDestinationFinalize(destination) else {
167
+ throw AppshotError.imageWrite
168
+ }
169
+ }
170
+
171
+ static func activateApplication() async throws {
172
+ guard CommandLine.arguments.count == 3,
173
+ let pid = pid_t(CommandLine.arguments[2]),
174
+ pid > 0
175
+ else {
176
+ throw AppshotError.invalidArguments
177
+ }
178
+ guard let application = NSRunningApplication(processIdentifier: pid) else {
179
+ throw AppshotError.processNotFound(pid)
180
+ }
181
+ guard application.activate(options: [.activateAllWindows]) else {
182
+ throw AppshotError.activationFailed(pid)
183
+ }
184
+ for _ in 0..<20 where !application.isActive {
185
+ try await Task.sleep(for: .milliseconds(100))
186
+ }
187
+ guard application.isActive else {
188
+ throw AppshotError.activationFailed(pid)
189
+ }
190
+ }
191
+ }