@hncode/hncode 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,37 +1,37 @@
1
- {
2
- "name": "@hncode/hncode",
3
- "version": "0.6.0",
4
- "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
- "type": "module",
6
- "main": "src/index.js",
7
- "bin": {
8
- "hncode": "bin/hncode"
9
- },
10
- "files": [
11
- "bin/",
12
- "src/",
13
- "LICENSE",
14
- "README.md",
15
- "README_CN.md",
16
- "package.json"
17
- ],
18
- "scripts": {
19
- "preinstall": "node bin/preinstall.cjs",
20
- "install": "node bin/install.cjs",
21
- "i18n": "node tools/sync-i18n.mjs",
22
- "i18n:check": "node tools/sync-i18n.mjs --check",
23
- "test": "node --test \"test/**/*.test.mjs\""
24
- },
25
- "engines": {
26
- "node": ">=20"
27
- },
28
- "license": "PolyForm-Noncommercial-1.0.0",
29
- "author": "NiceHello666",
30
- "keywords": [
31
- "cli",
32
- "agent",
33
- "coding",
34
- "ai",
35
- "tui"
36
- ]
37
- }
1
+ {
2
+ "name": "@hncode/hncode",
3
+ "version": "0.6.2",
4
+ "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "hncode": "bin/hncode"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "LICENSE",
14
+ "README.md",
15
+ "README_CN.md",
16
+ "package.json"
17
+ ],
18
+ "scripts": {
19
+ "preinstall": "node bin/preinstall.cjs",
20
+ "install": "node bin/install.cjs",
21
+ "i18n": "node tools/sync-i18n.mjs",
22
+ "i18n:check": "node tools/sync-i18n.mjs --check",
23
+ "test": "node --test \"test/**/*.test.mjs\""
24
+ },
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "license": "PolyForm-Noncommercial-1.0.0",
29
+ "author": "NiceHello666",
30
+ "keywords": [
31
+ "cli",
32
+ "agent",
33
+ "coding",
34
+ "ai",
35
+ "tui"
36
+ ]
37
+ }
package/src/clipboard.js CHANGED
@@ -62,33 +62,51 @@ function win() { return process.platform === 'win32'; }
62
62
  /**
63
63
  * Put text on the clipboard.
64
64
  *
65
- * WINDOWS: `Set-Clipboard` through PowerShell, NOT `clip.exe`.
65
+ * WINDOWS: `clip.exe` on STDIN, with the PowerShell path as the fallback.
66
66
  *
67
- * `clip.exe` takes UTF-16LE and needed a byte order mark to be read correctly — without it
68
- * non-ASCII arrived as mojibake (`中文测试` came back as `-N噀Km諎`, measured). But the mark is then
69
- * part of the clipboard text: every copy began with a zero-width U+FEFF that showed up as a stray
70
- * invisible character wherever it was pasted. Measured over six samples, `Set-Clipboard` was better
71
- * on every one:
67
+ * An earlier version chose `Set-Clipboard` because `clip.exe` prefixed every copy with a zero-width U+FEFF.
68
+ * That was a BOM problem, not a `clip.exe` problem: it reads stdin as a byte stream, so a leading `FF FE` is
69
+ * two literal characters. Sent as plain UTF-16LE with no mark, it round-trips exactly — verified on ASCII,
70
+ * Chinese, emoji, embedded newlines and 400000 characters.
72
71
  *
73
- * copied clip.exe + BOM Set-Clipboard
74
- * hello U+FEFF hello hello
75
- * 中文测试 U+FEFF 中文测试 中文测试
76
- * line1\nline2 U+FEFF line1 CRLF line2 line1\nline2 (line endings untouched)
77
- * emoji 🎉 ok U+FEFF emoji 🎉 ok emoji 🎉 ok
72
+ * Two measured reasons to prefer it:
78
73
  *
79
- * It also needs no escaping: PowerShell receives a .NET string rather than a byte stream, so there
80
- * is no console parser to satisfy — which is what the BOM and the quoting were working around.
74
+ * characters clip.exe pwsh one-shot
75
+ * 100 77ms 788ms
76
+ * 5000 56ms 748ms
77
+ * 20000 61ms ENAMETOOLONG <- the command line is capped near 32KB
78
+ * 400000 58ms ENAMETOOLONG
81
79
  *
82
- * `clip.exe` stays as a fallback for a machine with no PowerShell at all, where a copy carrying a
83
- * stray BOM beats no copy.
80
+ * `clip.exe` takes the text on STDIN, so its cost does not depend on the length and it has no ceiling; the
81
+ * PowerShell route puts the text on its COMMAND LINE and fails outright past ~20k characters. The one
82
+ * difference that remains: `clip.exe` normalises `\n` to `\r\n`, which is what the Windows clipboard expects
83
+ * anyway.
84
+ *
85
+ * `setClipboardText` stays for a machine with no `clip.exe`.
84
86
  */
85
87
  export function copyText(text) {
86
88
  const s = String(text == null ? '' : text);
87
89
  if (!s) return false;
88
90
  if (win()) {
89
- if (setClipboardText(s)) return true;
90
- cp.execSync('clip', { input: Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(s, 'utf16le')]), windowsHide: true, timeout: 5000 });
91
- return true;
91
+ // `clip.exe` FIRST, fed UTF-8 through a 65001 console.
92
+ //
93
+ // Measured 150-193ms at every size from 100 to 400000 characters, against 748-788ms for a PowerShell
94
+ // one-shot — and the PowerShell route puts the text on its COMMAND LINE, which Windows caps near 32KB, so
95
+ // a 20k-character copy failed outright with ENAMETOOLONG.
96
+ //
97
+ // THE ENCODING IS NOT UTF-16. `clip.exe` decodes stdin with the console code page, which is 936 (GBK) on a
98
+ // Chinese Windows: UTF-16LE bytes came back byte-swapped (`你好` U+4F60 U+597D arrived as U+604F U+597D)
99
+ // and UTF-8 bytes without the matching code page came back as mojibake. `chcp 65001` on the child and UTF-8
100
+ // in the pipe is what round-trips — verified on ASCII, Chinese, emoji and 400k characters.
101
+ //
102
+ // Synchronous, because the caller announces "Copied" the moment this returns.
103
+ const r = cp.spawnSync('cmd', ['/c', 'chcp 65001 >nul && clip'], {
104
+ input: Buffer.from(s, 'utf8'),
105
+ windowsHide: true,
106
+ timeout: 5000,
107
+ });
108
+ if (r && r.status === 0) return true;
109
+ return setClipboardText(s);
92
110
  }
93
111
  const cmd = process.platform === 'darwin' ? 'pbcopy' : 'xclip -selection clipboard';
94
112
  cp.execSync(cmd, { input: Buffer.from(s, 'utf8'), timeout: 5000 });
@@ -113,6 +131,41 @@ function setClipboardText(s) {
113
131
  return false;
114
132
  }
115
133
 
134
+ /**
135
+ * Put text on the clipboard WITHOUT blocking the event loop.
136
+ *
137
+ * `copyText` is synchronous because most callers want to know the outcome before they say "Copied". A copy is
138
+ * 150-190ms of child process on Windows, which is long enough to be felt as a stutter when it runs between two
139
+ * paints. The TUI uses this where a reply is not needed.
140
+ *
141
+ * @param {string} text
142
+ * @returns {Promise<boolean>}
143
+ */
144
+ export function copyTextAsync(text) {
145
+ const s = String(text == null ? '' : text);
146
+ if (!s) return Promise.resolve(false);
147
+ if (!win()) {
148
+ try { copyText(s); return Promise.resolve(true); } catch { return Promise.resolve(false); }
149
+ }
150
+ return new Promise((resolve) => {
151
+ try {
152
+ // Same command and encoding as `copyText`: the code page has to be set on the child, and the payload is
153
+ // UTF-8. See the note there for why UTF-16 does not work.
154
+ const child = cp.spawn('cmd', ['/c', 'chcp 65001 >nul && clip'], {
155
+ windowsHide: true,
156
+ stdio: ['pipe', 'ignore', 'ignore'],
157
+ });
158
+ child.on('error', () => resolve(false));
159
+ child.on('close', (code) => resolve(code === 0));
160
+ child.stdin.on('error', () => { /* a broken pipe is reported by `close` */ });
161
+ child.stdin.end(Buffer.from(s, 'utf8'));
162
+ } catch {
163
+ // No clip.exe: fall back to the blocking route rather than losing the copy.
164
+ try { resolve(setClipboardText(s)); } catch { resolve(false); }
165
+ }
166
+ });
167
+ }
168
+
116
169
  // Which PowerShell the helper runs. `pwsh` (7) starts far faster than the built-in
117
170
  // `powershell` (5.1) — measured 1.9s vs 11.5s for the same image probe — and the image
118
171
  // path needs the WinForms/Drawing assemblies. This is only the PREFERRED name: it is
@@ -193,7 +246,13 @@ function startHelper(exe) {
193
246
  // unavailable" for a clipboard that had content. The queued resolver here is what
194
247
  // marks the shell as ready, so one sentinel still has exactly one owner.
195
248
  helperQueue.push(() => { helperReady = true; });
196
- helper.stdin.write("try { Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing } catch {}\n'" + SENT + "'\n");
249
+ // The sequence probe's P/Invoke declaration is compiled here too: the first `Add-Type` on a cold shell
250
+ // measured ~900ms, which is exactly the cost this warm-up exists to move off the paste path.
251
+ helper.stdin.write(
252
+ "try { Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing } catch {}\n"
253
+ + "try { if(-not ('HnClip' -as [type])){Add-Type -Namespace '' -Name 'HnClip' -MemberDefinition '[DllImport(\"user32.dll\")] public static extern uint GetClipboardSequenceNumber();'} } catch {}\n"
254
+ + "'" + SENT + "'\n",
255
+ );
197
256
  }
198
257
 
199
258
  /** Tear down the helper and release every waiter. A read must never be left hanging on
@@ -249,7 +308,10 @@ function helperRun(script, timeoutMs) {
249
308
  function readOnce() {
250
309
  if (win()) {
251
310
  const b64 = runPowershell(
252
- '-NoProfile -Command "[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw) + \'\'))"',
311
+ // `Clipboard::GetText()` instead of `Get-Clipboard -Raw`: measured on a 20k-character text, the cmdlet
312
+ // took 169ms and the .NET call 13ms. `Get-Clipboard` goes through PowerShell's COM wrapper, which
313
+ // copies the string several times before it reaches the pipeline.
314
+ '-NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms; [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([System.Windows.Forms.Clipboard]::GetText() + \'\'))"',
253
315
  { encoding: 'ascii', windowsHide: true, timeout: ONESHOT_MS },
254
316
  );
255
317
  if (b64 == null) throw new Error('no powershell');
@@ -269,7 +331,7 @@ export async function readText(timeoutMs = HELPER_CMD_MS) {
269
331
  // warming is answered by nothing, and the cold path is the expensive one.
270
332
  if (helper && await waitHelperReady(HELPER_READY_MS)) {
271
333
  helperBusy = true;
272
- const out = await helperRun('[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw) + ""))', timeoutMs);
334
+ const out = await helperRun('[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([System.Windows.Forms.Clipboard]::GetText() + ""))', timeoutMs);
273
335
  if (out != null) {
274
336
  const b64 = out.replace(/\r/g, '').split('\n').filter((l) => l.trim()).pop() || '';
275
337
  try { return { text: Buffer.from(b64.trim(), 'base64').toString('utf8'), via: 'helper' }; } catch {}
@@ -301,7 +363,38 @@ export async function readText(timeoutMs = HELPER_CMD_MS) {
301
363
  // PowerShell expression (silently returned nothing), which made FILE pastes fall
302
364
  // back to the slow cold spawn. Emitting one F:: line per file is simpler and
303
365
  // also supports multi-file copies.
304
- //
366
+ /**
367
+ * Just the clipboard's TEXT, or '' — no file list, no image.
368
+ *
369
+ * Asked before paying for a PNG: a browser's image copy carries the pixels AND the address, and only the text
370
+ * formats can hold that address. Cheap on the warm helper, which is already running by the time anyone pastes.
371
+ *
372
+ * @returns {Promise<string>}
373
+ */
374
+ export async function readClipboardTextOnly() {
375
+ if (!win()) { try { return readOnce() || ''; } catch { return ''; } }
376
+ warmClipboard();
377
+ if (!helper) {
378
+ try { return readOnce() || ''; } catch { return ''; }
379
+ }
380
+ if (!helperReady && !await waitHelperReady(HELPER_READY_MS)) {
381
+ try { return readOnce() || ''; } catch { return ''; }
382
+ }
383
+ for (let i = 0; i < 20 && helperBusy; i++) await new Promise((r) => setTimeout(r, 25));
384
+ if (helperBusy || !helper) {
385
+ try { return readOnce() || ''; } catch { return ''; }
386
+ }
387
+ helperBusy = true;
388
+ let out;
389
+ try {
390
+ out = await helperRun(CLIP_PROBE_TEXT, HELPER_CMD_MS);
391
+ } finally {
392
+ helperBusy = false;
393
+ }
394
+ const line = String(out || '').split('\n').map((l) => l.trim()).find((l) => l.startsWith('T::'));
395
+ if (!line) return '';
396
+ try { return Buffer.from(line.slice(3), 'base64').toString('utf8'); } catch { return ''; }
397
+ }
305
398
  // The image branch uses the .NET WinForms clipboard, NOT `Get-Clipboard -Image`:
306
399
  // that parameter only exists on some builds (and is absent even from the pwsh 7
307
400
  // in use here), so it silently failed everywhere and image paste never worked.
@@ -321,15 +414,24 @@ export async function readText(timeoutMs = HELPER_CMD_MS) {
321
414
  */
322
415
  const CLIP_PROBE_TEXT = [
323
416
  "$ErrorActionPreference='SilentlyContinue'",
324
- "$t=Get-Clipboard -Raw",
417
+ // .NET, not the cmdlet: 13ms against 169ms on the same 20k-character text.
418
+ "$t=[System.Windows.Forms.Clipboard]::GetText()",
325
419
  "if($t){'T::'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($t))}",
326
420
  ].join('; ');
327
421
 
328
422
  const CLIP_PROBE = [
329
423
  "$ErrorActionPreference='SilentlyContinue'",
330
- "$f=Get-Clipboard -Format FileDropList",
331
- "if($f -and $f.Count -gt 0){$f|ForEach-Object{'F::'+$_.FullName};return}",
332
- "$t=Get-Clipboard -Raw",
424
+ // FILES COME FROM THE .NET CLIPBOARD, not `Get-Clipboard -Format FileDropList`.
425
+ //
426
+ // That parameter does not exist in PowerShell 7 — `Get-Clipboard: A parameter cannot be found that matches
427
+ // parameter name 'Format'` — and the `SilentlyContinue` above turns the error into an empty result, so a
428
+ // copied file read as "clipboard unavailable" while copied text worked. `Clipboard::GetFileDropList()` is the
429
+ // same call on both versions; verified on the 5.1 build in System32 and on pwsh 7 here.
430
+ "Add-Type -AssemblyName System.Windows.Forms",
431
+ "$f=[System.Windows.Forms.Clipboard]::GetFileDropList()",
432
+ "if($f -and $f.Count -gt 0){$f|ForEach-Object{'F::'+$_};return}",
433
+ // .NET, not the cmdlet: 13ms against 169ms on the same 20k-character text.
434
+ "$t=[System.Windows.Forms.Clipboard]::GetText()",
333
435
  "if($t){'T::'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($t));return}",
334
436
  // The assemblies are loaded once by warmClipboard(); no Add-Type here, so the
335
437
  // probe stays fast even the first time an image is on the clipboard.
@@ -337,6 +439,22 @@ const CLIP_PROBE = [
337
439
  "if($null -ne $i){$p=Join-Path $env:TEMP ('hncode-paste-'+[guid]::NewGuid().ToString('N')+'.png');$i.Save($p,[System.Drawing.Imaging.ImageFormat]::Png);'I::'+$p}",
338
440
  ].join('; ');
339
441
 
442
+ /**
443
+ * The clipboard's sequence number, one integer.
444
+ *
445
+ * Windows bumps it on every clipboard write, so comparing two readings answers "did the content change"
446
+ * without reading the content. `Get-Clipboard` has no equivalent, so this P/Invokes `user32` — once, because
447
+ * the type stays compiled for the life of the warm shell.
448
+ *
449
+ * A real read costs time proportional to the payload (measured: 14ms at 1k characters, 48ms at 400k); this
450
+ * does not, which is what makes a repeat paste of a long text instant.
451
+ */
452
+ const CLIP_SEQ_PROBE = [
453
+ "$ErrorActionPreference='SilentlyContinue'",
454
+ "if(-not ('HnClip' -as [type])){Add-Type -Namespace '' -Name 'HnClip' -MemberDefinition '[DllImport(\"user32.dll\")] public static extern uint GetClipboardSequenceNumber();'}",
455
+ "'S::'+[HnClip]::GetClipboardSequenceNumber()",
456
+ ].join('; ');
457
+
340
458
  // Parse the probe output into { text, via }. Handles one-or-more F:: lines.
341
459
  function parseClipResult(out) {
342
460
  const lines = String(out == null ? '' : out).replace(/\r/g, '').split('\n').map((l) => l.trim()).filter(Boolean);
@@ -353,6 +471,38 @@ function parseClipResult(out) {
353
471
  return null;
354
472
  }
355
473
 
474
+ /**
475
+ * A URL hidden in the TEXT of a copied image, or null.
476
+ *
477
+ * Copying an image out of a browser puts several formats on the clipboard at once: the pixels, an HTML
478
+ * fragment (`<img src="…">`, sometimes a `data:` URL) and, for a plain image, the image's own address. The
479
+ * pixel route costs a PNG written to disk for something the model can fetch directly — so the text formats
480
+ * are checked FIRST and only a clipboard with no address at all becomes a file.
481
+ *
482
+ * Only http(s) and `data:` are accepted. A `file:` URL is the same thing as a copied file, which the file
483
+ * drop list already covers.
484
+ *
485
+ * @param {string} text The clipboard's text, possibly an HTML fragment.
486
+ * @returns {string|null}
487
+ */
488
+ export function imageUrlFromClipboardText(text) {
489
+ const s = String(text || '').trim();
490
+ if (!s) return null;
491
+ // A bare URL: the image's own address was copied.
492
+ const bare = /^(https?:\/\/\S+|data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=\s]+)$/i.exec(s);
493
+ if (bare) return bare[1].replace(/\s+/g, '').trim();
494
+ // An HTML fragment: take the src of the first <img>, which is what a browser's image copy produces.
495
+ const img = /<img\b[^>]*\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(s);
496
+ if (img) {
497
+ const url = (img[2] || img[3] || img[4] || '').trim();
498
+ if (/^(https?:\/\/|data:image\/)/i.test(url)) return url.replace(/\s+/g, '');
499
+ }
500
+ // A plain address on its own line inside a longer text selection.
501
+ const line = s.split(/\r?\n/).map((l) => l.trim())
502
+ .find((l) => /^https?:\/\/\S+\.(png|jpe?g|gif|webp|bmp|svg)(\?\S*)?$/i.test(l));
503
+ return line || null;
504
+ }
505
+
356
506
  // ---- pasted-image lifecycle ------------------------------------------------
357
507
  // A pasted image is written to a temp .png (the model needs a PATH to hand to
358
508
  // ReadMediaFile), so something has to remove it again or the temp dir fills up
@@ -442,14 +592,49 @@ export function readClipboardContent() {
442
592
  return parseClipResult(out) || { text: '', via: 'none' };
443
593
  }
444
594
 
595
+ // ---- the fast path's memory --------------------------------------------------
596
+ //
597
+ // `GetClipboardSequenceNumber` is one integer, and Windows bumps it on every clipboard write. Asking the
598
+ // helper for it is a single cheap call; when it matches the last read, the previous result is returned without
599
+ // touching the clipboard at all. That is what makes a repeat paste instant regardless of how long the text is —
600
+ // the read cost scaled with the payload, but the sequence check does not.
601
+ let lastSeq = null;
602
+ let lastResult = null;
603
+ /** The sequence number alone, read on the warm helper. Null when it is not available. */
604
+ async function readClipboardSequence() {
605
+ warmClipboard();
606
+ if (!helper) return null;
607
+ if (!helperReady && !await waitHelperReady(HELPER_READY_MS)) return null;
608
+ if (helperBusy) return null; // somebody is mid-command; the caller can just read normally
609
+ helperBusy = true;
610
+ let out;
611
+ try {
612
+ out = await helperRun(CLIP_SEQ_PROBE, HELPER_CMD_MS);
613
+ } finally {
614
+ helperBusy = false;
615
+ }
616
+ const line = String(out || '').split('\n').map((l) => l.trim()).find((l) => l.startsWith('S::'));
617
+ if (!line) return null;
618
+ const n = Number(line.slice(3));
619
+ return Number.isFinite(n) ? n : null;
620
+ }
621
+
445
622
  // Async version that prefers the warm helper (~10ms) over a cold spawn (~2.9s).
446
623
  export async function readClipboardContentAsync() {
447
624
  if (!win()) {
448
625
  try { return { text: readOnce(), via: 'oneshot' }; } catch { return { text: '', via: 'none' }; }
449
626
  }
627
+ // A CLIPBOARD THAT HAS NOT CHANGED IS NOT READ AGAIN.
628
+ //
629
+ // The measured cost of a read is dominated by the payload (14ms for 1k characters, 48ms for 400k) and the
630
+ // user pays it on every paste even when the clipboard holds the same thing. The sequence number answers "did
631
+ // it change" in one cheap call, so a repeat paste of the same large text is instant.
632
+ const seq = await readClipboardSequence();
633
+ if (seq !== null && seq === lastSeq && lastResult) return { ...lastResult };
450
634
  const viaHelper = await readViaHelper();
451
- if (viaHelper) return viaHelper;
452
- return readClipboardContent();
635
+ const result = viaHelper || readClipboardContent();
636
+ if (seq !== null) { lastSeq = seq; lastResult = result; }
637
+ return result;
453
638
  }
454
639
 
455
640
  // The PowerShell logic for the one-shot fallback lives in a temp .ps1 file:
@@ -461,13 +646,16 @@ function clipScriptPath() {
461
646
  const p = path.join(os.tmpdir(), 'hncode-clipread.ps1');
462
647
  const script = [
463
648
  "$ErrorActionPreference = 'SilentlyContinue'",
464
- '$files = Get-Clipboard -Format FileDropList',
465
- 'if ($files -and $files.Count -gt 0) { $files | ForEach-Object { "F::" + $_.FullName }; exit }',
466
- '$txt = Get-Clipboard -Raw',
467
- 'if ($txt) { "T::" + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($txt)); exit }',
468
- // .NET clipboard, not `Get-Clipboard -Image` (see the CLIP_PROBE note).
649
+ // .NET clipboard for BOTH files and images. `Get-Clipboard -Format FileDropList` is missing from
650
+ // PowerShell 7, and with SilentContinue that turned a copied file into "nothing on the clipboard".
469
651
  'Add-Type -AssemblyName System.Windows.Forms',
470
652
  'Add-Type -AssemblyName System.Drawing',
653
+ '$files = [System.Windows.Forms.Clipboard]::GetFileDropList()',
654
+ 'if ($files -and $files.Count -gt 0) { $files | ForEach-Object { "F::" + $_ }; exit }',
655
+ // .NET, not the cmdlet: the cmdlet copies the string several times before the pipeline sees it.
656
+ '$txt = [System.Windows.Forms.Clipboard]::GetText()',
657
+ 'if ($txt) { "T::" + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($txt)); exit }',
658
+ // .NET clipboard, not `Get-Clipboard -Image` (see the CLIP_PROBE note).
471
659
  '$img = [System.Windows.Forms.Clipboard]::GetImage()',
472
660
  'if ($null -ne $img) {',
473
661
  ' $path = Join-Path $env:TEMP ("hncode-paste-" + [guid]::NewGuid().ToString("N") + ".png")',
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The Goal tool: the agent's own objective across turns.
3
+ *
4
+ * A turn ends when the model stops. Some work does not fit in one turn — a refactor across a dozen files, a
5
+ * migration, a suite that needs several rounds — and without a goal the agent simply stops halfway and waits
6
+ * to be told to continue. The goal makes that explicit: a line the agent wrote for itself, shown in the status
7
+ * panel, and the turn loop restarts the agent while one is set.
8
+ *
9
+ * ONE STORE, NOT TWO. The objective lives in the TUI's `state.objective` — the same field `/goal` writes — and
10
+ * this module only borrows it through a listener. A second copy here would drift the moment the user typed
11
+ * `/goal cancel`, and the loop would restart against an objective nobody could see.
12
+ *
13
+ * THREE ACTIONS:
14
+ *
15
+ * set Start or replace the objective.
16
+ * status Read it back, so a resumed turn can re-read what it was doing.
17
+ * clear Finish. Nothing restarts after this, which is why it has to be deliberate.
18
+ */
19
+
20
+ /** Read the goal. Installed by startTUI, backed by `state.objective`. */
21
+ let readGoal = () => '';
22
+ /** Write the goal. Installed by startTUI. */
23
+ let writeGoal = () => {};
24
+
25
+ /**
26
+ * @param {{get: () => string, set: (goal: string) => void}} api
27
+ */
28
+ export function installGoalStore(api) {
29
+ if (api && typeof api.get === 'function') readGoal = api.get;
30
+ if (api && typeof api.set === 'function') writeGoal = api.set;
31
+ }
32
+
33
+ /** The current objective, or ''. */
34
+ export function getGoal() {
35
+ try { return String(readGoal() || '').trim(); } catch { return ''; }
36
+ }
37
+
38
+ export const spec = {
39
+ name: 'Goal',
40
+ description: [
41
+ 'Set, read or clear the objective you are working towards across turns.',
42
+ '',
43
+ 'A turn ends when you stop responding. While a goal is set the agent restarts you with it quoted back, so',
44
+ 'use it for work that genuinely needs several turns — not for something you can finish now.',
45
+ '',
46
+ '- set: start or replace it. Write a finishable outcome ("all callers of X migrated and the suite passes"),',
47
+ ' not an activity ("work on X").',
48
+ '- status: read it back.',
49
+ '- clear: you are done. Call this the moment the goal is met; otherwise the loop restarts you again.',
50
+ '',
51
+ 'The user can set the same goal with /goal and can pause it. Never clear a goal you have not met.',
52
+ ].join('\n'),
53
+ parameters: {
54
+ type: 'object',
55
+ properties: {
56
+ action: {
57
+ type: 'string',
58
+ enum: ['set', 'status', 'clear'],
59
+ description: 'set = start or replace, status = read back, clear = finished.',
60
+ },
61
+ goal: {
62
+ type: 'string',
63
+ description: 'The objective. Required for `set`; a finishable outcome, not an activity.',
64
+ },
65
+ },
66
+ required: ['action'],
67
+ },
68
+
69
+ /**
70
+ * @param {{action: string, goal?: string}} args
71
+ * @returns {string}
72
+ */
73
+ execute(args) {
74
+ const action = String((args && args.action) || '').toLowerCase();
75
+ if (action === 'set') {
76
+ const goal = String((args && args.goal) || '').trim();
77
+ if (!goal) return 'Error: `set` needs a goal — the outcome you are working towards.';
78
+ try { writeGoal(goal); } catch { return 'Error: could not store the goal.'; }
79
+ return `Goal set: ${goal}\nThe turn loop restarts you with this until you call Goal with action clear.`;
80
+ }
81
+ if (action === 'status') {
82
+ const goal = getGoal();
83
+ return goal ? `Goal: ${goal}` : 'No goal is set.';
84
+ }
85
+ if (action === 'clear') {
86
+ const goal = getGoal();
87
+ if (!goal) return 'No goal to clear.';
88
+ try { writeGoal(''); } catch { return 'Error: could not clear the goal.'; }
89
+ return `Goal cleared: ${goal}\nNothing will restart this turn now.`;
90
+ }
91
+ return `Error: unknown action "${args && args.action}". Use set, status or clear.`;
92
+ },
93
+ };
@@ -22,6 +22,7 @@ import { TEAM_TOOL_SPECS } from './team.js';
22
22
  import { spec as notebookSpec } from './notebook.js';
23
23
  import { spec as diagnosticsSpec } from './diagnostics.js';
24
24
  import * as memoryMod from './memory.js';
25
+ import * as goalMod from './goal.js';
25
26
  import { pluginTools, pluginToolGeneration } from '../plugin.js';
26
27
 
27
28
 
@@ -55,6 +56,7 @@ const builtinTools = [
55
56
  notebookSpec,
56
57
  diagnosticsSpec,
57
58
  memoryMod.spec,
59
+ goalMod.spec,
58
60
  ...TEAM_TOOL_SPECS,
59
61
  ];
60
62