@pi-in-go/pigpen-acp 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 (134) hide show
  1. package/CREDITS.md +23 -0
  2. package/LICENSE +22 -0
  3. package/README.md +110 -0
  4. package/extensions/acp/cmd/pig-acp/e2e_llm_test.go +168 -0
  5. package/extensions/acp/cmd/pig-acp/e2e_test.go +541 -0
  6. package/extensions/acp/cmd/pig-acp/go.mod +6 -0
  7. package/extensions/acp/cmd/pig-acp/internal/acp/agent.go +784 -0
  8. package/extensions/acp/cmd/pig-acp/internal/acp/agent_builtin_test.go +536 -0
  9. package/extensions/acp/cmd/pig-acp/internal/acp/agent_commands_test.go +199 -0
  10. package/extensions/acp/cmd/pig-acp/internal/acp/agent_config_test.go +530 -0
  11. package/extensions/acp/cmd/pig-acp/internal/acp/agent_helpers_test.go +215 -0
  12. package/extensions/acp/cmd/pig-acp/internal/acp/agent_load_test.go +333 -0
  13. package/extensions/acp/cmd/pig-acp/internal/acp/agent_prompt.go +289 -0
  14. package/extensions/acp/cmd/pig-acp/internal/acp/agent_session_test.go +429 -0
  15. package/extensions/acp/cmd/pig-acp/internal/acp/auth.go +61 -0
  16. package/extensions/acp/cmd/pig-acp/internal/acp/bench_test.go +47 -0
  17. package/extensions/acp/cmd/pig-acp/internal/acp/helpers_test.go +421 -0
  18. package/extensions/acp/cmd/pig-acp/internal/acp/main_test.go +28 -0
  19. package/extensions/acp/cmd/pig-acp/internal/acp/mutation_extra_test.go +142 -0
  20. package/extensions/acp/cmd/pig-acp/internal/acp/paths.go +77 -0
  21. package/extensions/acp/cmd/pig-acp/internal/acp/pisessions.go +317 -0
  22. package/extensions/acp/cmd/pig-acp/internal/acp/protocol.go +188 -0
  23. package/extensions/acp/cmd/pig-acp/internal/acp/server.go +218 -0
  24. package/extensions/acp/cmd/pig-acp/internal/acp/server_test.go +386 -0
  25. package/extensions/acp/cmd/pig-acp/internal/acp/session.go +1068 -0
  26. package/extensions/acp/cmd/pig-acp/internal/acp/session_diff_test.go +183 -0
  27. package/extensions/acp/cmd/pig-acp/internal/acp/session_events_test.go +443 -0
  28. package/extensions/acp/cmd/pig-acp/internal/acp/session_extra_test.go +547 -0
  29. package/extensions/acp/cmd/pig-acp/internal/acp/session_usage_test.go +198 -0
  30. package/extensions/acp/cmd/pig-acp/internal/acp/settings.go +79 -0
  31. package/extensions/acp/cmd/pig-acp/internal/acp/slash.go +278 -0
  32. package/extensions/acp/cmd/pig-acp/internal/acp/slash_test.go +128 -0
  33. package/extensions/acp/cmd/pig-acp/internal/acp/startup.go +156 -0
  34. package/extensions/acp/cmd/pig-acp/internal/acp/store.go +118 -0
  35. package/extensions/acp/cmd/pig-acp/internal/acp/store_paths_test.go +466 -0
  36. package/extensions/acp/cmd/pig-acp/internal/acp/translate.go +352 -0
  37. package/extensions/acp/cmd/pig-acp/internal/acp/translate_test.go +105 -0
  38. package/extensions/acp/cmd/pig-acp/internal/acp/twin_test.go +28 -0
  39. package/extensions/acp/cmd/pig-acp/internal/acp/types.go +202 -0
  40. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/bench_test.go +24 -0
  41. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc.go +302 -0
  42. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc_test.go +320 -0
  43. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/twin_test.go +28 -0
  44. package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_unix.go +19 -0
  45. package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_windows.go +11 -0
  46. package/extensions/acp/cmd/pig-acp/internal/pirpc/process.go +530 -0
  47. package/extensions/acp/cmd/pig-acp/internal/pirpc/process_test.go +355 -0
  48. package/extensions/acp/cmd/pig-acp/internal/pirpc/twin_test.go +28 -0
  49. package/extensions/acp/cmd/pig-acp/main.go +177 -0
  50. package/extensions/acp/cmd/pig-acp/main_test.go +213 -0
  51. package/extensions/acp/cmd/pig-acp/sigpipe_unix.go +10 -0
  52. package/extensions/acp/cmd/pig-acp/sigpipe_windows.go +5 -0
  53. package/extensions/acp/cmd/pig-acp/testdata/ask/extension.go +28 -0
  54. package/extensions/acp/cmd/pig-acp/testdata/ask/go.mod +7 -0
  55. package/extensions/acp/cmd/pig-acp/testdata/ask/go.sum +2 -0
  56. package/extensions/acp/cmd/pig-acp/testdata/gate/extension.go +37 -0
  57. package/extensions/acp/cmd/pig-acp/testdata/gate/go.mod +7 -0
  58. package/extensions/acp/cmd/pig-acp/testdata/gate/go.sum +2 -0
  59. package/extensions/acp/cmd/pig-acp/twin_test.go +28 -0
  60. package/extensions/acp/extension.go +63 -0
  61. package/extensions/acp/extension_test.go +111 -0
  62. package/extensions/acp/fakehost_test.go +548 -0
  63. package/extensions/acp/go.mod +6 -0
  64. package/extensions/acp/go.sum +2 -0
  65. package/package.json +41 -0
  66. package/port/PORT.md +147 -0
  67. package/port/interop/package-lock.json +89 -0
  68. package/port/interop/package.json +13 -0
  69. package/port/interop/run.mjs +229 -0
  70. package/port/mutate.mjs +110 -0
  71. package/port/mutation-results.txt +107 -0
  72. package/port/mutations.json +748 -0
  73. package/port/oracle/pi-acp/LICENSE +21 -0
  74. package/port/oracle/pi-acp/README.md +209 -0
  75. package/port/oracle/pi-acp/package.json +65 -0
  76. package/port/oracle/pi-acp/src/acp/agent.ts +1661 -0
  77. package/port/oracle/pi-acp/src/acp/auth-required.ts +37 -0
  78. package/port/oracle/pi-acp/src/acp/auth.ts +59 -0
  79. package/port/oracle/pi-acp/src/acp/paths.ts +15 -0
  80. package/port/oracle/pi-acp/src/acp/pi-commands.ts +59 -0
  81. package/port/oracle/pi-acp/src/acp/pi-sessions.ts +333 -0
  82. package/port/oracle/pi-acp/src/acp/pi-settings.ts +75 -0
  83. package/port/oracle/pi-acp/src/acp/session-store.ts +68 -0
  84. package/port/oracle/pi-acp/src/acp/session.ts +1078 -0
  85. package/port/oracle/pi-acp/src/acp/slash-commands.ts +197 -0
  86. package/port/oracle/pi-acp/src/acp/translate/bash.ts +100 -0
  87. package/port/oracle/pi-acp/src/acp/translate/pi-messages.ts +17 -0
  88. package/port/oracle/pi-acp/src/acp/translate/pi-tools.ts +51 -0
  89. package/port/oracle/pi-acp/src/acp/translate/prompt.ts +71 -0
  90. package/port/oracle/pi-acp/src/index.ts +82 -0
  91. package/port/oracle/pi-acp/src/pi-rpc/command.ts +16 -0
  92. package/port/oracle/pi-acp/src/pi-rpc/process.ts +432 -0
  93. package/port/oracle/pi-acp/test/component/agent-steering-followup-modes.test.ts +143 -0
  94. package/port/oracle/pi-acp/test/component/session-diff.test.ts +214 -0
  95. package/port/oracle/pi-acp/test/component/session-events.test.ts +1102 -0
  96. package/port/oracle/pi-acp/test/component/session-list-and-load.test.ts +129 -0
  97. package/port/oracle/pi-acp/test/component/session-list-custom-session-dir.test.ts +52 -0
  98. package/port/oracle/pi-acp/test/component/session-list-scoped.test.ts +76 -0
  99. package/port/oracle/pi-acp/test/component/session-load-toolresult.test.ts +68 -0
  100. package/port/oracle/pi-acp/test/component/session-queue-cancel.test.ts +43 -0
  101. package/port/oracle/pi-acp/test/component/session-slash-commands.test.ts +37 -0
  102. package/port/oracle/pi-acp/test/component/session-thinking-modes.test.ts +22 -0
  103. package/port/oracle/pi-acp/test/component/session-title-long-session.test.ts +56 -0
  104. package/port/oracle/pi-acp/test/component/session-updatedAt-message-only.test.ts +57 -0
  105. package/port/oracle/pi-acp/test/helpers/fakes.ts +87 -0
  106. package/port/oracle/pi-acp/test/unit/auth-methods-terminal-auth-meta.test.ts +22 -0
  107. package/port/oracle/pi-acp/test/unit/builtin-commands.test.ts +60 -0
  108. package/port/oracle/pi-acp/test/unit/context-usage.test.ts +204 -0
  109. package/port/oracle/pi-acp/test/unit/merge-commands.test.ts +19 -0
  110. package/port/oracle/pi-acp/test/unit/model-thinking-levels.test.ts +268 -0
  111. package/port/oracle/pi-acp/test/unit/new-session-auth-required-when-no-models.test.ts +51 -0
  112. package/port/oracle/pi-acp/test/unit/new-session-pi-not-found.test.ts +26 -0
  113. package/port/oracle/pi-acp/test/unit/new-session-runtime-startup-errors.test.ts +148 -0
  114. package/port/oracle/pi-acp/test/unit/pi-command.test.ts +44 -0
  115. package/port/oracle/pi-acp/test/unit/pi-commands.test.ts +32 -0
  116. package/port/oracle/pi-acp/test/unit/pi-enable-embed-context-flag.test.ts +35 -0
  117. package/port/oracle/pi-acp/test/unit/pi-messages.test.ts +29 -0
  118. package/port/oracle/pi-acp/test/unit/pi-rpc-request-timeout.test.ts +137 -0
  119. package/port/oracle/pi-acp/test/unit/pi-rpc-session-path.test.ts +93 -0
  120. package/port/oracle/pi-acp/test/unit/pi-tools.test.ts +40 -0
  121. package/port/oracle/pi-acp/test/unit/prompt-to-pi-message.test.ts +74 -0
  122. package/port/oracle/pi-acp/test/unit/session-config-options.test.ts +217 -0
  123. package/port/oracle/pi-acp/test/unit/session-delete.test.ts +170 -0
  124. package/port/oracle/pi-acp/test/unit/session-restore.test.ts +239 -0
  125. package/port/oracle/pi-acp/test/unit/slash-commands.test.ts +36 -0
  126. package/port/oracle/pi-acp/test/unit/startup-info-env.test.ts +84 -0
  127. package/port/oracle/pi-acp/test/unit/startup-info-load-session.test.ts +53 -0
  128. package/port/oracle/pi-acp/test/unit/startup-info-project-packages.test.ts +74 -0
  129. package/port/oracle/pi-acp/test/unit/stdout-destroyed-does-not-crash.test.ts +32 -0
  130. package/port/oracle/pi-acp/test/unit/thinking-level-rpc.test.ts +43 -0
  131. package/port/schema/LICENSE +191 -0
  132. package/port/schema/schema.json +8015 -0
  133. package/port/upstream-tests.json +201 -0
  134. package/provenance.json +28 -0
@@ -0,0 +1,198 @@
1
+ package acp
2
+
3
+ // Twins of the usage_update tests in test/component/session-events.test.ts, and of
4
+ // test/component/session-queue-cancel.test.ts, session-slash-commands.test.ts.
5
+
6
+ import (
7
+ "errors"
8
+ "math"
9
+ "testing"
10
+ "time"
11
+ )
12
+
13
+ func usageUpdates(c *fakeConn) []Update {
14
+ var out []Update
15
+ for _, u := range c.ofKind("usage_update") {
16
+ out = append(out, u.Update)
17
+ }
18
+ return out
19
+ }
20
+
21
+ func TestSessionUsage(t *testing.T) {
22
+ tw(t, "component/session-events", "PiAcpSession: emits usage_update from contextUsage before resolving prompt on agent_settled", func(t *testing.T) {
23
+ conn, proc := newFakeConn(), newFakeProc()
24
+ proc.sessionStats = SessionStats{"tokens": map[string]any{"total": 999999}, "contextUsage": map[string]any{"tokens": 12345, "contextWindow": 200000}}
25
+ s := newTestSession(cwdNow(t), proc, conn)
26
+ started := make(chan struct{})
27
+ release := make(chan struct{})
28
+ conn.sessionUpdateHook = func(u Update) {
29
+ if u["sessionUpdate"] == "usage_update" {
30
+ close(started)
31
+ <-release
32
+ }
33
+ }
34
+ p := s.Prompt("hello", nil)
35
+ proc.emit(Event{"type": "agent_start"})
36
+ proc.emit(Event{"type": "agent_end"})
37
+ proc.emit(Event{"type": "agent_settled"})
38
+ select {
39
+ case <-started:
40
+ case <-time.After(5 * time.Second):
41
+ t.Fatal("usage_update delivery never started")
42
+ }
43
+ select {
44
+ case <-p:
45
+ t.Fatal("prompt resolved before usage_update landed")
46
+ case <-time.After(50 * time.Millisecond):
47
+ }
48
+ close(release)
49
+ if r := wait(t, p); r.Reason != StopEndTurn {
50
+ t.Fatalf("reason = %v", r)
51
+ }
52
+ if proc.statsCount() != 1 {
53
+ t.Errorf("get_session_stats called %d times", proc.statsCount())
54
+ }
55
+ jsonEqual(t, usageUpdates(conn), []Update{{"sessionUpdate": "usage_update", "used": 12345, "size": 200000}})
56
+ })
57
+
58
+ tw(t, "component/session-events", "PiAcpSession: skips usage_update when contextUsage tokens are null", func(t *testing.T) {
59
+ conn, proc := newFakeConn(), newFakeProc()
60
+ proc.sessionStats = SessionStats{"tokens": map[string]any{"total": 4000}, "contextUsage": map[string]any{"tokens": nil, "contextWindow": 200000}}
61
+ s := newTestSession(cwdNow(t), proc, conn)
62
+ p := s.Prompt("hello", nil)
63
+ proc.emit(Event{"type": "agent_settled"})
64
+ if r := wait(t, p); r.Reason != StopEndTurn {
65
+ t.Fatalf("reason = %v", r)
66
+ }
67
+ if len(usageUpdates(conn)) != 0 {
68
+ t.Error("usage_update emitted for null tokens")
69
+ }
70
+ })
71
+
72
+ tw(t, "component/session-events", "PiAcpSession: skips usage_update for invalid contextUsage values", func(t *testing.T) {
73
+ invalid := []struct {
74
+ name string
75
+ usage any
76
+ }{
77
+ {"missing contextUsage", nil},
78
+ {"negative tokens", map[string]any{"tokens": -1, "contextWindow": 100}},
79
+ {"fractional tokens", map[string]any{"tokens": 1.5, "contextWindow": 100}},
80
+ {"NaN tokens", map[string]any{"tokens": math.NaN(), "contextWindow": 100}},
81
+ {"zero contextWindow", map[string]any{"tokens": 10, "contextWindow": 0}},
82
+ {"negative contextWindow", map[string]any{"tokens": 10, "contextWindow": -1}},
83
+ {"fractional contextWindow", map[string]any{"tokens": 10, "contextWindow": 100.5}},
84
+ {"null contextWindow", map[string]any{"tokens": 10, "contextWindow": nil}},
85
+ {"infinite contextWindow", map[string]any{"tokens": 10, "contextWindow": math.Inf(1)}},
86
+ }
87
+ for _, tc := range invalid {
88
+ conn, proc := newFakeConn(), newFakeProc()
89
+ stats := SessionStats{}
90
+ if tc.usage != nil {
91
+ stats["contextUsage"] = tc.usage
92
+ }
93
+ proc.sessionStats = stats
94
+ s := newTestSession(cwdNow(t), proc, conn)
95
+ p := s.Prompt("hello", nil)
96
+ proc.emit(Event{"type": "agent_settled"})
97
+ if r := wait(t, p); r.Reason != StopEndTurn {
98
+ t.Fatalf("%s: reason = %v", tc.name, r)
99
+ }
100
+ if len(usageUpdates(conn)) != 0 {
101
+ t.Errorf("%s: usage_update emitted", tc.name)
102
+ }
103
+ }
104
+ })
105
+
106
+ tw(t, "component/session-events", "PiAcpSession: get_session_stats rejection does not break the prompt", func(t *testing.T) {
107
+ conn, proc := newFakeConn(), newFakeProc()
108
+ proc.sessionStatsError = errors.New("pi get_session_stats failed: unsupported")
109
+ s := newTestSession(cwdNow(t), proc, conn)
110
+ p := s.Prompt("hello", nil)
111
+ proc.emit(Event{"type": "agent_settled"})
112
+ if r := wait(t, p); r.Reason != StopEndTurn {
113
+ t.Fatalf("reason = %v", r)
114
+ }
115
+ if proc.statsCount() != 1 || len(usageUpdates(conn)) != 0 {
116
+ t.Errorf("stats=%d updates=%v", proc.statsCount(), usageUpdates(conn))
117
+ }
118
+ })
119
+
120
+ tw(t, "component/session-events", "PiAcpSession: get_session_stats timeout does not block the prompt", func(t *testing.T) {
121
+ conn, proc := newFakeConn(), newFakeProc()
122
+ // The timeout lives in Process.Request; the session sees it as an error.
123
+ proc.sessionStatsError = errors.New("pi get_session_stats timed out after 1000ms")
124
+ s := newTestSession(cwdNow(t), proc, conn)
125
+ p := s.Prompt("hello", nil)
126
+ proc.emit(Event{"type": "agent_settled"})
127
+ if r := wait(t, p); r.Reason != StopEndTurn {
128
+ t.Fatalf("reason = %v", r)
129
+ }
130
+ if proc.statsCount() != 1 || len(usageUpdates(conn)) != 0 {
131
+ t.Errorf("stats=%d updates=%v", proc.statsCount(), usageUpdates(conn))
132
+ }
133
+ })
134
+
135
+ tw(t, "component/session-events", "PiAcpSession: cancelled turn still reports cancelled after usage publish", func(t *testing.T) {
136
+ conn, proc := newFakeConn(), newFakeProc()
137
+ proc.sessionStats = SessionStats{"contextUsage": map[string]any{"tokens": 42, "contextWindow": 100}}
138
+ s := newTestSession(cwdNow(t), proc, conn)
139
+ p := s.Prompt("hello", nil)
140
+ if err := s.Cancel(); err != nil {
141
+ t.Fatal(err)
142
+ }
143
+ proc.emit(Event{"type": "agent_settled"})
144
+ if r := wait(t, p); r.Reason != StopCancelled {
145
+ t.Fatalf("reason = %v", r)
146
+ }
147
+ jsonEqual(t, usageUpdates(conn), []Update{{"sessionUpdate": "usage_update", "used": 42, "size": 100}})
148
+ })
149
+ }
150
+
151
+ // test/component/session-queue-cancel.test.ts
152
+ func TestSessionQueueCancel(t *testing.T) {
153
+ tw(t, "component/session-queue-cancel", "PiAcpSession: cancel clears queued prompts", func(t *testing.T) {
154
+ conn, proc := newFakeConn(), newFakeProc()
155
+ s := newTestSession(cwdNow(t), proc, conn)
156
+ first := s.Prompt("one", nil)
157
+ second := s.Prompt("two", nil)
158
+ third := s.Prompt("three", nil)
159
+ eventually(t, "the first prompt", func() bool { return len(proc.promptList()) == 1 })
160
+ if err := s.Cancel(); err != nil {
161
+ t.Fatal(err)
162
+ }
163
+ if proc.aborts() != 1 {
164
+ t.Errorf("abort count = %d", proc.aborts())
165
+ }
166
+ if r := wait(t, second); r.Reason != StopCancelled {
167
+ t.Errorf("second = %v", r)
168
+ }
169
+ if r := wait(t, third); r.Reason != StopCancelled {
170
+ t.Errorf("third = %v", r)
171
+ }
172
+ settledTurn(proc)
173
+ if r := wait(t, first); r.Reason != StopCancelled {
174
+ t.Errorf("first = %v", r)
175
+ }
176
+ // The queue was cleared, so no further prompt starts.
177
+ time.Sleep(20 * time.Millisecond)
178
+ if n := len(promptsOf(t, proc, 1)); n != 1 {
179
+ t.Errorf("%d prompts reached pi", n)
180
+ }
181
+ })
182
+ }
183
+
184
+ // test/component/session-slash-commands.test.ts
185
+ func TestSessionSlashCommands(t *testing.T) {
186
+ tw(t, "component/session-slash-commands", "PiAcpSession: expands /command before sending to pi", func(t *testing.T) {
187
+ conn, proc := newFakeConn(), newFakeProc()
188
+ s := newTestSession(cwdNow(t), proc, conn, FileSlashCommand{Name: "hello", Description: "(user)", Content: "Expanded $1", Source: "(user)"})
189
+ p := s.Prompt("/hello world", nil)
190
+ settledTurn(proc)
191
+ if r := wait(t, p); r.Reason != StopEndTurn {
192
+ t.Fatalf("reason = %v", r)
193
+ }
194
+ if got := promptsOf(t, proc, 1); len(got) != 1 || got[0].Message != "Expanded world" {
195
+ t.Fatalf("prompts = %v", got)
196
+ }
197
+ })
198
+ }
@@ -0,0 +1,79 @@
1
+ package acp
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "path/filepath"
7
+ )
8
+
9
+ func readJSONObject(path string) map[string]any {
10
+ raw, err := os.ReadFile(path)
11
+ if err != nil {
12
+ return map[string]any{}
13
+ }
14
+ var data any
15
+ if json.Unmarshal(raw, &data) != nil {
16
+ return map[string]any{}
17
+ }
18
+ if m, ok := data.(map[string]any); ok {
19
+ return m
20
+ }
21
+ return map[string]any{}
22
+ }
23
+
24
+ func deepMerge(a, b map[string]any) map[string]any {
25
+ out := make(map[string]any, len(a)+len(b))
26
+ for k, v := range a {
27
+ out[k] = v
28
+ }
29
+ for k, v := range b {
30
+ av, aok := out[k].(map[string]any)
31
+ bv, bok := v.(map[string]any)
32
+ if aok && bok {
33
+ out[k] = deepMerge(av, bv)
34
+ } else {
35
+ out[k] = v
36
+ }
37
+ }
38
+ return out
39
+ }
40
+
41
+ // mergedSettings mirrors pig's settings: the global file, overridden by the project file.
42
+ func mergedSettings(cwd string) map[string]any {
43
+ global := readJSONObject(filepath.Join(AgentDir(), "settings.json"))
44
+ project := readJSONObject(filepath.Join(absPathFrom(cwd), ProjectDirName(), "settings.json"))
45
+ return deepMerge(global, project)
46
+ }
47
+
48
+ func absPathFrom(cwd string) string {
49
+ if filepath.IsAbs(cwd) {
50
+ return filepath.Clean(cwd)
51
+ }
52
+ return absPath(cwd)
53
+ }
54
+
55
+ // GetEnableSkillCommands reads enableSkillCommands from the merged PiG settings.
56
+ func GetEnableSkillCommands(cwd string) bool {
57
+ m := mergedSettings(cwd)
58
+ if b, ok := m["enableSkillCommands"].(bool); ok {
59
+ return b
60
+ }
61
+ if skills, ok := m["skills"].(map[string]any); ok {
62
+ if b, ok := skills["enableSkillCommands"].(bool); ok {
63
+ return b
64
+ }
65
+ }
66
+ return true
67
+ }
68
+
69
+ // GetQuietStartup reads quietStartup from the merged PiG settings.
70
+ func GetQuietStartup(cwd string) bool {
71
+ m := mergedSettings(cwd)
72
+ if b, ok := m["quietStartup"].(bool); ok {
73
+ return b
74
+ }
75
+ if b, ok := m["quietStart"].(bool); ok {
76
+ return b
77
+ }
78
+ return false
79
+ }
@@ -0,0 +1,278 @@
1
+ package acp
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "regexp"
7
+ "strconv"
8
+ "strings"
9
+ )
10
+
11
+ // FileSlashCommand is a prompt template file used as a slash command.
12
+ type FileSlashCommand struct {
13
+ Name string
14
+ Description string
15
+ Content string
16
+ Source string // "(user)", "(project)", "(project:frontend)"
17
+ }
18
+
19
+ // AvailableCommand is an ACP available command.
20
+ type AvailableCommand struct {
21
+ Name string `json:"name"`
22
+ Description string `json:"description"`
23
+ Input map[string]any `json:"input,omitempty"`
24
+ }
25
+
26
+ var frontmatterLine = regexp.MustCompile(`^(\w+):\s*(.*)$`)
27
+
28
+ func parseFrontmatter(content string) (map[string]string, string) {
29
+ fm := map[string]string{}
30
+ if !strings.HasPrefix(content, "---") {
31
+ return fm, content
32
+ }
33
+ end := strings.Index(content[3:], "\n---")
34
+ if end == -1 {
35
+ return fm, content
36
+ }
37
+ end += 3
38
+ block := ""
39
+ if len(content) >= 4 {
40
+ block = content[4:end]
41
+ }
42
+ remaining := jsTrim(content[end+4:])
43
+ for _, line := range strings.Split(block, "\n") {
44
+ if m := frontmatterLine.FindStringSubmatch(line); m != nil {
45
+ fm[m[1]] = jsTrim(m[2])
46
+ }
47
+ }
48
+ return fm, remaining
49
+ }
50
+
51
+ func loadCommandsFromDir(dir, source, subdir string) []FileSlashCommand {
52
+ var commands []FileSlashCommand
53
+ entries, err := os.ReadDir(dir)
54
+ if err != nil {
55
+ return nil
56
+ }
57
+ for _, e := range entries {
58
+ full := filepath.Join(dir, e.Name())
59
+ isDir, isFile := e.IsDir(), e.Type().IsRegular()
60
+ if e.Type()&os.ModeSymlink != 0 {
61
+ if st, err := os.Stat(full); err == nil {
62
+ isDir, isFile = st.IsDir(), st.Mode().IsRegular()
63
+ }
64
+ }
65
+ if isDir {
66
+ sub := e.Name()
67
+ if subdir != "" {
68
+ sub = subdir + ":" + e.Name()
69
+ }
70
+ commands = append(commands, loadCommandsFromDir(full, source, sub)...)
71
+ continue
72
+ }
73
+ if !isFile || !strings.HasSuffix(e.Name(), ".md") {
74
+ continue
75
+ }
76
+ raw, err := os.ReadFile(full)
77
+ if err != nil {
78
+ continue
79
+ }
80
+ fm, content := parseFrontmatter(string(raw))
81
+ name := strings.TrimSuffix(e.Name(), ".md")
82
+ src := "(" + source + ")"
83
+ if subdir != "" {
84
+ src = "(" + source + ":" + subdir + ")"
85
+ }
86
+ desc := fm["description"]
87
+ if desc == "" {
88
+ for _, l := range strings.Split(content, "\n") {
89
+ if jsTrim(l) != "" {
90
+ desc = truncateRunes(l, 60)
91
+ if len([]rune(l)) > 60 {
92
+ desc += "..."
93
+ }
94
+ break
95
+ }
96
+ }
97
+ }
98
+ if desc != "" {
99
+ desc += " " + src
100
+ } else {
101
+ desc = src
102
+ }
103
+ commands = append(commands, FileSlashCommand{Name: name, Description: desc, Content: content, Source: src})
104
+ }
105
+ return commands
106
+ }
107
+
108
+ // LoadSlashCommands loads user then project prompt templates:
109
+ // <agent dir>/prompts and <cwd>/.pig/prompts.
110
+ func LoadSlashCommands(cwd string) []FileSlashCommand {
111
+ var commands []FileSlashCommand
112
+ commands = append(commands, loadCommandsFromDir(filepath.Join(AgentDir(), "prompts"), "user", "")...)
113
+ commands = append(commands, loadCommandsFromDir(filepath.Join(absPathFrom(cwd), ProjectDirName(), "prompts"), "project", "")...)
114
+ return commands
115
+ }
116
+
117
+ // ToAvailableCommands converts file commands, de-duplicating by name (first wins).
118
+ func ToAvailableCommands(cmds []FileSlashCommand) []AvailableCommand {
119
+ seen := map[string]bool{}
120
+ out := []AvailableCommand{}
121
+ for _, c := range cmds {
122
+ if seen[c.Name] {
123
+ continue
124
+ }
125
+ seen[c.Name] = true
126
+ out = append(out, AvailableCommand{Name: c.Name, Description: c.Description})
127
+ }
128
+ return out
129
+ }
130
+
131
+ // ParseCommandArgs splits arguments with bash-style quotes.
132
+ func ParseCommandArgs(s string) []string {
133
+ var args []string
134
+ var cur strings.Builder
135
+ var quote rune
136
+ for _, ch := range s {
137
+ if quote != 0 {
138
+ if ch == quote {
139
+ quote = 0
140
+ } else {
141
+ cur.WriteRune(ch)
142
+ }
143
+ continue
144
+ }
145
+ switch {
146
+ case ch == '"' || ch == '\'':
147
+ quote = ch
148
+ case ch == ' ' || ch == '\t':
149
+ if cur.Len() > 0 {
150
+ args = append(args, cur.String())
151
+ cur.Reset()
152
+ }
153
+ default:
154
+ cur.WriteRune(ch)
155
+ }
156
+ }
157
+ if cur.Len() > 0 {
158
+ args = append(args, cur.String())
159
+ }
160
+ return args
161
+ }
162
+
163
+ var digitsRE = regexp.MustCompile(`\$(\d+)`)
164
+
165
+ // SubstituteArgs replaces $1.. and $@.
166
+ func SubstituteArgs(content string, args []string) string {
167
+ result := strings.ReplaceAll(content, "$@", strings.Join(args, " "))
168
+ return digitsRE.ReplaceAllStringFunc(result, func(m string) string {
169
+ n, err := strconv.Atoi(m[1:])
170
+ if err != nil || n-1 < 0 || n-1 >= len(args) {
171
+ return ""
172
+ }
173
+ return args[n-1]
174
+ })
175
+ }
176
+
177
+ // ExpandSlashCommand expands a leading /command that names a file command.
178
+ func ExpandSlashCommand(text string, cmds []FileSlashCommand) string {
179
+ if !strings.HasPrefix(text, "/") {
180
+ return text
181
+ }
182
+ space := strings.Index(text, " ")
183
+ name, argStr := text[1:], ""
184
+ if space != -1 {
185
+ name, argStr = text[1:space], text[space+1:]
186
+ }
187
+ for _, c := range cmds {
188
+ if c.Name == name {
189
+ return SubstituteArgs(c.Content, ParseCommandArgs(argStr))
190
+ }
191
+ }
192
+ return text
193
+ }
194
+
195
+ // MergeCommands keeps order and de-duplicates by name (first wins).
196
+ func MergeCommands(a, b []AvailableCommand) []AvailableCommand {
197
+ seen := map[string]bool{}
198
+ out := []AvailableCommand{}
199
+ for _, list := range [][]AvailableCommand{a, b} {
200
+ for _, c := range list {
201
+ if seen[c.Name] {
202
+ continue
203
+ }
204
+ seen[c.Name] = true
205
+ out = append(out, c)
206
+ }
207
+ }
208
+ return out
209
+ }
210
+
211
+ // PiCommandsOptions select which `get_commands` entries are advertised.
212
+ type PiCommandsOptions struct {
213
+ EnableSkillCommands bool
214
+ IncludeExtensionCommands bool
215
+ }
216
+
217
+ func describeFallback(c map[string]any) string {
218
+ var parts []string
219
+ if s, _ := c["source"].(string); s != "" {
220
+ parts = append(parts, s)
221
+ }
222
+ if s, _ := c["location"].(string); s != "" {
223
+ parts = append(parts, s)
224
+ }
225
+ if len(parts) == 0 {
226
+ return "(command)"
227
+ }
228
+ return "(" + strings.Join(parts, ":") + ")"
229
+ }
230
+
231
+ // ToAvailableCommandsFromPiGetCommands converts `get_commands` data.
232
+ func ToAvailableCommandsFromPiGetCommands(data any, opts PiCommandsOptions) []AvailableCommand {
233
+ root := asObject(data)
234
+ raw, ok := root["commands"].([]any)
235
+ if !ok {
236
+ raw, _ = asObject(root["data"])["commands"].([]any)
237
+ }
238
+ out := []AvailableCommand{}
239
+ for _, item := range raw {
240
+ c := asObject(item)
241
+ name := ""
242
+ if s, ok := c["name"].(string); ok {
243
+ name = jsTrim(s)
244
+ }
245
+ if name == "" {
246
+ continue
247
+ }
248
+ if src, _ := c["source"].(string); !opts.IncludeExtensionCommands && src == "extension" {
249
+ continue
250
+ }
251
+ if !opts.EnableSkillCommands && strings.HasPrefix(name, "skill:") {
252
+ continue
253
+ }
254
+ desc := ""
255
+ if s, ok := c["description"].(string); ok {
256
+ desc = jsTrim(s)
257
+ }
258
+ if desc == "" {
259
+ desc = describeFallback(c)
260
+ }
261
+ out = append(out, AvailableCommand{Name: name, Description: desc})
262
+ }
263
+ return out
264
+ }
265
+
266
+ // BuiltinAvailableCommands are the commands the adapter handles itself; they never reach the model.
267
+ func BuiltinAvailableCommands() []AvailableCommand {
268
+ return []AvailableCommand{
269
+ {Name: "compact", Description: "Manually compact the session context", Input: map[string]any{"hint": "optional custom instructions"}},
270
+ {Name: "autocompact", Description: "Toggle automatic context compaction", Input: map[string]any{"hint": "on|off|toggle"}},
271
+ {Name: "export", Description: "Export session to an HTML file in the session cwd"},
272
+ {Name: "session", Description: "Show session stats (messages, tokens, cost, session file)"},
273
+ {Name: "name", Description: "Set session display name", Input: map[string]any{"hint": "<name>"}},
274
+ {Name: "steering", Description: "Get/set pig steering message delivery mode (how queued steering messages are delivered)", Input: map[string]any{"hint": "(no args to show) all | one-at-a-time"}},
275
+ {Name: "follow-up", Description: "Get/set pig follow-up message delivery mode (how queued follow-up messages are delivered)", Input: map[string]any{"hint": "(no args to show) all | one-at-a-time"}},
276
+ {Name: "changelog", Description: "Show pig changelog"},
277
+ }
278
+ }
@@ -0,0 +1,128 @@
1
+ package acp
2
+
3
+ // Twins of test/unit/slash-commands.test.ts, merge-commands.test.ts, pi-commands.test.ts,
4
+ // builtin command lists, and auth-methods-terminal-auth-meta.test.ts.
5
+
6
+ import (
7
+ "reflect"
8
+ "testing"
9
+ )
10
+
11
+ func TestSlashCommands(t *testing.T) {
12
+ tw(t, "unit/slash-commands", "parseCommandArgs: handles quotes", func(t *testing.T) {
13
+ for in, want := range map[string][]string{"a b": {"a", "b"}, "'a b' c": {"a b", "c"}, `"a b" c`: {"a b", "c"}} {
14
+ if got := ParseCommandArgs(in); !reflect.DeepEqual(got, want) {
15
+ t.Errorf("%q: got %q, want %q", in, got, want)
16
+ }
17
+ }
18
+ })
19
+ tw(t, "unit/slash-commands", "substituteArgs: replaces $1.. and $@", func(t *testing.T) {
20
+ if got := SubstituteArgs("x=$1 y=$2 all=$@", []string{"one", "two"}); got != "x=one y=two all=one two" {
21
+ t.Errorf("got %q", got)
22
+ }
23
+ if got := SubstituteArgs("$3", []string{"one"}); got != "" {
24
+ t.Errorf("got %q", got)
25
+ }
26
+ })
27
+ tw(t, "unit/slash-commands", "expandSlashCommand: expands known command", func(t *testing.T) {
28
+ cmds := []FileSlashCommand{{Name: "hello", Description: "(user)", Content: "Say hi to $1", Source: "(user)"}}
29
+ for in, want := range map[string]string{"/hello world": "Say hi to world", "/unknown world": "/unknown world", "not a command": "not a command"} {
30
+ if got := ExpandSlashCommand(in, cmds); got != want {
31
+ t.Errorf("%q: got %q, want %q", in, got, want)
32
+ }
33
+ }
34
+ })
35
+ tw(t, "unit/slash-commands", "toAvailableCommands: de-dupes by name (first wins)", func(t *testing.T) {
36
+ got := ToAvailableCommands([]FileSlashCommand{
37
+ {Name: "x", Description: "first", Content: "1", Source: "(user)"},
38
+ {Name: "x", Description: "second", Content: "2", Source: "(project)"}})
39
+ jsonEqual(t, got, []any{map[string]any{"name": "x", "description": "first"}})
40
+ })
41
+ tw(t, "unit/merge-commands", "mergeCommands: preserves order and de-dupes (first wins)", func(t *testing.T) {
42
+ mk := func(names ...string) []AvailableCommand {
43
+ var out []AvailableCommand
44
+ for _, n := range names {
45
+ out = append(out, AvailableCommand{Name: n})
46
+ }
47
+ return out
48
+ }
49
+ got := MergeCommands(mk("a", "b"), mk("b", "c"))
50
+ var names []string
51
+ for _, c := range got {
52
+ names = append(names, c.Name)
53
+ }
54
+ if !reflect.DeepEqual(names, []string{"a", "b", "c"}) {
55
+ t.Errorf("got %v", names)
56
+ }
57
+ })
58
+ tw(t, "unit/pi-commands", "toAvailableCommandsFromPiGetCommands: hides extension commands by default and filters skill commands", func(t *testing.T) {
59
+ data := map[string]any{"commands": []any{
60
+ map[string]any{"name": "x", "description": "X", "source": "extension"},
61
+ map[string]any{"name": "skill:foo", "description": "Foo", "source": "skill", "location": "user"},
62
+ map[string]any{"name": "y", "source": "prompt", "location": "project"},
63
+ }}
64
+ all := ToAvailableCommandsFromPiGetCommands(data, PiCommandsOptions{EnableSkillCommands: true})
65
+ jsonEqual(t, all, []any{
66
+ map[string]any{"name": "skill:foo", "description": "Foo"},
67
+ map[string]any{"name": "y", "description": "(prompt:project)"},
68
+ })
69
+ withExt := ToAvailableCommandsFromPiGetCommands(data, PiCommandsOptions{EnableSkillCommands: true, IncludeExtensionCommands: true})
70
+ jsonEqual(t, withExt, []any{
71
+ map[string]any{"name": "x", "description": "X"},
72
+ map[string]any{"name": "skill:foo", "description": "Foo"},
73
+ map[string]any{"name": "y", "description": "(prompt:project)"},
74
+ })
75
+ noSkills := ToAvailableCommandsFromPiGetCommands(data, PiCommandsOptions{EnableSkillCommands: false})
76
+ jsonEqual(t, noSkills, []any{map[string]any{"name": "y", "description": "(prompt:project)"}})
77
+ })
78
+ t.Run("builtin commands are the eight the adapter handles itself", func(t *testing.T) {
79
+ var names []string
80
+ for _, c := range BuiltinAvailableCommands() {
81
+ names = append(names, c.Name)
82
+ }
83
+ want := []string{"compact", "autocompact", "export", "session", "name", "steering", "follow-up", "changelog"}
84
+ if !reflect.DeepEqual(names, want) {
85
+ t.Errorf("got %v, want %v", names, want)
86
+ }
87
+ })
88
+ }
89
+
90
+ func TestAuthMethods(t *testing.T) {
91
+ tw(t, "unit/auth-methods-terminal-auth-meta", "getAuthMethods: includes Zed terminal-auth metadata when enabled", func(t *testing.T) {
92
+ methods := AuthMethods(true)
93
+ if len(methods) != 1 {
94
+ t.Fatalf("%d methods", len(methods))
95
+ }
96
+ m := methods[0]
97
+ if m["id"] != PiSetupMethodID {
98
+ t.Errorf("id = %v", m["id"])
99
+ }
100
+ meta, _ := m["_meta"].(map[string]any)
101
+ ta, _ := meta["terminal-auth"].(map[string]any)
102
+ if ta == nil {
103
+ t.Fatal("no terminal-auth metadata")
104
+ }
105
+ if _, ok := ta["command"].(string); !ok {
106
+ t.Errorf("command = %v", ta["command"])
107
+ }
108
+ jsonEqual(t, ta["args"], []any{"--terminal-login"})
109
+ if ta["label"] != "Launch pig" {
110
+ t.Errorf("label = %v", ta["label"])
111
+ }
112
+ if m["type"] != "terminal" {
113
+ t.Errorf("type = %v", m["type"])
114
+ }
115
+ jsonEqual(t, m["args"], []any{"--terminal-login"})
116
+ })
117
+ tw(t, "unit/auth-methods-terminal-auth-meta", "getAuthMethods: omits Zed terminal-auth metadata when disabled", func(t *testing.T) {
118
+ methods := AuthMethods(false)
119
+ if len(methods) != 1 {
120
+ t.Fatalf("%d methods", len(methods))
121
+ }
122
+ m := methods[0]
123
+ meta, _ := m["_meta"].(map[string]any)
124
+ if _, ok := meta["terminal-auth"]; ok {
125
+ t.Error("terminal-auth present")
126
+ }
127
+ })
128
+ }