Compare commits

...

15 Commits

Author SHA1 Message Date
Chris Farhood c55d6c61fc feat: declare hasOutOfProcessLiveness and remove onSpawn workarounds (FAR-24)
- Add `hasOutOfProcessLiveness: true` to createServerAdapter() so the
  reaper skips local PID checks and uses the staleness window instead.
- Remove the initial onSpawn call and all periodic keepalive onSpawn
  refreshes that were compensating for the missing flag.
- Remove POST_TERMINAL_KEEPALIVE_MS constant and keepaliveTick counter
  that backed those workarounds.
- Cast required: adapter-utils ServerAdapterModule type predates this field.
- Bump to 0.1.38.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 14:14:10 +00:00
Chris Farhood 32d6308eae 0.1.37 2026-04-24 13:11:13 +00:00
Chris Farhood b97117e10d test: mock readPaperclipRuntimeSkillEntries to eliminate real fs I/O under fake timers
Previously the test suite relied on real fs.stat completing within the fake
timer advance window (~11200ms).  Under CI with 11 parallel test files the I/O
could drain later than the advances allowed, causing a 1-in-4 timeout on the
"logs pod pending" test.

Fix: mock @paperclipai/adapter-utils/server-utils using vi.hoisted() + Object.assign
so readPaperclipRuntimeSkillEntries resolves immediately as a microtask.  All other
exports are forwarded to the real module via importOriginal.  Each beforeEach that
calls vi.resetAllMocks() or vi.clearAllMocks() now also calls
mockReadSkillEntries.mockResolvedValue([]) to restore the implementation.

Timer advances in affected tests are simplified to reflect the purely fake-timer
sequence (no I/O drain prefix).  All 323 tests pass deterministically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 13:11:04 +00:00
Chris Farhood abdce817f3 0.1.36
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 12:36:21 +00:00
Chris Farhood f9d8a2e0ce fix: resolve grace-period deadlock for stale UI status (FAR-23)
The log-stream-exit grace timer never fired because logExitTime was set
in the .then() of streamPodLogs, which only resolves once stopSignal is
set — but stopSignal is only set when completionWithGrace fires, which
requires logExitTime to be non-null. Classic deadlock.

Fix: add onFirstStreamExit callback to streamPodLogs, called after
attempt=0's streamPodLogsOnce returns (the first container exit signal).
execute() passes a closure that sets logExitTime immediately, breaking
the circular dependency and allowing the 30s grace timer to fire
correctly when K8s Job conditions lag container exit.

Tests: all 323 pass including the two FAR-23 grace-period regression tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 12:20:10 +00:00
Chris Farhood a7dfd5d502 test: fix flaky execute.ts timer tests and hit 80%+ line coverage
readPaperclipRuntimeSkillEntries does real fs.stat I/O under fake timers,
delaying execute()'s fake-timer registration by ~3200-4200ms of fake time
when tests run in isolation (cold OS page cache).  The previous approach
tried vi.spyOn on an ESM module namespace export, which throws
"Cannot redefine property" — a fundamental ESM constraint.

Fix: remove the broken spy.  Instead, each timer-heavy test now uses enough
advanceTimersByTimeAsync calls to (a) give the event loop sufficient turns
for the I/O to drain, and (b) cover the full fake-timer sequence even with
the maximum observed I/O delay.  Patterns chosen:

  reconnects (needs t+6000):       6 advances, ~12200ms total
  deadline exceeded (needs t+3000): 5 advances, ~8400ms total
  pod-creation wait (needs t+5000): 5 advances, ~9400ms total

execute.ts line coverage: 82.57% (was ~24% before this task's test additions).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 04:10:49 +00:00
Chris Farhood e310ba4156 0.1.35 2026-04-24 00:44:59 +00:00
Chris Farhood ae7adb0847 docs: add enableRtk, rtkMaxOutputBytes, reattachOrphanedJobs to config doc (N6)
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 00:01:57 +00:00
Chris Farhood d24510172e fix: remove misleading dangerouslySkipPermissions UI toggle (N5)
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 00:01:38 +00:00
Chris Farhood 29a4e709d0 fix: sanitize agent/run/company labels to RFC 1123 (N4)
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 00:00:56 +00:00
Chris Farhood 8a08e6a6ee fix: relabel reattached Job with current run-id and session-id (N3)
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 23:59:05 +00:00
Chris Farhood c0dba8e904 fix: never auto-delete live K8s orphans; block on mismatch (#8)
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 23:58:51 +00:00
Chris Farhood b91859c258 refactor: extract classifyOrphan helper with decision matrix (#8)
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 23:58:23 +00:00
Chris Farhood f1433b05a6 fix: reserve paperclip.io/ and app.kubernetes.io/ label prefixes (N2)
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 23:54:15 +00:00
Chris Farhood f64694f894 fix: validate companyId/instanceId against path traversal (N1)
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 23:53:18 +00:00
20 changed files with 4212 additions and 200 deletions
@@ -0,0 +1,502 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for src/cli/format-event.ts</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="../../prettify.css" />
<link rel="stylesheet" href="../../base.css" />
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(../../sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/cli</a> format-event.ts</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">79.78% </span>
<span class="quiet">Statements</span>
<span class='fraction'>75/94</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">64.13% </span>
<span class="quiet">Branches</span>
<span class='fraction'>93/145</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Functions</span>
<span class='fraction'>3/3</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">84.52% </span>
<span class="quiet">Lines</span>
<span class='fraction'>71/84</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line medium'></div>
<pre><table class="coverage">
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
<a name='L2'></a><a href='#L2'>2</a>
<a name='L3'></a><a href='#L3'>3</a>
<a name='L4'></a><a href='#L4'>4</a>
<a name='L5'></a><a href='#L5'>5</a>
<a name='L6'></a><a href='#L6'>6</a>
<a name='L7'></a><a href='#L7'>7</a>
<a name='L8'></a><a href='#L8'>8</a>
<a name='L9'></a><a href='#L9'>9</a>
<a name='L10'></a><a href='#L10'>10</a>
<a name='L11'></a><a href='#L11'>11</a>
<a name='L12'></a><a href='#L12'>12</a>
<a name='L13'></a><a href='#L13'>13</a>
<a name='L14'></a><a href='#L14'>14</a>
<a name='L15'></a><a href='#L15'>15</a>
<a name='L16'></a><a href='#L16'>16</a>
<a name='L17'></a><a href='#L17'>17</a>
<a name='L18'></a><a href='#L18'>18</a>
<a name='L19'></a><a href='#L19'>19</a>
<a name='L20'></a><a href='#L20'>20</a>
<a name='L21'></a><a href='#L21'>21</a>
<a name='L22'></a><a href='#L22'>22</a>
<a name='L23'></a><a href='#L23'>23</a>
<a name='L24'></a><a href='#L24'>24</a>
<a name='L25'></a><a href='#L25'>25</a>
<a name='L26'></a><a href='#L26'>26</a>
<a name='L27'></a><a href='#L27'>27</a>
<a name='L28'></a><a href='#L28'>28</a>
<a name='L29'></a><a href='#L29'>29</a>
<a name='L30'></a><a href='#L30'>30</a>
<a name='L31'></a><a href='#L31'>31</a>
<a name='L32'></a><a href='#L32'>32</a>
<a name='L33'></a><a href='#L33'>33</a>
<a name='L34'></a><a href='#L34'>34</a>
<a name='L35'></a><a href='#L35'>35</a>
<a name='L36'></a><a href='#L36'>36</a>
<a name='L37'></a><a href='#L37'>37</a>
<a name='L38'></a><a href='#L38'>38</a>
<a name='L39'></a><a href='#L39'>39</a>
<a name='L40'></a><a href='#L40'>40</a>
<a name='L41'></a><a href='#L41'>41</a>
<a name='L42'></a><a href='#L42'>42</a>
<a name='L43'></a><a href='#L43'>43</a>
<a name='L44'></a><a href='#L44'>44</a>
<a name='L45'></a><a href='#L45'>45</a>
<a name='L46'></a><a href='#L46'>46</a>
<a name='L47'></a><a href='#L47'>47</a>
<a name='L48'></a><a href='#L48'>48</a>
<a name='L49'></a><a href='#L49'>49</a>
<a name='L50'></a><a href='#L50'>50</a>
<a name='L51'></a><a href='#L51'>51</a>
<a name='L52'></a><a href='#L52'>52</a>
<a name='L53'></a><a href='#L53'>53</a>
<a name='L54'></a><a href='#L54'>54</a>
<a name='L55'></a><a href='#L55'>55</a>
<a name='L56'></a><a href='#L56'>56</a>
<a name='L57'></a><a href='#L57'>57</a>
<a name='L58'></a><a href='#L58'>58</a>
<a name='L59'></a><a href='#L59'>59</a>
<a name='L60'></a><a href='#L60'>60</a>
<a name='L61'></a><a href='#L61'>61</a>
<a name='L62'></a><a href='#L62'>62</a>
<a name='L63'></a><a href='#L63'>63</a>
<a name='L64'></a><a href='#L64'>64</a>
<a name='L65'></a><a href='#L65'>65</a>
<a name='L66'></a><a href='#L66'>66</a>
<a name='L67'></a><a href='#L67'>67</a>
<a name='L68'></a><a href='#L68'>68</a>
<a name='L69'></a><a href='#L69'>69</a>
<a name='L70'></a><a href='#L70'>70</a>
<a name='L71'></a><a href='#L71'>71</a>
<a name='L72'></a><a href='#L72'>72</a>
<a name='L73'></a><a href='#L73'>73</a>
<a name='L74'></a><a href='#L74'>74</a>
<a name='L75'></a><a href='#L75'>75</a>
<a name='L76'></a><a href='#L76'>76</a>
<a name='L77'></a><a href='#L77'>77</a>
<a name='L78'></a><a href='#L78'>78</a>
<a name='L79'></a><a href='#L79'>79</a>
<a name='L80'></a><a href='#L80'>80</a>
<a name='L81'></a><a href='#L81'>81</a>
<a name='L82'></a><a href='#L82'>82</a>
<a name='L83'></a><a href='#L83'>83</a>
<a name='L84'></a><a href='#L84'>84</a>
<a name='L85'></a><a href='#L85'>85</a>
<a name='L86'></a><a href='#L86'>86</a>
<a name='L87'></a><a href='#L87'>87</a>
<a name='L88'></a><a href='#L88'>88</a>
<a name='L89'></a><a href='#L89'>89</a>
<a name='L90'></a><a href='#L90'>90</a>
<a name='L91'></a><a href='#L91'>91</a>
<a name='L92'></a><a href='#L92'>92</a>
<a name='L93'></a><a href='#L93'>93</a>
<a name='L94'></a><a href='#L94'>94</a>
<a name='L95'></a><a href='#L95'>95</a>
<a name='L96'></a><a href='#L96'>96</a>
<a name='L97'></a><a href='#L97'>97</a>
<a name='L98'></a><a href='#L98'>98</a>
<a name='L99'></a><a href='#L99'>99</a>
<a name='L100'></a><a href='#L100'>100</a>
<a name='L101'></a><a href='#L101'>101</a>
<a name='L102'></a><a href='#L102'>102</a>
<a name='L103'></a><a href='#L103'>103</a>
<a name='L104'></a><a href='#L104'>104</a>
<a name='L105'></a><a href='#L105'>105</a>
<a name='L106'></a><a href='#L106'>106</a>
<a name='L107'></a><a href='#L107'>107</a>
<a name='L108'></a><a href='#L108'>108</a>
<a name='L109'></a><a href='#L109'>109</a>
<a name='L110'></a><a href='#L110'>110</a>
<a name='L111'></a><a href='#L111'>111</a>
<a name='L112'></a><a href='#L112'>112</a>
<a name='L113'></a><a href='#L113'>113</a>
<a name='L114'></a><a href='#L114'>114</a>
<a name='L115'></a><a href='#L115'>115</a>
<a name='L116'></a><a href='#L116'>116</a>
<a name='L117'></a><a href='#L117'>117</a>
<a name='L118'></a><a href='#L118'>118</a>
<a name='L119'></a><a href='#L119'>119</a>
<a name='L120'></a><a href='#L120'>120</a>
<a name='L121'></a><a href='#L121'>121</a>
<a name='L122'></a><a href='#L122'>122</a>
<a name='L123'></a><a href='#L123'>123</a>
<a name='L124'></a><a href='#L124'>124</a>
<a name='L125'></a><a href='#L125'>125</a>
<a name='L126'></a><a href='#L126'>126</a>
<a name='L127'></a><a href='#L127'>127</a>
<a name='L128'></a><a href='#L128'>128</a>
<a name='L129'></a><a href='#L129'>129</a>
<a name='L130'></a><a href='#L130'>130</a>
<a name='L131'></a><a href='#L131'>131</a>
<a name='L132'></a><a href='#L132'>132</a>
<a name='L133'></a><a href='#L133'>133</a>
<a name='L134'></a><a href='#L134'>134</a>
<a name='L135'></a><a href='#L135'>135</a>
<a name='L136'></a><a href='#L136'>136</a>
<a name='L137'></a><a href='#L137'>137</a>
<a name='L138'></a><a href='#L138'>138</a>
<a name='L139'></a><a href='#L139'>139</a>
<a name='L140'></a><a href='#L140'>140</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">13x</span>
<span class="cline-any cline-yes">13x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">12x</span>
<span class="cline-any cline-yes">12x</span>
<span class="cline-any cline-yes">12x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">10x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">13x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">6x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">import pc from "picocolors";
&nbsp;
function asErrorText(value: unknown): string {
<span class="missing-if-branch" title="else path not taken" >E</span>if (typeof value === "string") return value;
<span class="cstat-no" title="statement not covered" > if (typeof value !== "object" || value === null || Array.isArray(value)) <span class="cstat-no" title="statement not covered" >return "";</span></span>
const obj = <span class="cstat-no" title="statement not covered" >value as Record&lt;string, unknown&gt;;</span>
const message =
(<span class="cstat-no" title="statement not covered" >typeof obj.message === "string" &amp;&amp; obj.message) ||</span>
(typeof obj.error === "string" &amp;&amp; obj.error) ||
(typeof obj.code === "string" &amp;&amp; obj.code) ||
"";
<span class="missing-if-branch" title="if path not taken" >I</span>if (message) <span class="cstat-no" title="statement not covered" >return message;</span>
<span class="cstat-no" title="statement not covered" > try {</span>
<span class="cstat-no" title="statement not covered" > return JSON.stringify(obj);</span>
} catch {
<span class="cstat-no" title="statement not covered" > return "";</span>
}
}
&nbsp;
function printToolResult(block: Record&lt;string, unknown&gt;): void {
const isError = block.is_error === true;
let text = "";
if (typeof block.content === "string") {
text = block.content;
} else if (<span class="cstat-no" title="statement not covered" ><span class="missing-if-branch" title="else path not taken" >E</span>Array.isArray(block.content)) {</span>
const parts: string[] = <span class="cstat-no" title="statement not covered" >[];</span>
<span class="cstat-no" title="statement not covered" > for (const part of block.content) {</span>
<span class="cstat-no" title="statement not covered" > if (typeof part !== "object" || part === null || Array.isArray(part)) <span class="cstat-no" title="statement not covered" >continue;</span></span>
const record = <span class="cstat-no" title="statement not covered" >part as Record&lt;string, unknown&gt;;</span>
<span class="cstat-no" title="statement not covered" > if (typeof record.text === "string") <span class="cstat-no" title="statement not covered" >parts.push(record.text);</span></span>
}
<span class="cstat-no" title="statement not covered" > text = parts.join("\n");</span>
}
&nbsp;
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
<span class="missing-if-branch" title="else path not taken" >E</span>if (text) {
console.log((isError ? pc.red : pc.gray)(text));
}
}
&nbsp;
export function printClaudeStreamEvent(raw: string, debug: boolean): void {
const line = raw.trim();
if (!line) return;
&nbsp;
let parsed: Record&lt;string, unknown&gt; | null = null;
try {
parsed = JSON.parse(line) as Record&lt;string, unknown&gt;;
} catch {
console.log(line);
return;
}
&nbsp;
const type = typeof parsed.type === "string" ? parsed.type : <span class="branch-1 cbranch-no" title="branch not covered" >"";</span>
&nbsp;
if (type === "system" &amp;&amp; parsed.subtype === "init") {
const model = typeof parsed.model === "string" ? parsed.model : <span class="branch-1 cbranch-no" title="branch not covered" >"unknown";</span>
const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : <span class="branch-1 cbranch-no" title="branch not covered" >"";</span>
console.log(pc.blue(`Claude initialized (model: ${model}${sessionId ? `, session: ${sessionId}` : <span class="branch-1 cbranch-no" title="branch not covered" >""})</span>`));
return;
}
&nbsp;
if (type === "assistant") {
const message =
typeof parsed.message === "object" &amp;&amp; parsed.message !== null &amp;&amp; !Array.isArray(parsed.message)
? (parsed.message as Record&lt;string, unknown&gt;)
: <span class="branch-1 cbranch-no" title="branch not covered" >{};</span>
const content = Array.isArray(message.content) ? message.content : <span class="branch-1 cbranch-no" title="branch not covered" >[];</span>
for (const blockRaw of content) {
<span class="missing-if-branch" title="if path not taken" >I</span>if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) <span class="cstat-no" title="statement not covered" >continue;</span>
const block = blockRaw as Record&lt;string, unknown&gt;;
const blockType = typeof block.type === "string" ? block.type : <span class="branch-1 cbranch-no" title="branch not covered" >"";</span>
if (blockType === "text") {
const text = typeof block.text === "string" ? block.text : <span class="branch-1 cbranch-no" title="branch not covered" >"";</span>
<span class="missing-if-branch" title="else path not taken" >E</span>if (text) console.log(pc.green(`assistant: ${text}`));
} else if (blockType === "thinking") {
const text = typeof block.thinking === "string" ? block.thinking : <span class="branch-1 cbranch-no" title="branch not covered" >"";</span>
<span class="missing-if-branch" title="else path not taken" >E</span>if (text) console.log(pc.gray(`thinking: ${text}`));
} else if (<span class="missing-if-branch" title="else path not taken" >E</span>blockType === "tool_use") {
const name = typeof block.name === "string" ? block.name : <span class="branch-1 cbranch-no" title="branch not covered" >"unknown";</span>
console.log(pc.yellow(`tool_call: ${name}`));
<span class="missing-if-branch" title="else path not taken" >E</span>if (block.input !== undefined) {
console.log(pc.gray(JSON.stringify(block.input, null, 2)));
}
}
}
return;
}
&nbsp;
if (type === "user") {
const message =
typeof parsed.message === "object" &amp;&amp; parsed.message !== null &amp;&amp; !Array.isArray(parsed.message)
? (parsed.message as Record&lt;string, unknown&gt;)
: <span class="branch-1 cbranch-no" title="branch not covered" >{};</span>
const content = Array.isArray(message.content) ? message.content : <span class="branch-1 cbranch-no" title="branch not covered" >[];</span>
for (const blockRaw of content) {
<span class="missing-if-branch" title="if path not taken" >I</span>if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) <span class="cstat-no" title="statement not covered" >continue;</span>
const block = blockRaw as Record&lt;string, unknown&gt;;
<span class="missing-if-branch" title="else path not taken" >E</span>if (typeof block.type === "string" &amp;&amp; block.type === "tool_result") {
printToolResult(block);
}
}
return;
}
&nbsp;
if (type === "result") {
const usage =
typeof parsed.usage === "object" &amp;&amp; parsed.usage !== null &amp;&amp; !Array.isArray(parsed.usage)
? (parsed.usage as Record&lt;string, unknown&gt;)
: {};
const input = Number(usage.input_tokens ?? 0);
const output = Number(usage.output_tokens ?? 0);
const cached = Number(usage.cache_read_input_tokens ?? 0);
const cost = Number(parsed.total_cost_usd ?? 0);
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : <span class="branch-1 cbranch-no" title="branch not covered" >"";</span>
const isError = parsed.is_error === true;
const resultText = typeof parsed.result === "string" ? parsed.result : "";
if (resultText) {
console.log(pc.green("result:"));
console.log(resultText);
}
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(asErrorText).filter(Boolean) : [];
if (subtype.startsWith("error") || isError || errors.length &gt; 0) {
console.log(pc.red(`claude_result: subtype=${subtype || <span class="branch-1 cbranch-no" title="branch not covered" >"unknown"} </span>is_error=${isError ? "true" : <span class="branch-1 cbranch-no" title="branch not covered" >"false"}`))</span>;
<span class="missing-if-branch" title="else path not taken" >E</span>if (errors.length &gt; 0) {
console.log(pc.red(`claude_errors: ${errors.join(" | ")}`));
}
}
console.log(
pc.blue(
`tokens: in=${Number.isFinite(input) ? input : <span class="branch-1 cbranch-no" title="branch not covered" >0} </span>out=${Number.isFinite(output) ? output : <span class="branch-1 cbranch-no" title="branch not covered" >0} </span>cached=${Number.isFinite(cached) ? cached : <span class="branch-1 cbranch-no" title="branch not covered" >0} </span>cost=$${Number.isFinite(cost) ? cost.toFixed(6) : <span class="branch-1 cbranch-no" title="branch not covered" >"0.000000"}`,</span>
),
);
return;
}
&nbsp;
if (debug) {
console.log(pc.gray(line));
}
}
&nbsp;</pre></td></tr></table></pre>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-04-24T04:09:41.748Z
</div>
<script src="../../prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="../../sorter.js"></script>
<script src="../../block-navigation.js"></script>
</body>
</html>
+131
View File
@@ -0,0 +1,131 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for src/cli</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="../../prettify.css" />
<link rel="stylesheet" href="../../base.css" />
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(../../sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1><a href="../../index.html">All files</a> src/cli</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">79.78% </span>
<span class="quiet">Statements</span>
<span class='fraction'>75/94</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">64.13% </span>
<span class="quiet">Branches</span>
<span class='fraction'>93/145</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Functions</span>
<span class='fraction'>3/3</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">84.52% </span>
<span class="quiet">Lines</span>
<span class='fraction'>71/84</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line medium'></div>
<div class="pad1">
<table class="coverage-summary">
<thead>
<tr>
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
</tr>
</thead>
<tbody><tr>
<td class="file medium" data-value="format-event.ts"><a href="format-event.ts.html">format-event.ts</a></td>
<td data-value="79.78" class="pic medium">
<div class="chart"><div class="cover-fill" style="width: 79%"></div><div class="cover-empty" style="width: 21%"></div></div>
</td>
<td data-value="79.78" class="pct medium">79.78%</td>
<td data-value="94" class="abs medium">75/94</td>
<td data-value="64.13" class="pct medium">64.13%</td>
<td data-value="145" class="abs medium">93/145</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="3" class="abs high">3/3</td>
<td data-value="84.52" class="pct high">84.52%</td>
<td data-value="84" class="abs high">71/84</td>
</tr>
<tr>
<td class="file empty" data-value="index.ts"><a href="index.ts.html">index.ts</a></td>
<td data-value="0" class="pic empty">
<div class="chart"><div class="cover-fill" style="width: 0%"></div><div class="cover-empty" style="width: 100%"></div></div>
</td>
<td data-value="0" class="pct empty">0%</td>
<td data-value="0" class="abs empty">0/0</td>
<td data-value="0" class="pct empty">0%</td>
<td data-value="0" class="abs empty">0/0</td>
<td data-value="0" class="pct empty">0%</td>
<td data-value="0" class="abs empty">0/0</td>
<td data-value="0" class="pct empty">0%</td>
<td data-value="0" class="abs empty">0/0</td>
</tr>
</tbody>
</table>
</div>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-04-24T04:09:41.748Z
</div>
<script src="../../prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="../../sorter.js"></script>
<script src="../../block-navigation.js"></script>
</body>
</html>
@@ -0,0 +1,88 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for src/cli/index.ts</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="../../prettify.css" />
<link rel="stylesheet" href="../../base.css" />
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(../../sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/cli</a> index.ts</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">0% </span>
<span class="quiet">Statements</span>
<span class='fraction'>0/0</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">0% </span>
<span class="quiet">Branches</span>
<span class='fraction'>0/0</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">0% </span>
<span class="quiet">Functions</span>
<span class='fraction'>0/0</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">0% </span>
<span class="quiet">Lines</span>
<span class='fraction'>0/0</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line low'></div>
<pre><table class="coverage">
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
<a name='L2'></a><a href='#L2'>2</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">export { printClaudeStreamEvent } from "./format-event.js";
&nbsp;</pre></td></tr></table></pre>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-04-24T04:09:41.748Z
</div>
<script src="../../prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="../../sorter.js"></script>
<script src="../../block-navigation.js"></script>
</body>
</html>
@@ -0,0 +1,547 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for src/server/config-schema.ts</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="../../prettify.css" />
<link rel="stylesheet" href="../../base.css" />
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(../../sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/server</a> config-schema.ts</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Statements</span>
<span class='fraction'>2/2</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Branches</span>
<span class='fraction'>0/0</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Functions</span>
<span class='fraction'>1/1</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Lines</span>
<span class='fraction'>2/2</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line high'></div>
<pre><table class="coverage">
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
<a name='L2'></a><a href='#L2'>2</a>
<a name='L3'></a><a href='#L3'>3</a>
<a name='L4'></a><a href='#L4'>4</a>
<a name='L5'></a><a href='#L5'>5</a>
<a name='L6'></a><a href='#L6'>6</a>
<a name='L7'></a><a href='#L7'>7</a>
<a name='L8'></a><a href='#L8'>8</a>
<a name='L9'></a><a href='#L9'>9</a>
<a name='L10'></a><a href='#L10'>10</a>
<a name='L11'></a><a href='#L11'>11</a>
<a name='L12'></a><a href='#L12'>12</a>
<a name='L13'></a><a href='#L13'>13</a>
<a name='L14'></a><a href='#L14'>14</a>
<a name='L15'></a><a href='#L15'>15</a>
<a name='L16'></a><a href='#L16'>16</a>
<a name='L17'></a><a href='#L17'>17</a>
<a name='L18'></a><a href='#L18'>18</a>
<a name='L19'></a><a href='#L19'>19</a>
<a name='L20'></a><a href='#L20'>20</a>
<a name='L21'></a><a href='#L21'>21</a>
<a name='L22'></a><a href='#L22'>22</a>
<a name='L23'></a><a href='#L23'>23</a>
<a name='L24'></a><a href='#L24'>24</a>
<a name='L25'></a><a href='#L25'>25</a>
<a name='L26'></a><a href='#L26'>26</a>
<a name='L27'></a><a href='#L27'>27</a>
<a name='L28'></a><a href='#L28'>28</a>
<a name='L29'></a><a href='#L29'>29</a>
<a name='L30'></a><a href='#L30'>30</a>
<a name='L31'></a><a href='#L31'>31</a>
<a name='L32'></a><a href='#L32'>32</a>
<a name='L33'></a><a href='#L33'>33</a>
<a name='L34'></a><a href='#L34'>34</a>
<a name='L35'></a><a href='#L35'>35</a>
<a name='L36'></a><a href='#L36'>36</a>
<a name='L37'></a><a href='#L37'>37</a>
<a name='L38'></a><a href='#L38'>38</a>
<a name='L39'></a><a href='#L39'>39</a>
<a name='L40'></a><a href='#L40'>40</a>
<a name='L41'></a><a href='#L41'>41</a>
<a name='L42'></a><a href='#L42'>42</a>
<a name='L43'></a><a href='#L43'>43</a>
<a name='L44'></a><a href='#L44'>44</a>
<a name='L45'></a><a href='#L45'>45</a>
<a name='L46'></a><a href='#L46'>46</a>
<a name='L47'></a><a href='#L47'>47</a>
<a name='L48'></a><a href='#L48'>48</a>
<a name='L49'></a><a href='#L49'>49</a>
<a name='L50'></a><a href='#L50'>50</a>
<a name='L51'></a><a href='#L51'>51</a>
<a name='L52'></a><a href='#L52'>52</a>
<a name='L53'></a><a href='#L53'>53</a>
<a name='L54'></a><a href='#L54'>54</a>
<a name='L55'></a><a href='#L55'>55</a>
<a name='L56'></a><a href='#L56'>56</a>
<a name='L57'></a><a href='#L57'>57</a>
<a name='L58'></a><a href='#L58'>58</a>
<a name='L59'></a><a href='#L59'>59</a>
<a name='L60'></a><a href='#L60'>60</a>
<a name='L61'></a><a href='#L61'>61</a>
<a name='L62'></a><a href='#L62'>62</a>
<a name='L63'></a><a href='#L63'>63</a>
<a name='L64'></a><a href='#L64'>64</a>
<a name='L65'></a><a href='#L65'>65</a>
<a name='L66'></a><a href='#L66'>66</a>
<a name='L67'></a><a href='#L67'>67</a>
<a name='L68'></a><a href='#L68'>68</a>
<a name='L69'></a><a href='#L69'>69</a>
<a name='L70'></a><a href='#L70'>70</a>
<a name='L71'></a><a href='#L71'>71</a>
<a name='L72'></a><a href='#L72'>72</a>
<a name='L73'></a><a href='#L73'>73</a>
<a name='L74'></a><a href='#L74'>74</a>
<a name='L75'></a><a href='#L75'>75</a>
<a name='L76'></a><a href='#L76'>76</a>
<a name='L77'></a><a href='#L77'>77</a>
<a name='L78'></a><a href='#L78'>78</a>
<a name='L79'></a><a href='#L79'>79</a>
<a name='L80'></a><a href='#L80'>80</a>
<a name='L81'></a><a href='#L81'>81</a>
<a name='L82'></a><a href='#L82'>82</a>
<a name='L83'></a><a href='#L83'>83</a>
<a name='L84'></a><a href='#L84'>84</a>
<a name='L85'></a><a href='#L85'>85</a>
<a name='L86'></a><a href='#L86'>86</a>
<a name='L87'></a><a href='#L87'>87</a>
<a name='L88'></a><a href='#L88'>88</a>
<a name='L89'></a><a href='#L89'>89</a>
<a name='L90'></a><a href='#L90'>90</a>
<a name='L91'></a><a href='#L91'>91</a>
<a name='L92'></a><a href='#L92'>92</a>
<a name='L93'></a><a href='#L93'>93</a>
<a name='L94'></a><a href='#L94'>94</a>
<a name='L95'></a><a href='#L95'>95</a>
<a name='L96'></a><a href='#L96'>96</a>
<a name='L97'></a><a href='#L97'>97</a>
<a name='L98'></a><a href='#L98'>98</a>
<a name='L99'></a><a href='#L99'>99</a>
<a name='L100'></a><a href='#L100'>100</a>
<a name='L101'></a><a href='#L101'>101</a>
<a name='L102'></a><a href='#L102'>102</a>
<a name='L103'></a><a href='#L103'>103</a>
<a name='L104'></a><a href='#L104'>104</a>
<a name='L105'></a><a href='#L105'>105</a>
<a name='L106'></a><a href='#L106'>106</a>
<a name='L107'></a><a href='#L107'>107</a>
<a name='L108'></a><a href='#L108'>108</a>
<a name='L109'></a><a href='#L109'>109</a>
<a name='L110'></a><a href='#L110'>110</a>
<a name='L111'></a><a href='#L111'>111</a>
<a name='L112'></a><a href='#L112'>112</a>
<a name='L113'></a><a href='#L113'>113</a>
<a name='L114'></a><a href='#L114'>114</a>
<a name='L115'></a><a href='#L115'>115</a>
<a name='L116'></a><a href='#L116'>116</a>
<a name='L117'></a><a href='#L117'>117</a>
<a name='L118'></a><a href='#L118'>118</a>
<a name='L119'></a><a href='#L119'>119</a>
<a name='L120'></a><a href='#L120'>120</a>
<a name='L121'></a><a href='#L121'>121</a>
<a name='L122'></a><a href='#L122'>122</a>
<a name='L123'></a><a href='#L123'>123</a>
<a name='L124'></a><a href='#L124'>124</a>
<a name='L125'></a><a href='#L125'>125</a>
<a name='L126'></a><a href='#L126'>126</a>
<a name='L127'></a><a href='#L127'>127</a>
<a name='L128'></a><a href='#L128'>128</a>
<a name='L129'></a><a href='#L129'>129</a>
<a name='L130'></a><a href='#L130'>130</a>
<a name='L131'></a><a href='#L131'>131</a>
<a name='L132'></a><a href='#L132'>132</a>
<a name='L133'></a><a href='#L133'>133</a>
<a name='L134'></a><a href='#L134'>134</a>
<a name='L135'></a><a href='#L135'>135</a>
<a name='L136'></a><a href='#L136'>136</a>
<a name='L137'></a><a href='#L137'>137</a>
<a name='L138'></a><a href='#L138'>138</a>
<a name='L139'></a><a href='#L139'>139</a>
<a name='L140'></a><a href='#L140'>140</a>
<a name='L141'></a><a href='#L141'>141</a>
<a name='L142'></a><a href='#L142'>142</a>
<a name='L143'></a><a href='#L143'>143</a>
<a name='L144'></a><a href='#L144'>144</a>
<a name='L145'></a><a href='#L145'>145</a>
<a name='L146'></a><a href='#L146'>146</a>
<a name='L147'></a><a href='#L147'>147</a>
<a name='L148'></a><a href='#L148'>148</a>
<a name='L149'></a><a href='#L149'>149</a>
<a name='L150'></a><a href='#L150'>150</a>
<a name='L151'></a><a href='#L151'>151</a>
<a name='L152'></a><a href='#L152'>152</a>
<a name='L153'></a><a href='#L153'>153</a>
<a name='L154'></a><a href='#L154'>154</a>
<a name='L155'></a><a href='#L155'>155</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">6x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">6x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">// NOTE: These types must match what Paperclip's SchemaConfigFields component
// expects. Paperclip's server at GET /api/adapters/:type/config-schema
// calls adapter.getConfigSchema() and the UI reads the JSON — types are only
// used at build time here. The Paperclip types in @paperclipai/adapter-utils
// may lag behind; these locals are the source of truth for this adapter.
&nbsp;
interface ConfigFieldOption {
label: string;
value: string;
group?: string;
}
&nbsp;
type ConfigFieldSchema =
| { type: "text"; key: string; label: string; hint?: string; default?: unknown; meta?: Record&lt;string, unknown&gt; }
| { type: "number"; key: string; label: string; hint?: string; default?: unknown; meta?: Record&lt;string, unknown&gt; }
| { type: "toggle"; key: string; label: string; hint?: string; default?: unknown; meta?: Record&lt;string, unknown&gt; }
| { type: "select"; key: string; label: string; hint?: string; options: ConfigFieldOption[]; default?: unknown; meta?: Record&lt;string, unknown&gt; }
| { type: "textarea"; key: string; label: string; hint?: string; default?: unknown; meta?: Record&lt;string, unknown&gt; }
| { type: "combobox"; key: string; label: string; hint?: string; options?: ConfigFieldOption[]; default?: unknown; meta?: Record&lt;string, unknown&gt; };
&nbsp;
interface AdapterConfigSchema {
fields: ConfigFieldSchema[];
}
&nbsp;
export function getConfigSchema(): AdapterConfigSchema {
// model, effort, instructionsFilePath, timeoutSec, graceSec are provided
// by the platform UI and must not be duplicated here.
const fields: ConfigFieldSchema[] = [
// Core Claude fields
{
type: "number",
key: "maxTurnsPerRun",
label: "Max Turns Per Run",
hint: "Maximum number of agentic turns (tool calls) per heartbeat run. 0 means unlimited.",
default: 1000,
},
// Kubernetes
{
type: "text",
key: "serviceAccountName",
label: "Service Account",
hint: "Service Account name for Job pods. Defaults to the cluster default.",
},
{
type: "text",
key: "namespace",
label: "Namespace",
hint: "Kubernetes namespace for Jobs. Defaults to the Deployment namespace.",
},
{
type: "text",
key: "image",
label: "Container Image",
hint: "Override the container image used for Job pods. Defaults to the running Deployment image.",
},
{
type: "select",
key: "imagePullPolicy",
label: "Image Pull Policy",
hint: "Image pull policy for the container image.",
options: [
{ value: "IfNotPresent", label: "IfNotPresent" },
{ value: "Always", label: "Always" },
{ value: "Never", label: "Never" },
],
},
{
type: "text",
key: "kubeconfig",
label: "Kubeconfig Path",
hint: "Absolute path to a kubeconfig file on disk. Defaults to in-cluster service account auth.",
},
{
type: "number",
key: "ttlSecondsAfterFinished",
label: "TTL Seconds After Finished",
hint: "Auto-cleanup delay for completed Jobs in seconds. Default: 300.",
},
{
type: "toggle",
key: "retainJobs",
label: "Retain Jobs",
hint: "Skip cleanup of completed Jobs for debugging purposes.",
},
{
type: "toggle",
key: "reattachOrphanedJobs",
label: "Reattach to Orphaned Jobs",
hint: "If a prior K8s Job for the same agent/task/session is still running (e.g. Paperclip restarted mid-run), attach to it and stream its output instead of blocking the new run. When false, any non-terminal orphan blocks the new run. Default: on.",
default: true,
},
// Resource Limits
{
type: "text",
key: "resources.requests.cpu",
label: "CPU Request",
hint: "CPU request for Job pods (e.g. 100m, 0.5, 1).",
},
{
type: "text",
key: "resources.requests.memory",
label: "Memory Request",
hint: "Memory request for Job pods (e.g. 128Mi, 512Mi, 1Gi).",
},
{
type: "text",
key: "resources.limits.cpu",
label: "CPU Limit",
hint: "CPU limit for Job pods (e.g. 100m, 0.5, 1).",
},
{
type: "text",
key: "resources.limits.memory",
label: "Memory Limit",
hint: "Memory limit for Job pods (e.g. 128Mi, 512Mi, 1Gi).",
},
// Scheduling
{
type: "textarea",
key: "nodeSelector",
label: "Node Selector",
hint: "Node selector for Job pods. One key=value per line (e.g. disktype=ssd).",
},
{
type: "textarea",
key: "tolerations",
label: "Tolerations",
hint: "Tolerations for Job pods as JSON array.",
},
{
type: "textarea",
key: "labels",
label: "Labels",
hint: "Extra labels added to Job metadata. One key=value per line.",
},
// Output filtering (RTK-compatible)
{
type: "toggle",
key: "enableRtk",
label: "Enable Output Filtering",
hint: "Truncate oversized tool outputs before they reach the model, reducing token consumption. Implemented natively in Node.js — no external binary required. Installs a PostToolUse hook in ~/.claude/settings.json for each run.",
default: false,
},
{
type: "number",
key: "rtkMaxOutputBytes",
label: "Max Tool Output Bytes",
hint: "Maximum bytes of tool output to pass to the model when output filtering is enabled. Outputs exceeding this threshold are truncated with a summary. Default: 50000.",
default: 50000,
},
];
&nbsp;
return { fields };
}
&nbsp;</pre></td></tr></table></pre>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-04-24T04:09:41.748Z
</div>
<script src="../../prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="../../sorter.js"></script>
<script src="../../block-navigation.js"></script>
</body>
</html>
@@ -0,0 +1,523 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for src/server/log-dedup.ts</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="../../prettify.css" />
<link rel="stylesheet" href="../../base.css" />
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(../../sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/server</a> log-dedup.ts</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">89.33% </span>
<span class="quiet">Statements</span>
<span class='fraction'>67/75</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">80.32% </span>
<span class="quiet">Branches</span>
<span class='fraction'>49/61</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Functions</span>
<span class='fraction'>6/6</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">95.08% </span>
<span class="quiet">Lines</span>
<span class='fraction'>58/61</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line high'></div>
<pre><table class="coverage">
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
<a name='L2'></a><a href='#L2'>2</a>
<a name='L3'></a><a href='#L3'>3</a>
<a name='L4'></a><a href='#L4'>4</a>
<a name='L5'></a><a href='#L5'>5</a>
<a name='L6'></a><a href='#L6'>6</a>
<a name='L7'></a><a href='#L7'>7</a>
<a name='L8'></a><a href='#L8'>8</a>
<a name='L9'></a><a href='#L9'>9</a>
<a name='L10'></a><a href='#L10'>10</a>
<a name='L11'></a><a href='#L11'>11</a>
<a name='L12'></a><a href='#L12'>12</a>
<a name='L13'></a><a href='#L13'>13</a>
<a name='L14'></a><a href='#L14'>14</a>
<a name='L15'></a><a href='#L15'>15</a>
<a name='L16'></a><a href='#L16'>16</a>
<a name='L17'></a><a href='#L17'>17</a>
<a name='L18'></a><a href='#L18'>18</a>
<a name='L19'></a><a href='#L19'>19</a>
<a name='L20'></a><a href='#L20'>20</a>
<a name='L21'></a><a href='#L21'>21</a>
<a name='L22'></a><a href='#L22'>22</a>
<a name='L23'></a><a href='#L23'>23</a>
<a name='L24'></a><a href='#L24'>24</a>
<a name='L25'></a><a href='#L25'>25</a>
<a name='L26'></a><a href='#L26'>26</a>
<a name='L27'></a><a href='#L27'>27</a>
<a name='L28'></a><a href='#L28'>28</a>
<a name='L29'></a><a href='#L29'>29</a>
<a name='L30'></a><a href='#L30'>30</a>
<a name='L31'></a><a href='#L31'>31</a>
<a name='L32'></a><a href='#L32'>32</a>
<a name='L33'></a><a href='#L33'>33</a>
<a name='L34'></a><a href='#L34'>34</a>
<a name='L35'></a><a href='#L35'>35</a>
<a name='L36'></a><a href='#L36'>36</a>
<a name='L37'></a><a href='#L37'>37</a>
<a name='L38'></a><a href='#L38'>38</a>
<a name='L39'></a><a href='#L39'>39</a>
<a name='L40'></a><a href='#L40'>40</a>
<a name='L41'></a><a href='#L41'>41</a>
<a name='L42'></a><a href='#L42'>42</a>
<a name='L43'></a><a href='#L43'>43</a>
<a name='L44'></a><a href='#L44'>44</a>
<a name='L45'></a><a href='#L45'>45</a>
<a name='L46'></a><a href='#L46'>46</a>
<a name='L47'></a><a href='#L47'>47</a>
<a name='L48'></a><a href='#L48'>48</a>
<a name='L49'></a><a href='#L49'>49</a>
<a name='L50'></a><a href='#L50'>50</a>
<a name='L51'></a><a href='#L51'>51</a>
<a name='L52'></a><a href='#L52'>52</a>
<a name='L53'></a><a href='#L53'>53</a>
<a name='L54'></a><a href='#L54'>54</a>
<a name='L55'></a><a href='#L55'>55</a>
<a name='L56'></a><a href='#L56'>56</a>
<a name='L57'></a><a href='#L57'>57</a>
<a name='L58'></a><a href='#L58'>58</a>
<a name='L59'></a><a href='#L59'>59</a>
<a name='L60'></a><a href='#L60'>60</a>
<a name='L61'></a><a href='#L61'>61</a>
<a name='L62'></a><a href='#L62'>62</a>
<a name='L63'></a><a href='#L63'>63</a>
<a name='L64'></a><a href='#L64'>64</a>
<a name='L65'></a><a href='#L65'>65</a>
<a name='L66'></a><a href='#L66'>66</a>
<a name='L67'></a><a href='#L67'>67</a>
<a name='L68'></a><a href='#L68'>68</a>
<a name='L69'></a><a href='#L69'>69</a>
<a name='L70'></a><a href='#L70'>70</a>
<a name='L71'></a><a href='#L71'>71</a>
<a name='L72'></a><a href='#L72'>72</a>
<a name='L73'></a><a href='#L73'>73</a>
<a name='L74'></a><a href='#L74'>74</a>
<a name='L75'></a><a href='#L75'>75</a>
<a name='L76'></a><a href='#L76'>76</a>
<a name='L77'></a><a href='#L77'>77</a>
<a name='L78'></a><a href='#L78'>78</a>
<a name='L79'></a><a href='#L79'>79</a>
<a name='L80'></a><a href='#L80'>80</a>
<a name='L81'></a><a href='#L81'>81</a>
<a name='L82'></a><a href='#L82'>82</a>
<a name='L83'></a><a href='#L83'>83</a>
<a name='L84'></a><a href='#L84'>84</a>
<a name='L85'></a><a href='#L85'>85</a>
<a name='L86'></a><a href='#L86'>86</a>
<a name='L87'></a><a href='#L87'>87</a>
<a name='L88'></a><a href='#L88'>88</a>
<a name='L89'></a><a href='#L89'>89</a>
<a name='L90'></a><a href='#L90'>90</a>
<a name='L91'></a><a href='#L91'>91</a>
<a name='L92'></a><a href='#L92'>92</a>
<a name='L93'></a><a href='#L93'>93</a>
<a name='L94'></a><a href='#L94'>94</a>
<a name='L95'></a><a href='#L95'>95</a>
<a name='L96'></a><a href='#L96'>96</a>
<a name='L97'></a><a href='#L97'>97</a>
<a name='L98'></a><a href='#L98'>98</a>
<a name='L99'></a><a href='#L99'>99</a>
<a name='L100'></a><a href='#L100'>100</a>
<a name='L101'></a><a href='#L101'>101</a>
<a name='L102'></a><a href='#L102'>102</a>
<a name='L103'></a><a href='#L103'>103</a>
<a name='L104'></a><a href='#L104'>104</a>
<a name='L105'></a><a href='#L105'>105</a>
<a name='L106'></a><a href='#L106'>106</a>
<a name='L107'></a><a href='#L107'>107</a>
<a name='L108'></a><a href='#L108'>108</a>
<a name='L109'></a><a href='#L109'>109</a>
<a name='L110'></a><a href='#L110'>110</a>
<a name='L111'></a><a href='#L111'>111</a>
<a name='L112'></a><a href='#L112'>112</a>
<a name='L113'></a><a href='#L113'>113</a>
<a name='L114'></a><a href='#L114'>114</a>
<a name='L115'></a><a href='#L115'>115</a>
<a name='L116'></a><a href='#L116'>116</a>
<a name='L117'></a><a href='#L117'>117</a>
<a name='L118'></a><a href='#L118'>118</a>
<a name='L119'></a><a href='#L119'>119</a>
<a name='L120'></a><a href='#L120'>120</a>
<a name='L121'></a><a href='#L121'>121</a>
<a name='L122'></a><a href='#L122'>122</a>
<a name='L123'></a><a href='#L123'>123</a>
<a name='L124'></a><a href='#L124'>124</a>
<a name='L125'></a><a href='#L125'>125</a>
<a name='L126'></a><a href='#L126'>126</a>
<a name='L127'></a><a href='#L127'>127</a>
<a name='L128'></a><a href='#L128'>128</a>
<a name='L129'></a><a href='#L129'>129</a>
<a name='L130'></a><a href='#L130'>130</a>
<a name='L131'></a><a href='#L131'>131</a>
<a name='L132'></a><a href='#L132'>132</a>
<a name='L133'></a><a href='#L133'>133</a>
<a name='L134'></a><a href='#L134'>134</a>
<a name='L135'></a><a href='#L135'>135</a>
<a name='L136'></a><a href='#L136'>136</a>
<a name='L137'></a><a href='#L137'>137</a>
<a name='L138'></a><a href='#L138'>138</a>
<a name='L139'></a><a href='#L139'>139</a>
<a name='L140'></a><a href='#L140'>140</a>
<a name='L141'></a><a href='#L141'>141</a>
<a name='L142'></a><a href='#L142'>142</a>
<a name='L143'></a><a href='#L143'>143</a>
<a name='L144'></a><a href='#L144'>144</a>
<a name='L145'></a><a href='#L145'>145</a>
<a name='L146'></a><a href='#L146'>146</a>
<a name='L147'></a><a href='#L147'>147</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">147x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">82x</span>
<span class="cline-any cline-yes">82x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">65x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">65x</span>
<span class="cline-any cline-yes">21x</span>
<span class="cline-any cline-yes">21x</span>
<span class="cline-any cline-yes">21x</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">44x</span>
<span class="cline-any cline-yes">18x</span>
<span class="cline-any cline-yes">18x</span>
<span class="cline-any cline-yes">18x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">23x</span>
<span class="cline-any cline-yes">19x</span>
<span class="cline-any cline-yes">19x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">27x</span>
<span class="cline-any cline-yes">27x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">39x</span>
<span class="cline-any cline-yes">39x</span>
<span class="cline-any cline-yes">39x</span>
<span class="cline-any cline-yes">39x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">39x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">35x</span>
<span class="cline-any cline-yes">35x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">39x</span>
<span class="cline-any cline-yes">39x</span>
<span class="cline-any cline-yes">58x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">39x</span>
<span class="cline-any cline-yes">30x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">20x</span>
<span class="cline-any cline-yes">20x</span>
<span class="cline-any cline-yes">20x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">60x</span>
<span class="cline-any cline-yes">60x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">60x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">58x</span>
<span class="cline-any cline-yes">58x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">58x</span>
<span class="cline-any cline-yes">58x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">58x</span>
<span class="cline-any cline-yes">58x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">60x</span>
<span class="cline-any cline-yes">48x</span>
<span class="cline-any cline-yes">48x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">/**
* Line-level dedup filter for the K8s log stream.
*
* The K8s log follow stream can reconnect with an overlapping `sinceSeconds`
* window (integer-second granularity + a safety buffer), which replays a few
* seconds of recent output on every reconnect. Without dedup those replayed
* lines appear as duplicate events in the streaming UI — the same assistant
* text block shows up between every subsequent tool call (FAR-123).
*
* The filter operates at the chunk → line level: chunks are split on `\n`,
* incomplete trailing content is buffered until the next chunk, and each
* complete line is emitted at most once. JSON-shaped Claude stream-json
* events are keyed by their stable structural IDs; non-JSON lines pass
* through unchanged so genuinely-repeated status lines are not swallowed.
*/
&nbsp;
type Parsed = Record&lt;string, unknown&gt;;
&nbsp;
function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
&nbsp;
function asRecord(value: unknown): Parsed | null {
<span class="missing-if-branch" title="if path not taken" >I</span>if (typeof value !== "object" || value === null || Array.isArray(value)) <span class="cstat-no" title="statement not covered" >return null;</span>
return value as Parsed;
}
&nbsp;
/**
* Build a stable dedup key for a Claude stream-json event. Returns `null`
* when the event is not a recognized Claude event — those lines fall back to
* raw-content hashing so non-JSON output (paperclip status lines, shell
* output) is never deduped by identity.
*/
export function eventDedupKey(event: Parsed): string | null {
const type = asString(event.type);
&nbsp;
if (type === "system") {
const subtype = asString(event.subtype);
const sessionId = asString(event.session_id);
<span class="missing-if-branch" title="else path not taken" >E</span>if (subtype === "init" &amp;&amp; sessionId) return `system:init:${sessionId}`;
<span class="cstat-no" title="statement not covered" > return null;</span>
}
&nbsp;
if (type === "assistant") {
const message = asRecord(event.message);
const id = message ? asString(message.id) : <span class="branch-1 cbranch-no" title="branch not covered" >"";</span>
if (id) return `assistant:${id}`;
return null;
}
&nbsp;
if (type === "user") {
const message = asRecord(event.message);
const content = message &amp;&amp; Array.isArray(message.content) ? message.content : <span class="branch-1 cbranch-no" title="branch not covered" >[];</span>
const toolUseIds: string[] = [];
for (const entry of content) {
const block = asRecord(entry);
<span class="missing-if-branch" title="if path not taken" >I</span>if (!block) <span class="cstat-no" title="statement not covered" >continue;</span>
const toolUseId = asString(block.tool_use_id);
<span class="missing-if-branch" title="else path not taken" >E</span>if (toolUseId) toolUseIds.push(toolUseId);
}
<span class="missing-if-branch" title="else path not taken" >E</span>if (toolUseIds.length &gt; 0) return `user:tool_result:${toolUseIds.join(",")}`;
<span class="cstat-no" title="statement not covered" > return null;</span>
}
&nbsp;
if (type === "result") {
const sessionId = asString(event.session_id);
return sessionId ? `result:${sessionId}` : <span class="branch-1 cbranch-no" title="branch not covered" >"result:unknown";</span>
}
&nbsp;
return null;
}
&nbsp;
/**
* Stateful line-level dedup filter. Emits `filter(chunk)` output through
* the caller — preserves original chunk formatting (including trailing
* newlines) for lines that pass the dedup check.
*/
export class LogLineDedupFilter {
private buffer = "";
private readonly seenKeys = new Set&lt;string&gt;();
&nbsp;
/**
* Process a chunk and return the subset that should be forwarded.
* Incomplete trailing content (no terminating newline) is buffered and
* emitted on the next chunk that completes the line (or on flush()).
*/
filter(chunk: string): string {
<span class="missing-if-branch" title="if path not taken" >I</span>if (!chunk) <span class="cstat-no" title="statement not covered" >return "";</span>
const combined = this.buffer + chunk;
const endsWithNewline = combined.endsWith("\n");
const parts = combined.split("\n");
&nbsp;
if (endsWithNewline) {
// Discard the final empty element — last line was complete.
parts.pop();
this.buffer = "";
} else {
// Last element is an incomplete line — hold it for the next chunk.
this.buffer = parts.pop() ?? <span class="branch-1 cbranch-no" title="branch not covered" >"";</span>
}
&nbsp;
const out: string[] = [];
for (const line of parts) {
if (this.shouldEmit(line)) out.push(line);
}
if (out.length === 0) return "";
return out.join("\n") + "\n";
}
&nbsp;
/**
* Flush any incomplete trailing content. Called when the stream ends
* without a terminating newline so the final partial line isn't lost.
*/
flush(): string {
const pending = this.buffer;
this.buffer = "";
if (!pending) return "";
return this.shouldEmit(pending) ? pending : "";
}
&nbsp;
private shouldEmit(line: string): boolean {
const trimmed = line.trim();
<span class="missing-if-branch" title="if path not taken" >I</span>if (!trimmed) <span class="cstat-no" title="statement not covered" >return true;</span>
&nbsp;
// Only attempt dedup on JSON-shaped lines; pass shell/text output through.
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return true;
&nbsp;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
<span class="cstat-no" title="statement not covered" > return true;</span>
}
&nbsp;
const event = asRecord(parsed);
<span class="missing-if-branch" title="if path not taken" >I</span>if (!event) <span class="cstat-no" title="statement not covered" >return true;</span>
&nbsp;
// Recognized Claude stream-json event → structural key.
const structuralKey = eventDedupKey(event);
const key = structuralKey ?? `raw:${trimmed}`;
&nbsp;
if (this.seenKeys.has(key)) return false;
this.seenKeys.add(key);
return true;
}
}
&nbsp;</pre></td></tr></table></pre>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-04-24T04:09:41.748Z
</div>
<script src="../../prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="../../sorter.js"></script>
<script src="../../block-navigation.js"></script>
</body>
</html>
@@ -0,0 +1,178 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for src/server/models.ts</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="../../prettify.css" />
<link rel="stylesheet" href="../../base.css" />
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(../../sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/server</a> models.ts</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Statements</span>
<span class='fraction'>4/4</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Branches</span>
<span class='fraction'>6/6</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Functions</span>
<span class='fraction'>2/2</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Lines</span>
<span class='fraction'>4/4</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line high'></div>
<pre><table class="coverage">
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
<a name='L2'></a><a href='#L2'>2</a>
<a name='L3'></a><a href='#L3'>3</a>
<a name='L4'></a><a href='#L4'>4</a>
<a name='L5'></a><a href='#L5'>5</a>
<a name='L6'></a><a href='#L6'>6</a>
<a name='L7'></a><a href='#L7'>7</a>
<a name='L8'></a><a href='#L8'>8</a>
<a name='L9'></a><a href='#L9'>9</a>
<a name='L10'></a><a href='#L10'>10</a>
<a name='L11'></a><a href='#L11'>11</a>
<a name='L12'></a><a href='#L12'>12</a>
<a name='L13'></a><a href='#L13'>13</a>
<a name='L14'></a><a href='#L14'>14</a>
<a name='L15'></a><a href='#L15'>15</a>
<a name='L16'></a><a href='#L16'>16</a>
<a name='L17'></a><a href='#L17'>17</a>
<a name='L18'></a><a href='#L18'>18</a>
<a name='L19'></a><a href='#L19'>19</a>
<a name='L20'></a><a href='#L20'>20</a>
<a name='L21'></a><a href='#L21'>21</a>
<a name='L22'></a><a href='#L22'>22</a>
<a name='L23'></a><a href='#L23'>23</a>
<a name='L24'></a><a href='#L24'>24</a>
<a name='L25'></a><a href='#L25'>25</a>
<a name='L26'></a><a href='#L26'>26</a>
<a name='L27'></a><a href='#L27'>27</a>
<a name='L28'></a><a href='#L28'>28</a>
<a name='L29'></a><a href='#L29'>29</a>
<a name='L30'></a><a href='#L30'>30</a>
<a name='L31'></a><a href='#L31'>31</a>
<a name='L32'></a><a href='#L32'>32</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">5x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">5x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">import type { AdapterModel } from "@paperclipai/adapter-utils";
&nbsp;
const DIRECT_MODELS: AdapterModel[] = [
{ id: "claude-opus-4-7", label: "Claude Opus 4.7" },
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ id: "claude-haiku-4-6", label: "Claude Haiku 4.6" },
{ id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
];
&nbsp;
const BEDROCK_MODELS: AdapterModel[] = [
{ id: "us.anthropic.claude-opus-4-7", label: "Bedrock Opus 4.7" },
{ id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" },
{ id: "us.anthropic.claude-sonnet-4-6", label: "Bedrock Sonnet 4.6" },
{ id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", label: "Bedrock Sonnet 4.5" },
{ id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Bedrock Haiku 4.5" },
];
&nbsp;
function isBedrockEnv(): boolean {
return (
process.env.CLAUDE_CODE_USE_BEDROCK === "1" ||
process.env.CLAUDE_CODE_USE_BEDROCK === "true" ||
(typeof process.env.ANTHROPIC_BEDROCK_BASE_URL === "string" &amp;&amp;
process.env.ANTHROPIC_BEDROCK_BASE_URL.trim().length &gt; 0)
);
}
&nbsp;
export async function listK8sModels(): Promise&lt;AdapterModel[]&gt; {
return isBedrockEnv() ? BEDROCK_MODELS : DIRECT_MODELS;
}
&nbsp;</pre></td></tr></table></pre>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-04-24T04:09:41.748Z
</div>
<script src="../../prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="../../sorter.js"></script>
<script src="../../block-navigation.js"></script>
</body>
</html>
@@ -0,0 +1,562 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for src/server/prompt-cache.ts</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="../../prettify.css" />
<link rel="stylesheet" href="../../base.css" />
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(../../sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/server</a> prompt-cache.ts</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">34.88% </span>
<span class="quiet">Statements</span>
<span class='fraction'>30/86</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">47.82% </span>
<span class="quiet">Branches</span>
<span class='fraction'>22/46</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">30.76% </span>
<span class="quiet">Functions</span>
<span class='fraction'>4/13</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">34.66% </span>
<span class="quiet">Lines</span>
<span class='fraction'>26/75</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line low'></div>
<pre><table class="coverage">
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
<a name='L2'></a><a href='#L2'>2</a>
<a name='L3'></a><a href='#L3'>3</a>
<a name='L4'></a><a href='#L4'>4</a>
<a name='L5'></a><a href='#L5'>5</a>
<a name='L6'></a><a href='#L6'>6</a>
<a name='L7'></a><a href='#L7'>7</a>
<a name='L8'></a><a href='#L8'>8</a>
<a name='L9'></a><a href='#L9'>9</a>
<a name='L10'></a><a href='#L10'>10</a>
<a name='L11'></a><a href='#L11'>11</a>
<a name='L12'></a><a href='#L12'>12</a>
<a name='L13'></a><a href='#L13'>13</a>
<a name='L14'></a><a href='#L14'>14</a>
<a name='L15'></a><a href='#L15'>15</a>
<a name='L16'></a><a href='#L16'>16</a>
<a name='L17'></a><a href='#L17'>17</a>
<a name='L18'></a><a href='#L18'>18</a>
<a name='L19'></a><a href='#L19'>19</a>
<a name='L20'></a><a href='#L20'>20</a>
<a name='L21'></a><a href='#L21'>21</a>
<a name='L22'></a><a href='#L22'>22</a>
<a name='L23'></a><a href='#L23'>23</a>
<a name='L24'></a><a href='#L24'>24</a>
<a name='L25'></a><a href='#L25'>25</a>
<a name='L26'></a><a href='#L26'>26</a>
<a name='L27'></a><a href='#L27'>27</a>
<a name='L28'></a><a href='#L28'>28</a>
<a name='L29'></a><a href='#L29'>29</a>
<a name='L30'></a><a href='#L30'>30</a>
<a name='L31'></a><a href='#L31'>31</a>
<a name='L32'></a><a href='#L32'>32</a>
<a name='L33'></a><a href='#L33'>33</a>
<a name='L34'></a><a href='#L34'>34</a>
<a name='L35'></a><a href='#L35'>35</a>
<a name='L36'></a><a href='#L36'>36</a>
<a name='L37'></a><a href='#L37'>37</a>
<a name='L38'></a><a href='#L38'>38</a>
<a name='L39'></a><a href='#L39'>39</a>
<a name='L40'></a><a href='#L40'>40</a>
<a name='L41'></a><a href='#L41'>41</a>
<a name='L42'></a><a href='#L42'>42</a>
<a name='L43'></a><a href='#L43'>43</a>
<a name='L44'></a><a href='#L44'>44</a>
<a name='L45'></a><a href='#L45'>45</a>
<a name='L46'></a><a href='#L46'>46</a>
<a name='L47'></a><a href='#L47'>47</a>
<a name='L48'></a><a href='#L48'>48</a>
<a name='L49'></a><a href='#L49'>49</a>
<a name='L50'></a><a href='#L50'>50</a>
<a name='L51'></a><a href='#L51'>51</a>
<a name='L52'></a><a href='#L52'>52</a>
<a name='L53'></a><a href='#L53'>53</a>
<a name='L54'></a><a href='#L54'>54</a>
<a name='L55'></a><a href='#L55'>55</a>
<a name='L56'></a><a href='#L56'>56</a>
<a name='L57'></a><a href='#L57'>57</a>
<a name='L58'></a><a href='#L58'>58</a>
<a name='L59'></a><a href='#L59'>59</a>
<a name='L60'></a><a href='#L60'>60</a>
<a name='L61'></a><a href='#L61'>61</a>
<a name='L62'></a><a href='#L62'>62</a>
<a name='L63'></a><a href='#L63'>63</a>
<a name='L64'></a><a href='#L64'>64</a>
<a name='L65'></a><a href='#L65'>65</a>
<a name='L66'></a><a href='#L66'>66</a>
<a name='L67'></a><a href='#L67'>67</a>
<a name='L68'></a><a href='#L68'>68</a>
<a name='L69'></a><a href='#L69'>69</a>
<a name='L70'></a><a href='#L70'>70</a>
<a name='L71'></a><a href='#L71'>71</a>
<a name='L72'></a><a href='#L72'>72</a>
<a name='L73'></a><a href='#L73'>73</a>
<a name='L74'></a><a href='#L74'>74</a>
<a name='L75'></a><a href='#L75'>75</a>
<a name='L76'></a><a href='#L76'>76</a>
<a name='L77'></a><a href='#L77'>77</a>
<a name='L78'></a><a href='#L78'>78</a>
<a name='L79'></a><a href='#L79'>79</a>
<a name='L80'></a><a href='#L80'>80</a>
<a name='L81'></a><a href='#L81'>81</a>
<a name='L82'></a><a href='#L82'>82</a>
<a name='L83'></a><a href='#L83'>83</a>
<a name='L84'></a><a href='#L84'>84</a>
<a name='L85'></a><a href='#L85'>85</a>
<a name='L86'></a><a href='#L86'>86</a>
<a name='L87'></a><a href='#L87'>87</a>
<a name='L88'></a><a href='#L88'>88</a>
<a name='L89'></a><a href='#L89'>89</a>
<a name='L90'></a><a href='#L90'>90</a>
<a name='L91'></a><a href='#L91'>91</a>
<a name='L92'></a><a href='#L92'>92</a>
<a name='L93'></a><a href='#L93'>93</a>
<a name='L94'></a><a href='#L94'>94</a>
<a name='L95'></a><a href='#L95'>95</a>
<a name='L96'></a><a href='#L96'>96</a>
<a name='L97'></a><a href='#L97'>97</a>
<a name='L98'></a><a href='#L98'>98</a>
<a name='L99'></a><a href='#L99'>99</a>
<a name='L100'></a><a href='#L100'>100</a>
<a name='L101'></a><a href='#L101'>101</a>
<a name='L102'></a><a href='#L102'>102</a>
<a name='L103'></a><a href='#L103'>103</a>
<a name='L104'></a><a href='#L104'>104</a>
<a name='L105'></a><a href='#L105'>105</a>
<a name='L106'></a><a href='#L106'>106</a>
<a name='L107'></a><a href='#L107'>107</a>
<a name='L108'></a><a href='#L108'>108</a>
<a name='L109'></a><a href='#L109'>109</a>
<a name='L110'></a><a href='#L110'>110</a>
<a name='L111'></a><a href='#L111'>111</a>
<a name='L112'></a><a href='#L112'>112</a>
<a name='L113'></a><a href='#L113'>113</a>
<a name='L114'></a><a href='#L114'>114</a>
<a name='L115'></a><a href='#L115'>115</a>
<a name='L116'></a><a href='#L116'>116</a>
<a name='L117'></a><a href='#L117'>117</a>
<a name='L118'></a><a href='#L118'>118</a>
<a name='L119'></a><a href='#L119'>119</a>
<a name='L120'></a><a href='#L120'>120</a>
<a name='L121'></a><a href='#L121'>121</a>
<a name='L122'></a><a href='#L122'>122</a>
<a name='L123'></a><a href='#L123'>123</a>
<a name='L124'></a><a href='#L124'>124</a>
<a name='L125'></a><a href='#L125'>125</a>
<a name='L126'></a><a href='#L126'>126</a>
<a name='L127'></a><a href='#L127'>127</a>
<a name='L128'></a><a href='#L128'>128</a>
<a name='L129'></a><a href='#L129'>129</a>
<a name='L130'></a><a href='#L130'>130</a>
<a name='L131'></a><a href='#L131'>131</a>
<a name='L132'></a><a href='#L132'>132</a>
<a name='L133'></a><a href='#L133'>133</a>
<a name='L134'></a><a href='#L134'>134</a>
<a name='L135'></a><a href='#L135'>135</a>
<a name='L136'></a><a href='#L136'>136</a>
<a name='L137'></a><a href='#L137'>137</a>
<a name='L138'></a><a href='#L138'>138</a>
<a name='L139'></a><a href='#L139'>139</a>
<a name='L140'></a><a href='#L140'>140</a>
<a name='L141'></a><a href='#L141'>141</a>
<a name='L142'></a><a href='#L142'>142</a>
<a name='L143'></a><a href='#L143'>143</a>
<a name='L144'></a><a href='#L144'>144</a>
<a name='L145'></a><a href='#L145'>145</a>
<a name='L146'></a><a href='#L146'>146</a>
<a name='L147'></a><a href='#L147'>147</a>
<a name='L148'></a><a href='#L148'>148</a>
<a name='L149'></a><a href='#L149'>149</a>
<a name='L150'></a><a href='#L150'>150</a>
<a name='L151'></a><a href='#L151'>151</a>
<a name='L152'></a><a href='#L152'>152</a>
<a name='L153'></a><a href='#L153'>153</a>
<a name='L154'></a><a href='#L154'>154</a>
<a name='L155'></a><a href='#L155'>155</a>
<a name='L156'></a><a href='#L156'>156</a>
<a name='L157'></a><a href='#L157'>157</a>
<a name='L158'></a><a href='#L158'>158</a>
<a name='L159'></a><a href='#L159'>159</a>
<a name='L160'></a><a href='#L160'>160</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-yes">7x</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createHash } from "node:crypto";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
import {
type PaperclipSkillEntry,
ensurePaperclipSkillSymlink,
} from "@paperclipai/adapter-utils/server-utils";
&nbsp;
export interface ClaudePromptBundle {
bundleKey: string;
/** Absolute path to the bundle root directory (contains .claude/skills/ and agent-instructions.md). */
rootDir: string;
/** Value to pass as --add-dir to the Claude CLI. */
addDir: string;
/** Path to the materialized instructions file, or null if no instructions were provided. */
instructionsFilePath: string | null;
}
&nbsp;
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
&nbsp;
function validatePathComponent(value: string, fieldName: string): void {
if (value.trim().length === 0) throw new Error(`Invalid ${fieldName}: must not be empty`);
if (value.includes("/") || value.includes("\\")) throw new Error(`Invalid ${fieldName}: must not contain path separators`);
if (value.includes("..")) throw new Error(`Invalid ${fieldName}: must not contain ".."`);
if (value.includes("\0")) throw new Error(`Invalid ${fieldName}: must not contain null bytes`);
}
&nbsp;
function resolveManagedClaudePromptCacheRoot(companyId: string): string {
const paperclipHome =
(typeof process.env.PAPERCLIP_HOME === "string" &amp;&amp; process.env.PAPERCLIP_HOME.trim().length &gt; 0
? process.env.PAPERCLIP_HOME.trim()
: <span class="branch-1 cbranch-no" title="branch not covered" >null) ??</span>
<span class="branch-1 cbranch-no" title="branch not covered" > path.resolve(os.homedir(), ".paperclip");</span>
const instanceId =
(typeof process.env.PAPERCLIP_INSTANCE_ID === "string" &amp;&amp; process.env.PAPERCLIP_INSTANCE_ID.trim().length &gt; 0
? process.env.PAPERCLIP_INSTANCE_ID.trim()
: <span class="branch-1 cbranch-no" title="branch not covered" >null) ?? <span class="branch-1 cbranch-no" title="branch not covered" >D</span>EFAULT_PAPERCLIP_INSTANCE_ID;</span>
validatePathComponent(companyId, "companyId");
validatePathComponent(instanceId, "instanceId");
return path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "claude-prompt-cache");
}
&nbsp;
async function <span class="fstat-no" title="function not covered" >hashPathContents(</span>
candidate: string,
hash: ReturnType&lt;typeof createHash&gt;,
relativePath: string,
seenDirectories: Set&lt;string&gt;,
): Promise&lt;void&gt; {
const stat = <span class="cstat-no" title="statement not covered" >await fs.lstat(candidate);</span>
<span class="cstat-no" title="statement not covered" > if (stat.isSymbolicLink()) {</span>
<span class="cstat-no" title="statement not covered" > hash.update(`symlink:${relativePath}\n`);</span>
const resolved = <span class="cstat-no" title="statement not covered" >await fs.realpath(candidate).<span class="fstat-no" title="function not covered" >catch(() =&gt; <span class="cstat-no" title="statement not covered" >n</span>ull);</span></span>
<span class="cstat-no" title="statement not covered" > if (!resolved) {</span>
<span class="cstat-no" title="statement not covered" > hash.update("missing\n");</span>
<span class="cstat-no" title="statement not covered" > return;</span>
}
<span class="cstat-no" title="statement not covered" > await hashPathContents(resolved, hash, relativePath, seenDirectories);</span>
<span class="cstat-no" title="statement not covered" > return;</span>
}
<span class="cstat-no" title="statement not covered" > if (stat.isDirectory()) {</span>
const realDir = <span class="cstat-no" title="statement not covered" >await fs.realpath(candidate).<span class="fstat-no" title="function not covered" >catch(() =&gt; <span class="cstat-no" title="statement not covered" >c</span>andidate);</span></span>
<span class="cstat-no" title="statement not covered" > hash.update(`dir:${relativePath}\n`);</span>
<span class="cstat-no" title="statement not covered" > if (seenDirectories.has(realDir)) {</span>
<span class="cstat-no" title="statement not covered" > hash.update("loop\n");</span>
<span class="cstat-no" title="statement not covered" > return;</span>
}
<span class="cstat-no" title="statement not covered" > seenDirectories.add(realDir);</span>
const entries = <span class="cstat-no" title="statement not covered" >await fs.readdir(candidate, { withFileTypes: true });</span>
<span class="cstat-no" title="statement not covered" > entries.<span class="fstat-no" title="function not covered" >sort((a</span>, b) =&gt; <span class="cstat-no" title="statement not covered" >a.name.localeCompare(b.name))</span>;</span>
<span class="cstat-no" title="statement not covered" > for (const entry of entries) {</span>
const childRelativePath = <span class="cstat-no" title="statement not covered" >relativePath.length &gt; 0 ? `${relativePath}/${entry.name}` : entry.name;</span>
<span class="cstat-no" title="statement not covered" > await hashPathContents(path.join(candidate, entry.name), hash, childRelativePath, seenDirectories);</span>
}
<span class="cstat-no" title="statement not covered" > return;</span>
}
<span class="cstat-no" title="statement not covered" > if (stat.isFile()) {</span>
<span class="cstat-no" title="statement not covered" > hash.update(`file:${relativePath}\n`);</span>
<span class="cstat-no" title="statement not covered" > hash.update(await fs.readFile(candidate));</span>
<span class="cstat-no" title="statement not covered" > hash.update("\n");</span>
<span class="cstat-no" title="statement not covered" > return;</span>
}
<span class="cstat-no" title="statement not covered" > hash.update(`other:${relativePath}:${stat.mode}\n`);</span>
}
&nbsp;
async function buildClaudePromptBundleKey(input: {
skills: PaperclipSkillEntry[];
instructionsContents: string | null;
}): Promise&lt;string&gt; {
const hash = createHash("sha256");
hash.update("paperclip-claude-prompt-bundle:v1\n");
<span class="missing-if-branch" title="if path not taken" >I</span>if (input.instructionsContents) {
<span class="cstat-no" title="statement not covered" > hash.update("instructions\n");</span>
<span class="cstat-no" title="statement not covered" > hash.update(input.instructionsContents);</span>
<span class="cstat-no" title="statement not covered" > hash.update("\n");</span>
} else {
hash.update("instructions:none\n");
}
const sortedSkills = [...input.skills].<span class="fstat-no" title="function not covered" >sort((a</span>, b) =&gt; <span class="cstat-no" title="statement not covered" >a.runtimeName.localeCompare(b.runtimeName))</span>;
for (const entry of sortedSkills) {
<span class="cstat-no" title="statement not covered" > hash.update(`skill:${entry.key}:${entry.runtimeName}\n`);</span>
<span class="cstat-no" title="statement not covered" > await hashPathContents(entry.source, hash, entry.runtimeName, new Set());</span>
}
return hash.digest("hex");
}
&nbsp;
async function <span class="fstat-no" title="function not covered" >ensureReadableFile(t</span>argetPath: string, contents: string): Promise&lt;void&gt; {
<span class="cstat-no" title="statement not covered" > try {</span>
<span class="cstat-no" title="statement not covered" > await fs.access(targetPath, fsConstants.R_OK);</span>
<span class="cstat-no" title="statement not covered" > return;</span>
} catch {
// Fall through and materialize the file.
}
<span class="cstat-no" title="statement not covered" > await fs.mkdir(path.dirname(targetPath), { recursive: true });</span>
const tempPath = <span class="cstat-no" title="statement not covered" >`${targetPath}.${process.pid}.${Date.now()}.tmp`;</span>
<span class="cstat-no" title="statement not covered" > try {</span>
<span class="cstat-no" title="statement not covered" > await fs.writeFile(tempPath, contents, "utf8");</span>
<span class="cstat-no" title="statement not covered" > await fs.rename(tempPath, targetPath);</span>
} catch (err) {
const targetReadable = <span class="cstat-no" title="statement not covered" >await fs.access(targetPath, fsConstants.R_OK).<span class="fstat-no" title="function not covered" >then(() =&gt; <span class="cstat-no" title="statement not covered" >t</span>rue).<span class="fstat-no" title="function not covered" ></span>catch(() =&gt; <span class="cstat-no" title="statement not covered" >f</span>alse);</span></span>
<span class="cstat-no" title="statement not covered" > if (!targetReadable) <span class="cstat-no" title="statement not covered" >throw err;</span></span>
} finally {
<span class="cstat-no" title="statement not covered" > await fs.rm(tempPath, { force: true }).<span class="fstat-no" title="function not covered" >catch(() =&gt; {</span>});</span>
}
}
&nbsp;
export async function prepareClaudePromptBundle(input: {
companyId: string;
skills: PaperclipSkillEntry[];
instructionsContents: string | null;
onLog: AdapterExecutionContext["onLog"];
}): Promise&lt;ClaudePromptBundle&gt; {
const { companyId, skills, instructionsContents, onLog } = input;
const bundleKey = await buildClaudePromptBundleKey({ skills, instructionsContents });
const rootDir = path.join(resolveManagedClaudePromptCacheRoot(companyId), bundleKey);
const skillsHome = path.join(rootDir, ".claude", "skills");
await fs.mkdir(skillsHome, { recursive: true });
&nbsp;
for (const entry of skills) {
const target = <span class="cstat-no" title="statement not covered" >path.join(skillsHome, entry.runtimeName);</span>
<span class="cstat-no" title="statement not covered" > try {</span>
<span class="cstat-no" title="statement not covered" > await ensurePaperclipSkillSymlink(entry.source, target);</span>
} catch (err) {
<span class="cstat-no" title="statement not covered" > await onLog(</span>
"stderr",
`[paperclip] Failed to materialize Claude skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
&nbsp;
const instructionsFilePath = instructionsContents ? <span class="branch-0 cbranch-no" title="branch not covered" >path.join(rootDir, "agent-instructions.md") </span>: null;
<span class="missing-if-branch" title="if path not taken" >I</span>if (instructionsFilePath &amp;&amp; <span class="branch-1 cbranch-no" title="branch not covered" >instructionsContents) {</span>
<span class="cstat-no" title="statement not covered" > await ensureReadableFile(instructionsFilePath, instructionsContents);</span>
}
&nbsp;
return { bundleKey, rootDir, addDir: rootDir, instructionsFilePath };
}
&nbsp;</pre></td></tr></table></pre>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-04-24T04:09:41.748Z
</div>
<script src="../../prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="../../sorter.js"></script>
<script src="../../block-navigation.js"></script>
</body>
</html>
@@ -0,0 +1,388 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for src/server/skills.ts</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="../../prettify.css" />
<link rel="stylesheet" href="../../base.css" />
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(../../sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/server</a> skills.ts</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Statements</span>
<span class='fraction'>25/25</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">88.88% </span>
<span class="quiet">Branches</span>
<span class='fraction'>16/18</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Functions</span>
<span class='fraction'>7/7</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Lines</span>
<span class='fraction'>19/19</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line high'></div>
<pre><table class="coverage">
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
<a name='L2'></a><a href='#L2'>2</a>
<a name='L3'></a><a href='#L3'>3</a>
<a name='L4'></a><a href='#L4'>4</a>
<a name='L5'></a><a href='#L5'>5</a>
<a name='L6'></a><a href='#L6'>6</a>
<a name='L7'></a><a href='#L7'>7</a>
<a name='L8'></a><a href='#L8'>8</a>
<a name='L9'></a><a href='#L9'>9</a>
<a name='L10'></a><a href='#L10'>10</a>
<a name='L11'></a><a href='#L11'>11</a>
<a name='L12'></a><a href='#L12'>12</a>
<a name='L13'></a><a href='#L13'>13</a>
<a name='L14'></a><a href='#L14'>14</a>
<a name='L15'></a><a href='#L15'>15</a>
<a name='L16'></a><a href='#L16'>16</a>
<a name='L17'></a><a href='#L17'>17</a>
<a name='L18'></a><a href='#L18'>18</a>
<a name='L19'></a><a href='#L19'>19</a>
<a name='L20'></a><a href='#L20'>20</a>
<a name='L21'></a><a href='#L21'>21</a>
<a name='L22'></a><a href='#L22'>22</a>
<a name='L23'></a><a href='#L23'>23</a>
<a name='L24'></a><a href='#L24'>24</a>
<a name='L25'></a><a href='#L25'>25</a>
<a name='L26'></a><a href='#L26'>26</a>
<a name='L27'></a><a href='#L27'>27</a>
<a name='L28'></a><a href='#L28'>28</a>
<a name='L29'></a><a href='#L29'>29</a>
<a name='L30'></a><a href='#L30'>30</a>
<a name='L31'></a><a href='#L31'>31</a>
<a name='L32'></a><a href='#L32'>32</a>
<a name='L33'></a><a href='#L33'>33</a>
<a name='L34'></a><a href='#L34'>34</a>
<a name='L35'></a><a href='#L35'>35</a>
<a name='L36'></a><a href='#L36'>36</a>
<a name='L37'></a><a href='#L37'>37</a>
<a name='L38'></a><a href='#L38'>38</a>
<a name='L39'></a><a href='#L39'>39</a>
<a name='L40'></a><a href='#L40'>40</a>
<a name='L41'></a><a href='#L41'>41</a>
<a name='L42'></a><a href='#L42'>42</a>
<a name='L43'></a><a href='#L43'>43</a>
<a name='L44'></a><a href='#L44'>44</a>
<a name='L45'></a><a href='#L45'>45</a>
<a name='L46'></a><a href='#L46'>46</a>
<a name='L47'></a><a href='#L47'>47</a>
<a name='L48'></a><a href='#L48'>48</a>
<a name='L49'></a><a href='#L49'>49</a>
<a name='L50'></a><a href='#L50'>50</a>
<a name='L51'></a><a href='#L51'>51</a>
<a name='L52'></a><a href='#L52'>52</a>
<a name='L53'></a><a href='#L53'>53</a>
<a name='L54'></a><a href='#L54'>54</a>
<a name='L55'></a><a href='#L55'>55</a>
<a name='L56'></a><a href='#L56'>56</a>
<a name='L57'></a><a href='#L57'>57</a>
<a name='L58'></a><a href='#L58'>58</a>
<a name='L59'></a><a href='#L59'>59</a>
<a name='L60'></a><a href='#L60'>60</a>
<a name='L61'></a><a href='#L61'>61</a>
<a name='L62'></a><a href='#L62'>62</a>
<a name='L63'></a><a href='#L63'>63</a>
<a name='L64'></a><a href='#L64'>64</a>
<a name='L65'></a><a href='#L65'>65</a>
<a name='L66'></a><a href='#L66'>66</a>
<a name='L67'></a><a href='#L67'>67</a>
<a name='L68'></a><a href='#L68'>68</a>
<a name='L69'></a><a href='#L69'>69</a>
<a name='L70'></a><a href='#L70'>70</a>
<a name='L71'></a><a href='#L71'>71</a>
<a name='L72'></a><a href='#L72'>72</a>
<a name='L73'></a><a href='#L73'>73</a>
<a name='L74'></a><a href='#L74'>74</a>
<a name='L75'></a><a href='#L75'>75</a>
<a name='L76'></a><a href='#L76'>76</a>
<a name='L77'></a><a href='#L77'>77</a>
<a name='L78'></a><a href='#L78'>78</a>
<a name='L79'></a><a href='#L79'>79</a>
<a name='L80'></a><a href='#L80'>80</a>
<a name='L81'></a><a href='#L81'>81</a>
<a name='L82'></a><a href='#L82'>82</a>
<a name='L83'></a><a href='#L83'>83</a>
<a name='L84'></a><a href='#L84'>84</a>
<a name='L85'></a><a href='#L85'>85</a>
<a name='L86'></a><a href='#L86'>86</a>
<a name='L87'></a><a href='#L87'>87</a>
<a name='L88'></a><a href='#L88'>88</a>
<a name='L89'></a><a href='#L89'>89</a>
<a name='L90'></a><a href='#L90'>90</a>
<a name='L91'></a><a href='#L91'>91</a>
<a name='L92'></a><a href='#L92'>92</a>
<a name='L93'></a><a href='#L93'>93</a>
<a name='L94'></a><a href='#L94'>94</a>
<a name='L95'></a><a href='#L95'>95</a>
<a name='L96'></a><a href='#L96'>96</a>
<a name='L97'></a><a href='#L97'>97</a>
<a name='L98'></a><a href='#L98'>98</a>
<a name='L99'></a><a href='#L99'>99</a>
<a name='L100'></a><a href='#L100'>100</a>
<a name='L101'></a><a href='#L101'>101</a>
<a name='L102'></a><a href='#L102'>102</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-yes">5x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">import type {
AdapterSkillContext,
AdapterSkillSnapshot,
AdapterSkillEntry,
} from "@paperclipai/adapter-utils";
import {
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
readInstalledSkillTargets,
} from "@paperclipai/adapter-utils/server-utils";
import path from "node:path";
&nbsp;
const SKILLS_HOME = "/paperclip/.claude/skills";
&nbsp;
async function buildK8sSkillSnapshot(
config: Record&lt;string, unknown&gt;,
): Promise&lt;AdapterSkillSnapshot&gt; {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, import.meta.dirname ?? <span class="branch-1 cbranch-no" title="branch not covered" >__dirname);</span>
const availableByKey = new Map(availableEntries.map((e) =&gt; [e.key, e]));
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const installed = await readInstalledSkillTargets(SKILLS_HOME);
&nbsp;
const entries: AdapterSkillEntry[] = availableEntries.map((entry) =&gt; ({
key: entry.key,
runtimeName: entry.runtimeName,
desired: desiredSet.has(entry.key),
managed: true,
state: desiredSet.has(entry.key) ? "configured" : "available",
origin: entry.required ? "paperclip_required" : "company_managed",
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
readOnly: false,
sourcePath: entry.source,
targetPath: null,
detail: desiredSet.has(entry.key)
? "Materialized into the PVC-backed Claude prompt bundle before each K8s Job run."
: null,
required: Boolean(entry.required),
requiredReason: entry.requiredReason ?? null,
}));
&nbsp;
const warnings: string[] = [];
&nbsp;
for (const desiredSkill of desiredSkills) {
if (availableByKey.has(desiredSkill)) continue;
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
entries.push({
key: desiredSkill,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
origin: "external_unknown",
originLabel: "External or unavailable",
readOnly: false,
sourcePath: undefined,
targetPath: undefined,
detail: "Paperclip cannot find this skill in the runtime skills directory.",
});
}
&nbsp;
for (const [name, installedEntry] of installed.entries()) {
if (availableEntries.some((e) =&gt; e.runtimeName === name)) continue;
entries.push({
key: name,
runtimeName: name,
desired: false,
managed: false,
state: "external",
origin: "user_installed",
originLabel: "User-installed",
locationLabel: "~/.claude/skills",
readOnly: true,
sourcePath: null,
targetPath: installedEntry.targetPath ?? <span class="branch-1 cbranch-no" title="branch not covered" >path.join(SKILLS_HOME, name),</span>
detail: "Installed outside Paperclip management in the Claude skills home.",
});
}
&nbsp;
entries.sort((a, b) =&gt; a.key.localeCompare(b.key));
&nbsp;
return {
adapterType: "claude_k8s",
supported: true,
mode: "ephemeral",
desiredSkills,
entries,
warnings,
};
}
&nbsp;
export async function listK8sSkills(ctx: AdapterSkillContext): Promise&lt;AdapterSkillSnapshot&gt; {
return buildK8sSkillSnapshot(ctx.config);
}
&nbsp;
export async function syncK8sSkills(
ctx: AdapterSkillContext,
_desiredSkills: string[],
): Promise&lt;AdapterSkillSnapshot&gt; {
return buildK8sSkillSnapshot(ctx.config);
}
&nbsp;</pre></td></tr></table></pre>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-04-24T04:09:41.748Z
</div>
<script src="../../prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="../../sorter.js"></script>
<script src="../../block-navigation.js"></script>
</body>
</html>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "paperclip-adapter-claude-k8s",
"version": "0.1.34",
"version": "0.1.38",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paperclip-adapter-claude-k8s",
"version": "0.1.34",
"version": "0.1.38",
"license": "MIT",
"dependencies": {
"@kubernetes/client-node": "^1.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paperclip-adapter-claude-k8s",
"version": "0.1.34",
"version": "0.1.38",
"description": "Paperclip adapter plugin that runs Claude Code agents as Kubernetes Jobs",
"license": "MIT",
"repository": {
+5 -1
View File
@@ -15,7 +15,6 @@ Core fields:
- model (string, optional): Claude model id
- effort (string, optional): reasoning effort passed via --effort (low|medium|high)
- maxTurnsPerRun (number, optional): max turns for one run
- dangerouslySkipPermissions (boolean, optional): pass --dangerously-skip-permissions to claude
- instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime via --append-system-prompt-file
- extraArgs (string[], optional): additional CLI args appended to the claude command
- env (object, optional): KEY=VALUE environment variables; overrides inherited vars from the Deployment
@@ -31,6 +30,11 @@ Kubernetes fields:
- labels (object, optional): extra labels added to Job metadata
- ttlSecondsAfterFinished (number, optional): auto-cleanup delay; default 300
- retainJobs (boolean, optional): skip cleanup on completion for debugging
- reattachOrphanedJobs (boolean, optional): when true (default), attach to a running orphaned Job that matches the current agent/task/session instead of blocking; when false, any non-terminal orphan blocks the new run
Output filtering fields:
- enableRtk (boolean, optional): truncate oversized tool outputs before they reach the model via a PostToolUse hook; default false
- rtkMaxOutputBytes (number, optional): byte threshold for tool output truncation when enableRtk is true; default 50000
Operational fields:
- timeoutSec (number, optional): run timeout in seconds; 0 means no timeout
+2 -4
View File
@@ -34,12 +34,10 @@ describe("getConfigSchema", () => {
expect(field!.default).toBe(1000);
});
it("dangerouslySkipPermissions defaults to true", () => {
it("does not expose dangerouslySkipPermissions in UI schema", () => {
const schema = getConfigSchema();
const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "dangerouslySkipPermissions");
expect(field).toBeDefined();
expect(field!.type).toBe("toggle");
expect(field!.default).toBe(true);
expect(field).toBeUndefined();
});
it("reattachOrphanedJobs defaults to true", () => {
+1 -8
View File
@@ -34,13 +34,6 @@ export function getConfigSchema(): AdapterConfigSchema {
hint: "Maximum number of agentic turns (tool calls) per heartbeat run. 0 means unlimited.",
default: 1000,
},
{
type: "toggle",
key: "dangerouslySkipPermissions",
label: "Skip Permissions",
hint: "Pass --dangerously-skip-permissions to Claude. Enabled by default for unattended K8s Jobs.",
default: true,
},
// Kubernetes
{
type: "text",
@@ -93,7 +86,7 @@ export function getConfigSchema(): AdapterConfigSchema {
type: "toggle",
key: "reattachOrphanedJobs",
label: "Reattach to Orphaned Jobs",
hint: "If a prior K8s Job for the same agent/task/session is still running (e.g. Paperclip restarted mid-run), attach to it and stream its output instead of deleting it and starting a new pod. Default: on.",
hint: "If a prior K8s Job for the same agent/task/session is still running (e.g. Paperclip restarted mid-run), attach to it and stream its output instead of blocking the new run. When false, any non-terminal orphan blocks the new run. Default: on.",
default: true,
},
// Resource Limits
File diff suppressed because it is too large Load Diff
+200 -136
View File
@@ -26,15 +26,18 @@ const POLL_INTERVAL_MS = 2000;
const KEEPALIVE_INTERVAL_MS = 15_000;
const LOG_STREAM_RECONNECT_DELAY_MS = 3_000;
const MAX_LOG_RECONNECT_ATTEMPTS = 50;
// How long to keep refreshing onSpawn after the Job reaches a terminal state.
// Covers the cleanup path (delete job, parse stdout) so a slow K8s API call
// doesn't trip the 5-minute reaper staleness window.
const POST_TERMINAL_KEEPALIVE_MS = 90_000;
// Upper bound on how long streamPodLogsOnce will wait after stopSignal fires
// before force-returning, even if logApi.log has not yet resolved. Defensive
// against the K8s client library not propagating writable.destroy() into an
// abort of the underlying HTTP request.
const LOG_STREAM_BAIL_TIMEOUT_MS = 3_000;
// After the log stream exits (container stopped producing output), wait this
// long for the K8s Job condition to be confirmed before treating the job as
// done. K8s Job conditions can lag pod exit by several seconds or more under
// cluster load. Without this bound, waitForJobCompletion keeps polling while
// streamPodLogs keeps reconnecting — together they can hold execute() open for
// minutes, causing stale "running" status in the UI (FAR-23).
const LOG_EXIT_COMPLETION_GRACE_MS = 30_000;
/**
* Detect a Kubernetes 404 (Not Found) error from @kubernetes/client-node.
@@ -89,30 +92,46 @@ export function buildPartialRunError(
: `Claude exited with code ${exitCode ?? -1}`;
}
export type OrphanClassification =
| "reattach"
| "block_session_mismatch"
| "block_task_mismatch"
| "block_task_unknown";
/**
* Evaluate an orphaned K8s Job (one whose `paperclip.io/run-id` label does
* not match the current runId) as a potential reattach target. A Job is
* reattachable when it belongs to the same agent, same task, and same resume
* session as the current run — meaning the previous Paperclip instance was
* mid-stream on the exact piece of work this new run was dispatched to do.
* Classify a non-terminal orphaned K8s Job (one whose `paperclip.io/run-id`
* label does not match the current runId but does belong to this agent) as a
* reattach candidate or a block reason.
*
* Decision matrix:
* - taskId mismatch (both present, different values) → block_task_mismatch
* - taskId missing on either side → block_task_unknown
* - taskId match + both have sessionId + sessionIds differ → block_session_mismatch
* - taskId match + one or both sides missing sessionId → reattach (reconcile)
* - taskId match + both have sessionId + sessionIds match → reattach (happy path)
*
* Exported for unit tests.
*/
export function isReattachableOrphan(
export function classifyOrphan(
job: k8s.V1Job,
expected: { agentId: string; taskId: string | null; sessionId: string | null },
): boolean {
if (!expected.taskId || !expected.sessionId) return false;
expected: { taskId: string | null; sessionId: string | null },
): OrphanClassification {
const labels = job.metadata?.labels ?? {};
if (labels["paperclip.io/adapter-type"] !== "claude_k8s") return false;
if (labels["paperclip.io/agent-id"] !== expected.agentId) return false;
if (labels["paperclip.io/task-id"] !== expected.taskId) return false;
if (labels["paperclip.io/session-id"] !== expected.sessionId) return false;
const conditions = job.status?.conditions ?? [];
const terminal = conditions.some(
(c) => (c.type === "Complete" || c.type === "Failed") && c.status === "True",
);
if (terminal) return false;
return true;
const jobTaskId = labels["paperclip.io/task-id"] ?? null;
const jobSessionId = labels["paperclip.io/session-id"] ?? null;
// taskId missing on either side
if (!expected.taskId || !jobTaskId) return "block_task_unknown";
// taskId mismatch
if (expected.taskId !== jobTaskId) return "block_task_mismatch";
// taskId matches — check sessionId
if (expected.sessionId && jobSessionId && expected.sessionId !== jobSessionId) {
return "block_session_mismatch";
}
return "reattach";
}
/**
@@ -341,6 +360,11 @@ export async function streamPodLogsOnce(
*
* Capped at MAX_LOG_RECONNECT_ATTEMPTS to prevent infinite reconnect
* loops during sustained API partitions.
*
* onFirstStreamExit is called the first time streamPodLogsOnce returns
* (container has exited or stream disconnected). Used by execute() to
* start the LOG_EXIT_COMPLETION_GRACE_MS grace timer (FAR-23) without
* waiting for all reconnects to exhaust.
*/
async function streamPodLogs(
namespace: string,
@@ -349,6 +373,7 @@ async function streamPodLogs(
kubeconfigPath?: string,
stopSignal?: { stopped: boolean },
dedup?: LogLineDedupFilter,
onFirstStreamExit?: () => void,
): Promise<string> {
const allChunks: string[] = [];
let attempt = 0;
@@ -379,6 +404,9 @@ async function streamPodLogs(
const preStreamTs = Math.floor(Date.now() / 1000);
const result = await streamPodLogsOnce(namespace, podName, onLog, kubeconfigPath, sinceSeconds, dedup, stopSignal);
// Signal first stream exit immediately so the grace-period timer in
// execute() can start without waiting for all reconnects to complete.
if (attempt === 0) onFirstStreamExit?.();
if (result) {
allChunks.push(result);
// Update last-received timestamp to now (the stream just ended,
@@ -525,6 +553,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
// the current runId. When reattachOrphanedJobs is enabled and the orphan matches
// the current agent+task+session, we attach to it instead of deleting it (FAR-124).
const agentId = ctx.agent.id;
const sanitizedAgentId = sanitizeLabelValue(agentId);
if (!sanitizedAgentId) {
await onLog("stderr", `[paperclip] Cannot create K8s Job: agent.id "${agentId}" produces no valid RFC 1123 label characters\n`);
return {
exitCode: null,
signal: null,
timedOut: false,
errorMessage: `Agent ID "${agentId}" cannot be sanitized to a valid Kubernetes label`,
errorCode: "k8s_agent_id_invalid",
};
}
const selfPod = await getSelfPodInfo(kubeconfigPath);
const guardNamespace = asString(config.namespace, "") || selfPod.namespace;
const reattachOrphanedJobs = asBoolean(config.reattachOrphanedJobs, true);
@@ -538,7 +577,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const batchApi = getBatchApi(kubeconfigPath);
const existing = await batchApi.listNamespacedJob({
namespace: guardNamespace,
labelSelector: `paperclip.io/agent-id=${agentId},paperclip.io/adapter-type=claude_k8s`,
labelSelector: `paperclip.io/agent-id=${sanitizedAgentId},paperclip.io/adapter-type=claude_k8s`,
});
const running = existing.items.filter(
(j) => !j.status?.conditions?.some((c) => (c.type === "Complete" || c.type === "Failed") && c.status === "True"),
@@ -553,45 +592,72 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
(j) => (j.metadata?.labels?.["paperclip.io/run-id"] ?? "") === runId,
);
// Pick the most recent reattachable orphan — same agent + task + session,
// not terminal. Only one target is chosen; any other orphans get
// cleaned up as before.
if (reattachOrphanedJobs && orphaned.length > 0) {
const candidates = orphaned
.filter((j) =>
isReattachableOrphan(j, {
agentId,
taskId: currentTaskLabel,
sessionId: currentSessionLabel,
}),
)
.sort((a, b) => {
const at = new Date(a.metadata?.creationTimestamp ?? 0).getTime();
const bt = new Date(b.metadata?.creationTimestamp ?? 0).getTime();
return bt - at;
});
const chosen = candidates[0];
const chosenName = chosen?.metadata?.name;
if (chosen && chosenName) {
reattachTarget = {
jobName: chosenName,
namespace: chosen.metadata?.namespace ?? guardNamespace,
priorRunId: chosen.metadata?.labels?.["paperclip.io/run-id"] ?? "",
image: chosen.spec?.template?.spec?.containers?.[0]?.image ?? "unknown",
if (orphaned.length > 0) {
if (!reattachOrphanedJobs) {
// When reattach is disabled, block on any non-terminal orphan.
const names = orphaned.map((j) => j.metadata?.name).join(", ");
await onLog("stderr", `[paperclip] Concurrent run blocked: orphaned Job(s) running and reattach disabled: ${names}\n`);
return {
exitCode: null,
signal: null,
timedOut: false,
errorMessage: `Concurrent run blocked: orphaned Job(s) still running for this agent (reattach disabled)`,
errorCode: "k8s_concurrent_run_blocked",
};
}
}
const toDelete = orphaned.filter(
(j) => !reattachTarget || j.metadata?.name !== reattachTarget.jobName,
);
if (toDelete.length > 0) {
const orphanNames = toDelete.map((j) => j.metadata?.name).join(", ");
await onLog("stdout", `[paperclip] Cleaning up ${toDelete.length} orphaned K8s Job(s) from previous run(s): ${orphanNames}\n`);
for (const j of toDelete) {
const name = j.metadata?.name;
if (name) {
await cleanupJob(guardNamespace, name, onLog, kubeconfigPath);
// Apply the decision matrix to each orphan, newest-first. The first
// reattachable orphan becomes the target; any block classification
// stops the new run immediately. Orphans are never deleted here —
// terminal ones are cleaned up by TTL; live mismatches should not be
// killed because they may still be doing real work.
const sortedOrphans = [...orphaned].sort((a, b) => {
const at = new Date(a.metadata?.creationTimestamp ?? 0).getTime();
const bt = new Date(b.metadata?.creationTimestamp ?? 0).getTime();
return bt - at;
});
for (const orphan of sortedOrphans) {
const classification = classifyOrphan(orphan, {
taskId: currentTaskLabel,
sessionId: currentSessionLabel,
});
const orphanName = orphan.metadata?.name ?? "unknown";
if (classification === "reattach") {
if (!reattachTarget) {
reattachTarget = {
jobName: orphanName,
namespace: orphan.metadata?.namespace ?? guardNamespace,
priorRunId: orphan.metadata?.labels?.["paperclip.io/run-id"] ?? "",
image: orphan.spec?.template?.spec?.containers?.[0]?.image ?? "unknown",
};
}
} else if (classification === "block_task_unknown") {
await onLog("stderr", `[paperclip] Blocked: orphaned Job ${orphanName} has missing task label — cannot safely reattach\n`);
return {
exitCode: null,
signal: null,
timedOut: false,
errorMessage: `Concurrent run blocked: orphaned Job ${orphanName} has unknown task context`,
errorCode: "k8s_orphan_task_unknown",
};
} else if (classification === "block_task_mismatch") {
await onLog("stderr", `[paperclip] Blocked: orphaned Job ${orphanName} belongs to a different task\n`);
return {
exitCode: null,
signal: null,
timedOut: false,
errorMessage: `Concurrent run blocked: orphaned Job ${orphanName} is running a different task`,
errorCode: "k8s_concurrent_run_blocked",
};
} else if (classification === "block_session_mismatch") {
await onLog("stderr", `[paperclip] Blocked: orphaned Job ${orphanName} has a different session\n`);
return {
exitCode: null,
signal: null,
timedOut: false,
errorMessage: `Concurrent run blocked: orphaned Job ${orphanName} has a mismatched session`,
errorCode: "k8s_orphan_session_mismatch",
};
}
}
}
@@ -690,6 +756,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
await onLog("stdout", `[paperclip] Reattaching to in-flight K8s Job ${jobName} in namespace ${namespace} (prior run ${reattachTarget.priorRunId || "unknown"})\n`);
// Relabel the reattached Job with the current run-id (and session-id if
// available) so the next concurrency guard sees it as owned by this run
// rather than an orphan from the prior run.
const labelPatch: Array<{ op: "add" | "replace"; path: string; value: string }> = [
{ op: "replace", path: "/metadata/labels/paperclip.io~1run-id", value: runId },
];
if (currentSessionLabel) {
labelPatch.push({ op: "replace", path: "/metadata/labels/paperclip.io~1session-id", value: currentSessionLabel });
}
try {
await batchApi.patchNamespacedJob({
name: jobName,
namespace,
body: labelPatch,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await onLog("stderr", `[paperclip] Warning: failed to relabel reattached Job ${jobName}: ${msg}\n`);
}
} else {
// Build Job manifest
const built = buildJobManifest({ ctx, selfPod, promptBundle });
@@ -700,6 +786,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const claudeArgs = built.claudeArgs;
const promptMetrics = built.promptMetrics;
promptSecret = built.promptSecret;
if (built.skippedLabels.length > 0) {
await onLog("stderr", `[paperclip] Warning: skipped ${built.skippedLabels.length} extra label(s) with reserved prefix: ${built.skippedLabels.join(", ")}\n`);
}
// Report invocation metadata
if (onMeta) {
@@ -839,16 +928,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
await onLog("stdout", `[paperclip] Pod running: ${podName}\n`);
}
// Notify the server that execution has started. This sets
// processStartedAt and refreshes updatedAt in the DB, which the
// stale-run reaper (reapOrphanedRuns) uses to decide liveness.
if (ctx.onSpawn) {
await ctx.onSpawn({
pid: process.pid, // Paperclip server PID — always alive while adapter runs in-process
processGroupId: null,
startedAt: new Date().toISOString(),
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const phase = reattachTarget ? "reattach" : "scheduling";
@@ -871,49 +950,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
// Keepalive: periodically send a status line via onLog so the
// Paperclip server knows the adapter is still alive even when the
// pod produces no output (e.g. Claude is in a long thinking phase).
//
// IMPORTANT: onLog alone does NOT update the run's updatedAt in the
// DB — it only appends to the log store and publishes SSE events.
// The stale-run reaper checks updatedAt, so we must also call
// onSpawn periodically to refresh it. Without this, multi-instance
// deployments can reap a live run from another server instance
// after the 5-minute staleness window.
//
// BUT: the keepalive must NEVER refresh updatedAt if the underlying
// K8s Job is already terminal. Otherwise, if execute() stalls after
// the pod finishes (e.g. a slow K8s API call, a hung log stream
// drain, or a Job whose Complete condition lags pod termination),
// we would keep the run marked "alive" indefinitely while the pod
// is actually gone — the exact "UI thinks jobs are running when
// they are not" bug. We verify Job liveness every tick and stop
// refreshing as soon as the Job reaches a terminal state; if
// execute() is truly stuck, the reaper will then catch it within
// the normal 5-minute staleness window.
let lastLogAt = Date.now();
let keepaliveTick = 0;
let keepaliveJobTerminal = false;
let keepaliveJobTerminalAt: number | null = null;
let consecutiveTerminalReadings = 0;
keepaliveTimer = setInterval(() => {
// Fire-and-forget the async work; setInterval callbacks must be
// synchronous or the timer will drift.
void (async () => {
if (keepaliveJobTerminal) {
// Post-terminal window: keep refreshing onSpawn during cleanup
// (job deletion, log parsing, K8s API calls) so the reaper doesn't
// fire a false process_lost while execute() is still running.
if (
ctx.onSpawn &&
keepaliveJobTerminalAt !== null &&
Date.now() - keepaliveJobTerminalAt <= POST_TERMINAL_KEEPALIVE_MS
) {
keepaliveTick++;
if (keepaliveTick % 6 === 0) {
void ctx.onSpawn({ pid: process.pid, processGroupId: null, startedAt: new Date().toISOString() }).catch(() => {});
}
}
return;
}
if (keepaliveJobTerminal) return;
// Verify the Job is still alive before announcing or refreshing.
// Require two consecutive terminal readings before latching to
@@ -928,16 +972,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
consecutiveTerminalReadings++;
if (consecutiveTerminalReadings >= 2) {
keepaliveJobTerminal = true;
keepaliveJobTerminalAt = Date.now();
if (ctx.onSpawn) {
void ctx.onSpawn({ pid: process.pid, processGroupId: null, startedAt: new Date().toISOString() }).catch(() => {});
}
return;
}
// First terminal reading — do not latch yet; next tick confirms.
keepaliveTick++;
if (ctx.onSpawn && (keepaliveTick === 1 || keepaliveTick % 12 === 0)) {
void ctx.onSpawn({ pid: process.pid, processGroupId: null, startedAt: new Date().toISOString() }).catch(() => {});
}
return;
}
@@ -949,10 +983,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
// window as a safety net.
if (isK8s404(err)) {
keepaliveJobTerminal = true;
keepaliveJobTerminalAt = Date.now();
if (ctx.onSpawn) {
void ctx.onSpawn({ pid: process.pid, processGroupId: null, startedAt: new Date().toISOString() }).catch(() => {});
}
return;
}
// Log transient errors but leave keepaliveJobTerminal false so
@@ -964,14 +994,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const silenceSec = Math.round((Date.now() - lastLogAt) / 1000);
void onLog("stdout", `[paperclip] keepalive — job ${jobName} running (${silenceSec}s since last output)\n`).catch(() => {});
// Refresh updatedAt every ~3 minutes (12 ticks × 15s = 180s) to
// stay well within the 5-minute reaper staleness window. Also
// fire on tick 1 for an early safety margin after job start.
keepaliveTick++;
if (ctx.onSpawn && (keepaliveTick === 1 || keepaliveTick % 12 === 0)) {
void ctx.onSpawn({ pid: process.pid, processGroupId: null, startedAt: new Date().toISOString() }).catch(() => {});
}
})();
}, KEEPALIVE_INTERVAL_MS);
const wrappedOnLog: typeof onLog = async (stream, chunk) => {
@@ -986,18 +1008,60 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
// reuse it and avoid pushing already-sent lines to the UI (finding #6, FAR-15).
const logDedup = new LogLineDedupFilter();
const [logResult, completionResult] = await Promise.allSettled([
streamPodLogs(namespace, podName, wrappedOnLog, kubeconfigPath, logStopSignal, logDedup),
waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath).then((r) => {
// Track when the log stream first exits so the grace-period can fire
// if the K8s Job condition lags behind container exit (FAR-23).
// Set via onFirstStreamExit callback (called after attempt=0 returns)
// rather than in .then() of streamPodLogs, which would create a
// deadlock: streamPodLogs only resolves after stopSignal is set, but
// stopSignal is set by the grace timer which needs logExitTime to be
// non-null.
let logExitTime: number | null = null;
const trackedLogStream = streamPodLogs(
namespace, podName, wrappedOnLog, kubeconfigPath, logStopSignal, logDedup,
() => { logExitTime = Date.now(); },
);
// completionWithGrace races waitForJobCompletion against a grace timer
// that fires LOG_EXIT_COMPLETION_GRACE_MS after the log stream exits.
// This bounds the stale-UI window when K8s Job conditions lag container
// exit (FAR-23): without it, waitForJobCompletion polls indefinitely
// while streamPodLogs reconnects, holding execute() open for minutes.
// logStopSignal.stopped is set on every settled path (fulfilled, rejected,
// or grace) so streamPodLogs stops reconnecting promptly.
type CompletionResult = { succeeded: boolean; timedOut: boolean; jobGone?: boolean };
let gracePoller: ReturnType<typeof setInterval> | null = null;
const completionWithGrace = new Promise<CompletionResult>((resolve, reject) => {
let settled = false;
const settleOk = (r: CompletionResult) => {
if (settled) return;
settled = true;
if (gracePoller) { clearInterval(gracePoller); gracePoller = null; }
logStopSignal.stopped = true;
return r;
}),
resolve(r);
};
const settleErr = (err: unknown) => {
if (settled) return;
settled = true;
if (gracePoller) { clearInterval(gracePoller); gracePoller = null; }
logStopSignal.stopped = true;
reject(err);
};
waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath).then(settleOk).catch(settleErr);
gracePoller = setInterval(() => {
if (logExitTime !== null && Date.now() - logExitTime >= LOG_EXIT_COMPLETION_GRACE_MS) {
void onLog("stdout", `[paperclip] Log stream exited ${LOG_EXIT_COMPLETION_GRACE_MS / 1000}s ago without K8s Job condition update — proceeding with captured output (FAR-23)\n`).catch(() => {});
settleOk({ succeeded: false, timedOut: false, jobGone: true });
}
}, 1_000);
});
const [logResult, completionResult] = await Promise.allSettled([
trackedLogStream,
completionWithGrace,
]);
// Stop the keepalive immediately once the job has reached a terminal
// state — do not wait for the finally block. Any K8s API call or
// cleanup that happens after this point should not keep the run
// marked "alive" in the DB via onSpawn refreshes.
// state — do not wait for the finally block.
if (keepaliveTimer) {
clearInterval(keepaliveTimer);
keepaliveTimer = null;
+5 -1
View File
@@ -33,9 +33,13 @@ export function createServerAdapter(): ServerAdapterModule {
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
// Tells the reaper to skip local PID checks and use the staleness-based
// liveness window instead (adapter spawns K8s Jobs in separate pods).
// Cast required: adapter-utils ServerAdapterModule type predates this field.
hasOutOfProcessLiveness: true,
agentConfigurationDoc,
getConfigSchema,
};
} as ServerAdapterModule;
}
export { execute, testEnvironment, sessionCodec };
+69
View File
@@ -166,6 +166,75 @@ describe("buildJobManifest", () => {
expect(job.metadata?.labels?.["paperclip.io/task-id"]).toBeUndefined();
expect(job.metadata?.labels?.["paperclip.io/session-id"]).toBeUndefined();
});
it("drops user label with paperclip.io/ prefix", () => {
ctx.config = { labels: { "paperclip.io/run-id": "hijacked" } };
const { job, skippedLabels } = buildJobManifest({ ctx, selfPod });
expect(job.metadata?.labels?.["paperclip.io/run-id"]).not.toBe("hijacked");
expect(skippedLabels).toContain("paperclip.io/run-id");
});
it("drops user label with app.kubernetes.io/ prefix", () => {
ctx.config = { labels: { "app.kubernetes.io/managed-by": "attacker" } };
const { job, skippedLabels } = buildJobManifest({ ctx, selfPod });
expect(job.metadata?.labels?.["app.kubernetes.io/managed-by"]).toBe("paperclip");
expect(skippedLabels).toContain("app.kubernetes.io/managed-by");
});
it("passes through user label without reserved prefix", () => {
ctx.config = { labels: { "custom.io/team": "platform" } };
const { job, skippedLabels } = buildJobManifest({ ctx, selfPod });
expect(job.metadata?.labels?.["custom.io/team"]).toBe("platform");
expect(skippedLabels).not.toContain("custom.io/team");
});
it("populates skippedLabels with all dropped keys", () => {
ctx.config = {
labels: {
"paperclip.io/agent-id": "x",
"app.kubernetes.io/component": "y",
"safe": "z",
},
};
const { skippedLabels } = buildJobManifest({ ctx, selfPod });
expect(skippedLabels).toHaveLength(2);
expect(skippedLabels).toContain("paperclip.io/agent-id");
expect(skippedLabels).toContain("app.kubernetes.io/component");
});
});
describe("system label sanitization (N4)", () => {
it("sanitizes agent.id with @ to a valid RFC 1123 label", () => {
ctx.agent.id = "user@example.com";
const { job } = buildJobManifest({ ctx, selfPod });
const label = job.metadata?.labels?.["paperclip.io/agent-id"];
expect(label).toMatch(/^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/);
expect(label).not.toContain("@");
});
it("sanitizes agent.id with spaces to a valid RFC 1123 label", () => {
ctx.agent.id = "my agent id";
const { job } = buildJobManifest({ ctx, selfPod });
const label = job.metadata?.labels?.["paperclip.io/agent-id"];
expect(label).toMatch(/^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/);
});
it("omits paperclip.io/run-id when sanitized value is null (all-invalid runId)", () => {
// inject an all-special-chars runId via context override — buildJobManifest
// uses ctx.runId directly
const badCtx = makeCtx({ runId: "@@@" });
const { job, skippedLabels } = buildJobManifest({ ctx: badCtx, selfPod });
expect(job.metadata?.labels?.["paperclip.io/run-id"]).toBeUndefined();
expect(skippedLabels).toContain("paperclip.io/run-id");
});
it("selector matches sanitized agent-id label", () => {
ctx.agent.id = "Agent@Test";
const { job } = buildJobManifest({ ctx, selfPod });
const agentLabel = job.metadata?.labels?.["paperclip.io/agent-id"];
// the label should equal what sanitizeLabelValue produces
expect(agentLabel).toBe("AgentTest");
});
});
describe("annotations", () => {
+19 -6
View File
@@ -200,6 +200,8 @@ export interface JobBuildResult {
/** Non-null when the prompt is too large for an env var and must be
* staged as a K8s Secret before creating the Job. */
promptSecret: PromptSecret | null;
/** User-supplied extra labels that were dropped because they used a reserved prefix. */
skippedLabels: string[];
}
function sanitizeForK8sName(value: string, maxLen = 16): string {
@@ -442,15 +444,22 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
},
};
// Labels
// Labels — system identifiers must pass RFC 1123 label value format.
const sanitizedAgentId = sanitizeLabelValue(agent.id);
const sanitizedRunId = sanitizeLabelValue(runId);
const sanitizedCompanyId = sanitizeLabelValue(agent.companyId);
const skippedLabels: string[] = [];
if (!sanitizedRunId) skippedLabels.push("paperclip.io/run-id");
if (!sanitizedCompanyId) skippedLabels.push("paperclip.io/company-id");
const labels: Record<string, string> = {
"app.kubernetes.io/managed-by": "paperclip",
"app.kubernetes.io/component": "agent-job",
"paperclip.io/agent-id": agent.id,
"paperclip.io/run-id": runId,
"paperclip.io/company-id": agent.companyId,
// sanitizedAgentId null-check is enforced in execute.ts before Job creation
"paperclip.io/agent-id": sanitizedAgentId ?? agent.id,
"paperclip.io/adapter-type": "claude_k8s",
};
if (sanitizedRunId) labels["paperclip.io/run-id"] = sanitizedRunId;
if (sanitizedCompanyId) labels["paperclip.io/company-id"] = sanitizedCompanyId;
// Reattach-target labels: let a future execute() identify this Job as the
// continuation of the same logical unit of work (same task + same resume
// session) so it can attach to the running pod across a Paperclip restart
@@ -461,7 +470,11 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
const sessionLabel = runtimeSessionId ? sanitizeLabelValue(runtimeSessionId) : null;
if (sessionLabel) labels["paperclip.io/session-id"] = sessionLabel;
for (const [key, value] of Object.entries(extraLabels)) {
labels[key] = value;
if (key.startsWith("paperclip.io/") || key.startsWith("app.kubernetes.io/")) {
skippedLabels.push(key);
} else {
labels[key] = value;
}
}
// Volumes
@@ -628,5 +641,5 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
},
};
return { job, jobName, namespace, prompt, claudeArgs, promptMetrics, promptSecret };
return { job, jobName, namespace, prompt, claudeArgs, promptMetrics, promptSecret, skippedLabels };
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from "vitest";
import os from "node:os";
import path from "node:path";
import { prepareClaudePromptBundle } from "./prompt-cache.js";
const onLog = vi.fn();
describe("prepareClaudePromptBundle path traversal validation", () => {
const validArgs = {
skills: [],
instructionsContents: null,
onLog,
};
it("rejects companyId containing ..", async () => {
await expect(prepareClaudePromptBundle({ ...validArgs, companyId: ".." })).rejects.toThrow(/companyId/);
});
it("rejects companyId containing ../x", async () => {
await expect(prepareClaudePromptBundle({ ...validArgs, companyId: "../x" })).rejects.toThrow(/companyId/);
});
it("rejects companyId containing /", async () => {
await expect(prepareClaudePromptBundle({ ...validArgs, companyId: "a/b" })).rejects.toThrow(/companyId/);
});
it("rejects companyId containing backslash", async () => {
await expect(prepareClaudePromptBundle({ ...validArgs, companyId: "a\\b" })).rejects.toThrow(/companyId/);
});
it("rejects companyId containing null byte", async () => {
await expect(prepareClaudePromptBundle({ ...validArgs, companyId: "a\0b" })).rejects.toThrow(/companyId/);
});
it("rejects empty companyId", async () => {
await expect(prepareClaudePromptBundle({ ...validArgs, companyId: "" })).rejects.toThrow(/companyId/);
});
it("rejects whitespace-only companyId", async () => {
await expect(prepareClaudePromptBundle({ ...validArgs, companyId: " " })).rejects.toThrow(/companyId/);
});
it("accepts a valid companyId", async () => {
vi.stubEnv("PAPERCLIP_HOME", path.join(os.tmpdir(), `prompt-cache-test-${process.pid}`));
const result = await prepareClaudePromptBundle({ ...validArgs, companyId: "acme-co" });
expect(result.rootDir).toContain("acme-co");
vi.unstubAllEnvs();
});
});
+9
View File
@@ -21,6 +21,13 @@ export interface ClaudePromptBundle {
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
function validatePathComponent(value: string, fieldName: string): void {
if (value.trim().length === 0) throw new Error(`Invalid ${fieldName}: must not be empty`);
if (value.includes("/") || value.includes("\\")) throw new Error(`Invalid ${fieldName}: must not contain path separators`);
if (value.includes("..")) throw new Error(`Invalid ${fieldName}: must not contain ".."`);
if (value.includes("\0")) throw new Error(`Invalid ${fieldName}: must not contain null bytes`);
}
function resolveManagedClaudePromptCacheRoot(companyId: string): string {
const paperclipHome =
(typeof process.env.PAPERCLIP_HOME === "string" && process.env.PAPERCLIP_HOME.trim().length > 0
@@ -31,6 +38,8 @@ function resolveManagedClaudePromptCacheRoot(companyId: string): string {
(typeof process.env.PAPERCLIP_INSTANCE_ID === "string" && process.env.PAPERCLIP_INSTANCE_ID.trim().length > 0
? process.env.PAPERCLIP_INSTANCE_ID.trim()
: null) ?? DEFAULT_PAPERCLIP_INSTANCE_ID;
validatePathComponent(companyId, "companyId");
validatePathComponent(instanceId, "instanceId");
return path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "claude-prompt-cache");
}