@datename/fastcode-termux 0.1.6-beta → 0.1.8-beta.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 (165) hide show
  1. package/LICENSE +4 -0
  2. package/README.md +50 -17
  3. package/dist/agent/approval-json.d.ts +43 -0
  4. package/dist/agent/approval-json.js +69 -0
  5. package/dist/agent/approval-native.d.ts +3 -0
  6. package/dist/agent/approval-native.js +165 -0
  7. package/dist/agent/approval-store.d.ts +16 -0
  8. package/dist/agent/approval-store.js +21 -0
  9. package/dist/agent/approval.d.ts +33 -0
  10. package/dist/agent/approval.js +40 -5
  11. package/dist/agent/compaction.d.ts +2 -0
  12. package/dist/agent/compaction.js +60 -17
  13. package/dist/agent/guards.d.ts +16 -16
  14. package/dist/agent/guards.js +978 -24
  15. package/dist/agent/policy/defaults.d.ts +16 -0
  16. package/dist/agent/policy/defaults.js +17 -0
  17. package/dist/agent/policy/egress.d.ts +32 -0
  18. package/dist/agent/policy/egress.js +280 -0
  19. package/dist/agent/process-tree.d.ts +20 -0
  20. package/dist/agent/process-tree.js +81 -10
  21. package/dist/agent/protocol.d.ts +20 -1
  22. package/dist/agent/protocol.js +165 -38
  23. package/dist/agent/registry.d.ts +3 -1
  24. package/dist/agent/registry.js +168 -11
  25. package/dist/agent/runner.d.ts +21 -0
  26. package/dist/agent/runner.js +44 -5
  27. package/dist/agent/shell-ast.d.ts +11 -0
  28. package/dist/agent/shell-ast.js +204 -0
  29. package/dist/agent/shell-session.d.ts +14 -0
  30. package/dist/agent/shell-session.js +251 -40
  31. package/dist/agent/tool-schemas.js +26 -7
  32. package/dist/agent/tools/append_file.js +3 -1
  33. package/dist/agent/tools/apply_patch.js +44 -4
  34. package/dist/agent/tools/copy_file.js +26 -3
  35. package/dist/agent/tools/delete_file.js +22 -3
  36. package/dist/agent/tools/grep.js +35 -4
  37. package/dist/agent/tools/make_dir.js +3 -1
  38. package/dist/agent/tools/move_path.d.ts +7 -0
  39. package/dist/agent/tools/move_path.js +31 -5
  40. package/dist/agent/tools/read_file.js +63 -3
  41. package/dist/agent/tools/read_many.js +21 -3
  42. package/dist/agent/tools/readers.d.ts +53 -1
  43. package/dist/agent/tools/readers.js +236 -26
  44. package/dist/agent/tools/replace_in_file.js +38 -5
  45. package/dist/agent/tools/shell.js +2 -2
  46. package/dist/agent/tools/types.d.ts +26 -0
  47. package/dist/agent/tools/validate.d.ts +15 -2
  48. package/dist/agent/tools/validate.js +66 -3
  49. package/dist/agent/tools/write_file.js +3 -1
  50. package/dist/app.d.ts +2 -1
  51. package/dist/app.js +526 -85
  52. package/dist/cli-main.js +363 -0
  53. package/dist/cli.js +397 -337
  54. package/dist/commands/about.js +1 -1
  55. package/dist/commands/clear.d.ts +1 -0
  56. package/dist/commands/clear.js +1 -1
  57. package/dist/commands/compact.d.ts +12 -0
  58. package/dist/commands/compact.js +14 -2
  59. package/dist/commands/context.js +5 -4
  60. package/dist/commands/index.js +2 -0
  61. package/dist/commands/jobs.js +3 -2
  62. package/dist/commands/kill.js +3 -1
  63. package/dist/commands/login.d.ts +2 -2
  64. package/dist/commands/login.js +15 -15
  65. package/dist/commands/model.d.ts +1 -13
  66. package/dist/commands/model.js +6 -38
  67. package/dist/commands/provider.d.ts +1 -15
  68. package/dist/commands/provider.js +4 -22
  69. package/dist/commands/skills.js +34 -4
  70. package/dist/commands/threads.d.ts +9 -0
  71. package/dist/commands/threads.js +26 -0
  72. package/dist/config/store.d.ts +21 -0
  73. package/dist/config/store.js +47 -9
  74. package/dist/config/trust.d.ts +9 -0
  75. package/dist/config/trust.js +41 -2
  76. package/dist/headless.d.ts +7 -0
  77. package/dist/headless.js +213 -0
  78. package/dist/index.js +19 -3
  79. package/dist/lib/debug.d.ts +2 -0
  80. package/dist/lib/debug.js +22 -0
  81. package/dist/lib/diagnostics.d.ts +19 -0
  82. package/dist/lib/diagnostics.js +32 -6
  83. package/dist/lib/highlight-subset.d.ts +21 -0
  84. package/dist/lib/highlight-subset.js +88 -0
  85. package/dist/lib/lifecycle.d.ts +5 -0
  86. package/dist/lib/lifecycle.js +62 -0
  87. package/dist/lib/markdown.js +22 -4
  88. package/dist/providers/deepseek.js +1 -1
  89. package/dist/providers/openaiCompatible.d.ts +24 -2
  90. package/dist/providers/openaiCompatible.js +20 -6
  91. package/dist/providers/spec.d.ts +12 -0
  92. package/dist/providers/spec.js +29 -0
  93. package/dist/session/log.d.ts +14 -2
  94. package/dist/session/log.js +56 -27
  95. package/dist/session/projection.d.ts +34 -1
  96. package/dist/session/projection.js +315 -46
  97. package/dist/skills/manager.d.ts +15 -0
  98. package/dist/skills/manager.js +135 -18
  99. package/dist/types.d.ts +15 -0
  100. package/dist/ui/StatusBar.js +1 -1
  101. package/package.json +3 -2
  102. package/src/agent/approval-json.ts +103 -0
  103. package/src/agent/approval-native.ts +168 -0
  104. package/src/agent/approval-store.ts +38 -0
  105. package/src/agent/approval.ts +58 -11
  106. package/src/agent/compaction.ts +62 -17
  107. package/src/agent/guards.ts +1049 -25
  108. package/src/agent/policy/defaults.ts +34 -0
  109. package/src/agent/policy/egress.ts +335 -0
  110. package/src/agent/process-tree.ts +105 -23
  111. package/src/agent/protocol.ts +187 -39
  112. package/src/agent/registry.ts +186 -14
  113. package/src/agent/runner.ts +50 -7
  114. package/src/agent/shell-ast.ts +227 -0
  115. package/src/agent/shell-session.ts +265 -41
  116. package/src/agent/tool-schemas.ts +26 -7
  117. package/src/agent/tools/append_file.ts +3 -1
  118. package/src/agent/tools/apply_patch.ts +45 -4
  119. package/src/agent/tools/copy_file.ts +27 -3
  120. package/src/agent/tools/delete_file.ts +22 -3
  121. package/src/agent/tools/grep.ts +36 -4
  122. package/src/agent/tools/make_dir.ts +3 -1
  123. package/src/agent/tools/move_path.ts +38 -5
  124. package/src/agent/tools/read_file.ts +64 -3
  125. package/src/agent/tools/read_many.ts +21 -3
  126. package/src/agent/tools/readers.ts +289 -26
  127. package/src/agent/tools/replace_in_file.ts +39 -5
  128. package/src/agent/tools/shell.ts +2 -2
  129. package/src/agent/tools/types.ts +43 -1
  130. package/src/agent/tools/validate.ts +73 -4
  131. package/src/agent/tools/write_file.ts +3 -1
  132. package/src/app.ts +533 -82
  133. package/src/{cli.ts → cli-main.ts} +34 -6
  134. package/src/commands/about.ts +1 -1
  135. package/src/commands/clear.ts +1 -1
  136. package/src/commands/compact.ts +14 -2
  137. package/src/commands/context.ts +5 -4
  138. package/src/commands/index.ts +2 -0
  139. package/src/commands/jobs.ts +3 -2
  140. package/src/commands/kill.ts +3 -1
  141. package/src/commands/login.ts +15 -15
  142. package/src/commands/model.ts +6 -38
  143. package/src/commands/provider.ts +4 -22
  144. package/src/commands/skills.ts +34 -4
  145. package/src/commands/threads.ts +28 -0
  146. package/src/config/store.ts +47 -9
  147. package/src/config/trust.ts +45 -2
  148. package/src/headless.ts +225 -0
  149. package/src/index.ts +19 -3
  150. package/src/lib/debug.ts +24 -0
  151. package/src/lib/diagnostics.ts +32 -6
  152. package/src/lib/highlight-subset.ts +93 -0
  153. package/src/lib/lifecycle.ts +69 -0
  154. package/src/lib/markdown.ts +22 -4
  155. package/src/providers/deepseek.ts +1 -1
  156. package/src/providers/openaiCompatible.ts +47 -10
  157. package/src/providers/spec.ts +42 -0
  158. package/src/session/log.ts +70 -27
  159. package/src/session/projection.ts +360 -47
  160. package/src/skills/manager.ts +140 -18
  161. package/src/types/highlight-shims.d.ts +14 -0
  162. package/src/types.ts +16 -1
  163. package/src/ui/StatusBar.ts +1 -1
  164. package/tsconfig.json +1 -0
  165. /package/dist/{cli.d.ts → cli-main.d.ts} +0 -0
@@ -5,21 +5,122 @@
5
5
  * a SKILL.md file plus optional resource files. FastCode supports three
6
6
  * install sources:
7
7
  *
8
- * 1. skills.sh / skillshub.wtf API (default registry)
9
- * `/skills install pdf` → resolves via the registry
10
- * `/skills install owner/repo/skill` → fetches raw SKILL.md
11
- * 2. Direct https URLs ending in SKILL.md.
8
+ * 1. A registry configured via FASTCODE_SKILLS_REGISTRY or skillsRegistry
9
+ * `/skills install pdf` → resolves via the configured registry
10
+ * `/skills install owner/repo/skill` → fetches raw SKILL.md from the registry
11
+ * 2. Direct https URLs ending in SKILL.md (no registry required).
12
12
  * 3. Local paths (just symlinks into ~/.fastcode/skills/).
13
13
  *
14
14
  * Installed skills can be enabled per-session and are then stitched into
15
15
  * the system prompt at chat time, exactly like Claude does internally.
16
16
  */
17
+ import { createHash } from "node:crypto";
17
18
  import { readFileSync, writeFileSync, existsSync, readdirSync, rmSync, mkdirSync, } from "node:fs";
18
19
  import { join } from "node:path";
19
20
  import { getPaths } from "../config/paths.js";
21
+ import { invalidateConfigCache } from "../config/store.js";
20
22
  import { fetchJson } from "../lib/http.js";
21
23
  import { parseSkill } from "./parser.js";
22
- const DEFAULT_REGISTRY = "https://skillshub.wtf";
24
+ const SOURCE_FILE = ".fastcode-source";
25
+ const INTEGRITY_VERSION = 1;
26
+ const REGISTRY_REQUIRED = "No skills registry configured. Set one with `/skills registry <url>` or FASTCODE_SKILLS_REGISTRY.";
27
+ /**
28
+ * Pick the skills registry without treating an empty environment variable as
29
+ * "configured". `??` would keep `""` and make env overrides silently break;
30
+ * explicit null/empty checks keep `""` and `" "` on the config path. There is
31
+ * intentionally no built-in default registry.
32
+ */
33
+ export function resolveRegistry(envValue, configValue) {
34
+ const env = typeof envValue === "string" && envValue.trim() ? envValue.trim() : "";
35
+ const configured = typeof configValue === "string" && configValue.trim() ? configValue.trim() : "";
36
+ return (env || configured).replace(/\/+$/, "");
37
+ }
38
+ function requireRegistry(registry) {
39
+ if (!registry)
40
+ throw new Error(REGISTRY_REQUIRED);
41
+ return registry;
42
+ }
43
+ export function hashSkillDirectory(folder) {
44
+ const entries = skillFiles(folder).sort();
45
+ return createHash("sha256").update(entries.join("\n")).digest("hex");
46
+ }
47
+ function skillFiles(folder, prefix = "") {
48
+ const out = [];
49
+ let items;
50
+ try {
51
+ items = readdirSync(folder, { withFileTypes: true });
52
+ }
53
+ catch {
54
+ return out;
55
+ }
56
+ for (const item of items) {
57
+ if (item.name === SOURCE_FILE)
58
+ continue;
59
+ const full = join(folder, item.name);
60
+ const rel = prefix ? prefix + "/" + item.name : item.name;
61
+ if (item.isDirectory()) {
62
+ out.push(...skillFiles(full, rel));
63
+ continue;
64
+ }
65
+ if (!item.isFile())
66
+ continue;
67
+ const digest = createHash("sha256").update(readFileSync(full)).digest("hex");
68
+ out.push(rel + "\0" + digest);
69
+ }
70
+ return out;
71
+ }
72
+ function readSourceMeta(folder) {
73
+ const sourcePath = join(folder, SOURCE_FILE);
74
+ if (!existsSync(sourcePath))
75
+ return { source: "local", legacy: true };
76
+ const raw = readFileSync(sourcePath, "utf8").trim();
77
+ if (raw.startsWith("{")) {
78
+ try {
79
+ const parsed = JSON.parse(raw);
80
+ return {
81
+ source: typeof parsed.source === "string" ? parsed.source : "local",
82
+ sha256: typeof parsed.sha256 === "string" ? parsed.sha256 : undefined,
83
+ legacy: typeof parsed.sha256 !== "string",
84
+ };
85
+ }
86
+ catch {
87
+ // Fall through and treat the file as a legacy plain-text source.
88
+ }
89
+ }
90
+ return { source: raw || "local", legacy: true };
91
+ }
92
+ function writeSourceMeta(folder, source) {
93
+ const sha256 = hashSkillDirectory(folder);
94
+ const payload = {
95
+ version: INTEGRITY_VERSION,
96
+ algorithm: "sha256",
97
+ source,
98
+ sha256,
99
+ };
100
+ writeFileSync(join(folder, SOURCE_FILE), JSON.stringify(payload, null, 2));
101
+ return sha256;
102
+ }
103
+ export function rebaselineSkills(names) {
104
+ const installed = listInstalledSkills();
105
+ const wanted = names && names.length > 0 ? new Set(names) : undefined;
106
+ const results = [];
107
+ let changed = false;
108
+ for (const skill of installed) {
109
+ if (wanted && !wanted.has(skill.name) && !wanted.has(basename(skill.path)))
110
+ continue;
111
+ try {
112
+ writeSourceMeta(skill.path, skill.source === "local" ? "local" : skill.source);
113
+ results.push({ name: skill.name, ok: true });
114
+ changed = true;
115
+ }
116
+ catch (err) {
117
+ results.push({ name: skill.name, ok: false, warning: err?.message ?? String(err) });
118
+ }
119
+ }
120
+ if (changed)
121
+ invalidateConfigCache();
122
+ return results;
123
+ }
23
124
  /** List skills currently installed under ~/.fastcode/skills. */
24
125
  export function listInstalledSkills() {
25
126
  const { skills } = getPaths();
@@ -35,16 +136,20 @@ export function listInstalledSkills() {
35
136
  continue;
36
137
  const raw = readFileSync(md, "utf8");
37
138
  const parsed = parseSkill(raw, dir.name);
38
- const sourcePath = join(folder, ".fastcode-source");
39
- const source = existsSync(sourcePath)
40
- ? readFileSync(sourcePath, "utf8").trim()
41
- : "local";
139
+ const meta = readSourceMeta(folder);
140
+ const actualHash = hashSkillDirectory(folder);
141
+ const integrity = !meta.sha256
142
+ ? "unverified"
143
+ : actualHash === meta.sha256 ? "ok" : "drift";
42
144
  out.push({
43
145
  name: parsed.name || dir.name,
44
146
  description: parsed.description,
45
- source,
147
+ source: meta.source,
46
148
  path: folder,
47
149
  body: parsed.body,
150
+ integrity,
151
+ sha256: actualHash,
152
+ lockedHash: meta.sha256,
48
153
  });
49
154
  }
50
155
  return out.sort((a, b) => a.name.localeCompare(b.name));
@@ -56,7 +161,7 @@ export function getSkill(name) {
56
161
  /** Install a skill. Returns the parsed manifest on success. */
57
162
  export async function installSkill(spec, opts = {}) {
58
163
  const { skills } = getPaths();
59
- const registry = (opts.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, "");
164
+ const registry = resolveRegistry(process.env["FASTCODE_SKILLS_REGISTRY"], opts.registry);
60
165
  let parsed;
61
166
  let source;
62
167
  let folderName;
@@ -68,7 +173,8 @@ export async function installSkill(spec, opts = {}) {
68
173
  }
69
174
  else if (looksLikeRepoPath(spec)) {
70
175
  // owner/repo or owner/repo/skill — fetch via registry's raw endpoint.
71
- const url = `${registry}/${spec}?format=md`;
176
+ const base = requireRegistry(registry);
177
+ const url = `${base}/${spec}?format=md`;
72
178
  const body = await fetchText(url);
73
179
  parsed = parseSkill(body, parts(spec).pop() ?? spec);
74
180
  source = url;
@@ -76,11 +182,12 @@ export async function installSkill(spec, opts = {}) {
76
182
  }
77
183
  else {
78
184
  // Single-token name → search the registry.
79
- const hits = await searchRegistry(spec, registry);
185
+ const base = requireRegistry(registry);
186
+ const hits = await searchRegistry(spec, base);
80
187
  const hit = hits[0];
81
188
  if (!hit)
82
- throw new Error(`No skill found for "${spec}" in ${registry}`);
83
- const url = hit.fetchUrl ?? `${registry}/${hit.slug}?format=md`;
189
+ throw new Error(`No skill found for "${spec}" in ${base}`);
190
+ const url = hit.fetchUrl ?? `${base}/${hit.slug}?format=md`;
84
191
  const body = await fetchText(url);
85
192
  parsed = parseSkill(body, hit.name);
86
193
  source = url;
@@ -94,13 +201,14 @@ export async function installSkill(spec, opts = {}) {
94
201
  rmSync(targetDir, { recursive: true, force: true });
95
202
  mkdirSync(targetDir, { recursive: true });
96
203
  writeFileSync(join(targetDir, "SKILL.md"), reassemble(parsed));
97
- writeFileSync(join(targetDir, ".fastcode-source"), source);
204
+ writeSourceMeta(targetDir, source);
98
205
  return {
99
206
  name: parsed.name || folderName,
100
207
  description: parsed.description,
101
208
  source,
102
209
  path: targetDir,
103
210
  body: parsed.body,
211
+ integrity: "ok",
104
212
  };
105
213
  }
106
214
  /** Remove an installed skill by name or folder. */
@@ -125,8 +233,10 @@ export function uninstallSkill(name) {
125
233
  return false;
126
234
  }
127
235
  /** Search the registry for skills matching a query. */
128
- export async function searchRegistry(query, registry = DEFAULT_REGISTRY) {
129
- const base = registry.replace(/\/+$/, "");
236
+ export async function searchRegistry(query, registry) {
237
+ const base = resolveRegistry(process.env["FASTCODE_SKILLS_REGISTRY"], registry);
238
+ if (!base)
239
+ return [];
130
240
  try {
131
241
  const json = await fetchJson(`${base}/api/v1/skills/search?q=${encodeURIComponent(query)}`);
132
242
  return json.data ?? [];
@@ -145,6 +255,13 @@ export function buildSkillsPrompt(enabled) {
145
255
  const skill = installed.find((s) => s.name === name || basename(s.path) === name);
146
256
  if (!skill)
147
257
  continue;
258
+ if (skill.integrity !== "ok") {
259
+ const reason = skill.integrity === "drift"
260
+ ? "content drift detected"
261
+ : "no integrity baseline (legacy install)";
262
+ process.stderr.write(`skill ${skill.name} ${reason}; disabled. Run /skills verify ${skill.name} --confirm to re-baseline.\n`);
263
+ continue;
264
+ }
148
265
  chunks.push(`<skill name="${skill.name}">\n${skill.body.trim()}\n</skill>`);
149
266
  }
150
267
  if (chunks.length === 0)
package/dist/types.d.ts CHANGED
@@ -121,6 +121,15 @@ export interface SlashCommand {
121
121
  /** Execute the command. Return text to render, or undefined for no output. */
122
122
  run(ctx: CommandContext): Promise<CommandResult> | CommandResult;
123
123
  }
124
+ export interface CommandCompaction {
125
+ summaryRef?: string;
126
+ source: "local" | "server" | "none";
127
+ beforeTokens: number;
128
+ afterTokens: number;
129
+ droppedMessages: number;
130
+ keepMessages: number;
131
+ reason?: "threshold" | "heap" | "manual";
132
+ }
124
133
  export interface CommandResult {
125
134
  /** Text rendered as a system message in the transcript. */
126
135
  message?: string;
@@ -130,6 +139,10 @@ export interface CommandResult {
130
139
  quit?: boolean;
131
140
  /** Open an interactive picker after the command runs. */
132
141
  picker?: PickerRequest;
142
+ /** Manual /compact result; the app appends the matching compacted fact. */
143
+ compacted?: CommandCompaction;
144
+ /** /clear result; the app appends a cleared fact so resume does not resurrect history. */
145
+ cleared?: boolean;
133
146
  }
134
147
  export interface CommandContext {
135
148
  args: string[];
@@ -163,6 +176,7 @@ export interface AppActions {
163
176
  setSystemPrompt(prompt: string): void;
164
177
  setSkillsRegistry(url: string): void;
165
178
  setReasoningEffort(effort: ReasoningEffort): void;
179
+ setShellPoolSize(size: number): void;
166
180
  enableSkill(name: string): void;
167
181
  disableSkill(name: string): void;
168
182
  enablePlugin(name: string): void;
@@ -178,6 +192,7 @@ export interface AppActions {
178
192
  startLogin(options: {
179
193
  provider?: string;
180
194
  model?: string;
195
+ flow?: "provider" | "model" | "login";
181
196
  }): void;
182
197
  }
183
198
  export interface SkillManifest {
@@ -9,7 +9,7 @@ export const StatusBar = ({ theme, providerId, model, inputTokens, outputTokens,
9
9
  const providerModel = `${providerId}/${model}`;
10
10
  const label = compact ? truncate(providerModel, Math.max(18, columns - 28)) : providerModel;
11
11
  const effort = reasoningEffort && reasoningEffort !== "auto" ? `effort:${reasoningEffort}` : "";
12
- return (_jsxs(Box, { flexDirection: compact ? "column" : "row", justifyContent: "space-between", borderStyle: "round", borderColor: theme.border, paddingX: 1, children: [_jsxs(Text, { color: theme.muted, children: [_jsx(Text, { color: theme.accent, children: "\u25CF" }), " ", label, _jsx(Text, { color: theme.muted, children: " \u00B7 " }), inputTokens, "\u2191 ", outputTokens, "\u2193 ", cost, effort ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: " \u00B7 " }), effort] })) : null, !compact ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: " \u00B7 " }), "skills: ", enabledSkillCount, _jsx(Text, { color: theme.muted, children: " \u00B7 " }), platformLabel()] })) : null] }), _jsx(Text, { color: theme.muted, children: compact
12
+ return (_jsxs(Box, { flexDirection: compact ? "column" : "row", justifyContent: "space-between", borderStyle: "round", borderColor: theme.border, paddingX: 1, children: [_jsxs(Text, { color: theme.muted, children: [_jsx(Text, { color: theme.accent, children: "\u25CF" }), " ", label, _jsx(Text, { color: theme.muted, children: " \u00B7 " }), inputTokens, "\u2191 ", outputTokens, "\u2193 ", cost, !compact && effort ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: " \u00B7 " }), effort] })) : null, !compact ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: " \u00B7 " }), "skills: ", enabledSkillCount, _jsx(Text, { color: theme.muted, children: " \u00B7 " }), platformLabel()] })) : null] }), _jsx(Text, { color: theme.muted, children: compact
13
13
  ? `${effort ? effort + " \u00B7 " : ""}${platformLabel()} \u00B7 skills:${enabledSkillCount} \u00B7 ${hint ?? "/help \u00B7 esc \u00B7 ctrl+c"}`
14
14
  : hint ?? "/help \u00B7 esc cancel \u00B7 ctrl+c quit" })] }));
15
15
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datename/fastcode-termux",
3
- "version": "0.1.6-beta",
3
+ "version": "0.1.8-beta.0",
4
4
  "description": "FastCode — a blazing-fast TUI coding assistant. Multi-LLM, skills.sh skills, slash commands. Works on Termux, Linux, Windows, and macOS.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  "node": ">=18.17"
22
22
  },
23
23
  "scripts": {
24
- "build": "tsc -p tsconfig.json && node scripts/build.mjs",
24
+ "build": "tsc -p tsconfig.build.json && node scripts/build.mjs",
25
25
  "build:bundle": "node scripts/build.mjs",
26
26
  "dev": "tsx src/index.ts",
27
27
  "start": "node bin/fastcode.mjs",
@@ -65,6 +65,7 @@
65
65
  "url": "https://github.com/dateName/fastcode-termux/issues"
66
66
  },
67
67
  "dependencies": {
68
+ "bash-parser": "^0.5.0",
68
69
  "boxen": "^7.1.1",
69
70
  "chalk": "^5.3.0",
70
71
  "cli-highlight": "^2.1.11",
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Stable NDJSON gate protocol for GUI/JSON clients.
3
+ *
4
+ * This module is pure: it validates and encodes one request/decision at a
5
+ * time. A headless transport can consume it without leaking TUI output to
6
+ * stdout. Hard Block requests never reach this code.
7
+ */
8
+ import type { ApprovalDecision, ApprovalOption, ApprovalRequest } from "./approval.js";
9
+ import { decisionForOption } from "./approval.js";
10
+
11
+ export const GATE_PROTOCOL_VERSION = 1;
12
+
13
+ export interface GateAskEvent {
14
+ event: "gate:ask";
15
+ version: number;
16
+ id: string;
17
+ tier: number;
18
+ action: string;
19
+ reason: string;
20
+ prompt: string;
21
+ source: string;
22
+ targets: string[];
23
+ diffstat: { files: number; lines: number };
24
+ options: Array<{ id: string; label: string; kind: string; default?: boolean }>;
25
+ default: string;
26
+ require_input?: string;
27
+ }
28
+
29
+ function optionId(option: ApprovalOption, index: number): string {
30
+ return option.id ?? `${option.kind}-${index}`;
31
+ }
32
+
33
+ function targetsFor(request: ApprovalRequest): string[] {
34
+ const args: any = request.args ?? {};
35
+ const values = [args.path, args.from, args.to, args.cwd, request.preview?.kind === "diff" ? "diff" : undefined];
36
+ return values.filter((value): value is string => typeof value === "string" && value.length > 0);
37
+ }
38
+
39
+ export function encodeGateAsk(request: ApprovalRequest): GateAskEvent {
40
+ const options = request.options.map((option, index) => ({
41
+ id: optionId(option, index),
42
+ label: option.label,
43
+ kind: option.kind,
44
+ ...(index === (request.defaultIndex ?? 0) ? { default: true } : {}),
45
+ }));
46
+ const defaultIndex = Math.max(0, Math.min(request.defaultIndex ?? 0, options.length - 1));
47
+ const text = request.preview?.kind === "diff" ? request.preview.text : "";
48
+ const feedbackOption = request.options.find((option) => option.kind === "feedback");
49
+ const requireInput = request.freeInput?.prompt ?? feedbackOption?.prompt;
50
+ return {
51
+ event: "gate:ask",
52
+ version: GATE_PROTOCOL_VERSION,
53
+ id: request.id,
54
+ tier: request.tier ?? 2,
55
+ action: request.tool,
56
+ reason: request.reason,
57
+ prompt: request.prompt ?? "Approval required",
58
+ source: request.source ?? "system",
59
+ targets: targetsFor(request),
60
+ diffstat: { files: text ? text.split(/\r?\n/).filter(Boolean).length : 0, lines: text ? text.split(/\r?\n/).length : 0 },
61
+ options,
62
+ default: options[defaultIndex]?.id ?? "",
63
+ ...(requireInput ? { require_input: requireInput } : {}),
64
+ };
65
+ }
66
+
67
+ export interface GateDecisionResult {
68
+ decision: ApprovalDecision;
69
+ error?: string;
70
+ }
71
+
72
+ /**
73
+ * Accept only a well-formed decision for this exact request id and one of its
74
+ * stable option ids. Malformed JSON, wrong ids, unknown options and missing
75
+ * required input all fail closed.
76
+ */
77
+ export function parseGateDecision(request: ApprovalRequest, line: string): GateDecisionResult {
78
+ let payload: any;
79
+ try {
80
+ payload = JSON.parse(line);
81
+ }
82
+ catch {
83
+ return { decision: { kind: "deny", reason: "malformed JSON decision" }, error: "malformed JSON" };
84
+ }
85
+ if (!payload || typeof payload !== "object" || payload.version !== GATE_PROTOCOL_VERSION)
86
+ return { decision: { kind: "deny", reason: "unsupported gate protocol version" }, error: "version" };
87
+ if (payload.event !== "gate:decision")
88
+ return { decision: { kind: "deny", reason: "unexpected gate event" }, error: "event" };
89
+ if (payload.id !== request.id)
90
+ return { decision: { kind: "deny", reason: "decision id does not match the pending request" }, error: "id" };
91
+ if (request.freeInput?.enabled && (typeof payload.input !== "string" || payload.input.trim() === ""))
92
+ return { decision: { kind: "deny", reason: "required free input is missing" }, error: "input" };
93
+ const option = request.options.find((entry, index) => optionId(entry, index) === payload.option);
94
+ if (!option)
95
+ return { decision: { kind: "deny", reason: "unknown approval option id" }, error: "option" };
96
+ if (option.kind === "feedback") {
97
+ const text = typeof payload.input === "string" ? payload.input.trim() : "";
98
+ if (!text)
99
+ return { decision: { kind: "deny", reason: "feedback input is required" }, error: "feedback" };
100
+ return { decision: { kind: "feedback", text } };
101
+ }
102
+ return { decision: decisionForOption(option) };
103
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Termux:API approval sheet.
3
+ *
4
+ * Returns undefined when termux-dialog is missing/times out/crashes so the
5
+ * caller can fall back to the TUI. A real cancellation returns a deny.
6
+ * The command is built with spawn args (no shell interpolation).
7
+ */
8
+ import { spawn } from "node:child_process";
9
+ import { decisionForOption, type ApprovalDecision, type ApprovalRequest } from "./approval.js";
10
+
11
+ export function nativeDecisionFromPayload(request: ApprovalRequest, payload: any): ApprovalDecision | undefined {
12
+ if (payload?.code === -1)
13
+ return { kind: "deny", reason: "termux-dialog cancelled" };
14
+ if (payload?.code !== 0 || !Number.isInteger(payload.index) ||
15
+ payload.index < 0 || payload.index >= request.options.length)
16
+ return { kind: "deny", reason: "termux-dialog returned an invalid selection" };
17
+ return decisionForOption(request.options[payload.index]);
18
+ }
19
+
20
+ async function nativeFeedback(prompt: string, signal?: AbortSignal, timeoutMs = 60_000): Promise<string | undefined> {
21
+ if (signal?.aborted)
22
+ return undefined;
23
+ return await new Promise((resolve) => {
24
+ let settled = false;
25
+ let child: ReturnType<typeof spawn> | undefined;
26
+ let timer: ReturnType<typeof setTimeout> | undefined;
27
+ const finish = (value: string | undefined) => {
28
+ if (settled)
29
+ return;
30
+ settled = true;
31
+ if (timer)
32
+ clearTimeout(timer);
33
+ signal?.removeEventListener("abort", onAbort);
34
+ resolve(value);
35
+ };
36
+ const onAbort = () => {
37
+ try {
38
+ child?.kill("SIGKILL");
39
+ }
40
+ catch {
41
+ // Already gone.
42
+ }
43
+ finish(undefined);
44
+ };
45
+ signal?.addEventListener("abort", onAbort, { once: true });
46
+ child = spawn("termux-dialog", ["text", "-t", prompt], {
47
+ stdio: ["ignore", "pipe", "ignore"],
48
+ windowsHide: true,
49
+ });
50
+ let stdout = "";
51
+ child.stdout?.setEncoding("utf8");
52
+ child.stdout?.on("data", (chunk: string) => {
53
+ stdout += chunk;
54
+ });
55
+ child.on("error", () => finish(undefined));
56
+ child.on("close", () => {
57
+ let payload: any;
58
+ try {
59
+ payload = JSON.parse(stdout.trim());
60
+ }
61
+ catch {
62
+ finish(undefined);
63
+ return;
64
+ }
65
+ if (payload?.code === 0 && typeof payload.text === "string")
66
+ finish(payload.text);
67
+ else
68
+ finish(undefined);
69
+ });
70
+ timer = setTimeout(() => {
71
+ try {
72
+ child?.kill("SIGKILL");
73
+ }
74
+ catch {
75
+ // Already gone.
76
+ }
77
+ finish(undefined);
78
+ }, Math.max(1000, timeoutMs));
79
+ timer.unref?.();
80
+ });
81
+ }
82
+
83
+ export async function nativeApproval(request: ApprovalRequest, signal?: AbortSignal): Promise<ApprovalDecision | undefined> {
84
+ if (signal?.aborted)
85
+ return { kind: "deny", reason: "approval cancelled" };
86
+ const labels = request.options.map((option) => option.label);
87
+ const title = request.prompt ?? `Approval: ${request.tool}`;
88
+ return await new Promise((resolve) => {
89
+ let settled = false;
90
+ let child: ReturnType<typeof spawn> | undefined;
91
+ let timer: ReturnType<typeof setTimeout> | undefined;
92
+ const finish = (value: ApprovalDecision | undefined) => {
93
+ if (settled)
94
+ return;
95
+ settled = true;
96
+ if (timer)
97
+ clearTimeout(timer);
98
+ signal?.removeEventListener("abort", onAbort);
99
+ resolve(value);
100
+ };
101
+ const onAbort = () => {
102
+ try {
103
+ child?.kill("SIGKILL");
104
+ }
105
+ catch {
106
+ // Already gone.
107
+ }
108
+ finish({ kind: "deny", reason: "approval cancelled" });
109
+ };
110
+ signal?.addEventListener("abort", onAbort, { once: true });
111
+ child = spawn("termux-dialog", ["sheet", "-v", labels.join(","), "-t", title], {
112
+ stdio: ["ignore", "pipe", "ignore"],
113
+ windowsHide: true,
114
+ });
115
+ let stdout = "";
116
+ child.stdout?.setEncoding("utf8");
117
+ child.stdout?.on("data", (chunk: string) => {
118
+ stdout += chunk;
119
+ });
120
+ child.on("error", () => finish(undefined));
121
+ child.on("close", async () => {
122
+ let payload: any;
123
+ try {
124
+ payload = JSON.parse(stdout.trim());
125
+ }
126
+ catch {
127
+ finish(undefined);
128
+ return;
129
+ }
130
+ const selected = payload?.code === 0 && Number.isInteger(payload.index)
131
+ ? request.options[payload.index]
132
+ : undefined;
133
+ if (selected?.kind === "feedback") {
134
+ if (timer) {
135
+ clearTimeout(timer);
136
+ timer = undefined;
137
+ }
138
+ const prompt = selected.prompt || "Why should this be denied?";
139
+ const text = await nativeFeedback(prompt, signal, request.timeoutMs);
140
+ if (signal?.aborted) {
141
+ finish({ kind: "deny", reason: "approval cancelled" });
142
+ return;
143
+ }
144
+ if (typeof text !== "string") {
145
+ // Text dialog unavailable/cancelled: fall back to the TUI prompt.
146
+ finish(undefined);
147
+ return;
148
+ }
149
+ const feedback = text.trim();
150
+ finish(feedback
151
+ ? { kind: "feedback", text: feedback }
152
+ : { kind: "deny", reason: "feedback was empty (fail closed)" });
153
+ return;
154
+ }
155
+ finish(nativeDecisionFromPayload(request, payload));
156
+ });
157
+ timer = setTimeout(() => {
158
+ try {
159
+ child?.kill("SIGKILL");
160
+ }
161
+ catch {
162
+ // Already gone.
163
+ }
164
+ finish(undefined);
165
+ }, Math.max(1000, request.timeoutMs ?? 60_000));
166
+ timer.unref?.();
167
+ });
168
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Approval scope storage.
3
+ *
4
+ * - once: no persistence
5
+ * - session: in-memory, implicitly cleared when the process exits
6
+ * - always: ~/.fastcode/trusted.json `tools` entry bound to catalogHash
7
+ *
8
+ * Keys include the normalized tool-call fingerprint so "always" cannot grant
9
+ * every future shell command from one approval.
10
+ */
11
+ import type { ApprovalScope } from "./approval.js";
12
+ import type { ToolCall } from "./tools/types.js";
13
+ import { toolCallFingerprint } from "./guards.js";
14
+ import { isToolApproved, rememberToolApproval } from "../config/trust.js";
15
+
16
+ const sessionApprovals = new Set<string>();
17
+
18
+ export function approvalKey(tool: string, call: ToolCall, cwd: string): string {
19
+ return `${tool}:${toolCallFingerprint(call, cwd)}`;
20
+ }
21
+
22
+ export function isPreApproved(key: string, catalogHash: string): boolean {
23
+ return sessionApprovals.has(`${catalogHash}:${key}`) || isToolApproved(key, catalogHash);
24
+ }
25
+
26
+ export function rememberApproval(key: string, catalogHash: string, scope: ApprovalScope): void {
27
+ if (scope === "once")
28
+ return;
29
+ if (scope === "session") {
30
+ sessionApprovals.add(`${catalogHash}:${key}`);
31
+ return;
32
+ }
33
+ rememberToolApproval(key, catalogHash);
34
+ }
35
+
36
+ export function clearSessionApprovals(): void {
37
+ sessionApprovals.clear();
38
+ }